Skip to content
Open
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
16 changes: 9 additions & 7 deletions src/specify_cli/workflows/expressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,20 +137,21 @@ def _filter_from_json(value: Any) -> Any:
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]
idx_match = re.match(r"^([\w-]+)\[(\d+)\]$", part)
# Handle list indexing: name[0], name[-1]
idx_match = re.match(r"^([\w-]+)\[(-?\d+)\]$", 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 Expand Up @@ -1047,8 +1048,9 @@ def _has_incomplete_operand(text: str) -> bool:
# None, so a correction built on one turns a truthy condition false.
_NAMESPACE_ROOTS = ("inputs", "steps", "item", "fan_in", "context")

# Exactly what _resolve_dot_path accepts: a name, optionally one numeric index.
_PATH_SEGMENT = re.compile(r"^[\w-]+(\[\d+\])?$")
# Exactly what _resolve_dot_path accepts: a name, optionally one index, which
# may be negative.
_PATH_SEGMENT = re.compile(r"^[\w-]+(\[-?\d+\])?$")


class _ProbeNamespace(dict):
Expand Down Expand Up @@ -1213,7 +1215,7 @@ def _unresolvable_term(text: str) -> str | None:
# branch returns None for those however it is written -- so the index is
# stripped for `item` alone rather than for roots in general.
root = segments[0].strip()
indexed_root = re.fullmatch(r"([\w-]+)\[\d+\]", root)
indexed_root = re.fullmatch(r"([\w-]+)\[-?\d+\]", root)
if indexed_root is not None and indexed_root.group(1) == "item":
root = indexed_root.group(1)
if root not in _NAMESPACE_ROOTS:
Expand Down
19 changes: 19 additions & 0 deletions tests/test_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -927,6 +927,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
5 changes: 4 additions & 1 deletion tests/unit/test_condition_expression_block.py
Original file line number Diff line number Diff line change
Expand Up @@ -804,7 +804,10 @@ def test_resolvable_filter_arguments_keep_the_correction(condition):
assert _wrapped_evaluates(condition)


@pytest.mark.parametrize("condition", ["item[0] == 'x'", "item[1] == 'y'"])
@pytest.mark.parametrize(
"condition",
["item[0] == 'x'", "item[1] == 'y'", "item[-1] == 'y'", "item[-2] == 'x'"],
)
def test_an_indexed_item_root_keeps_the_correction(condition):
"""`item` is the only root that is not always a mapping.

Expand Down