Skip to content

Commit ea6c337

Browse files
authored
Add ForEach decorator to iterate over a list (#476)
1 parent 4953156 commit ea6c337

File tree

2 files changed

+230
-0
lines changed

2 files changed

+230
-0
lines changed

py_trees/decorators.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
* :class:`py_trees.decorators.Condition`
3737
* :class:`py_trees.decorators.Count`
3838
* :class:`py_trees.decorators.EternalGuard`
39+
* :class:`py_trees.decorators.ForEach`
3940
* :class:`py_trees.decorators.Inverter`
4041
* :class:`py_trees.decorators.OneShot`
4142
* :class:`py_trees.decorators.Repeat`
@@ -80,6 +81,7 @@
8081
import inspect
8182
import time
8283
import typing
84+
from collections.abc import Iterable
8385

8486
from . import behaviour, blackboard, common
8587

@@ -920,3 +922,74 @@ def update(self) -> common.Status:
920922
the behaviour's new status :class:`~py_trees.common.Status`
921923
"""
922924
return self.decorated.status
925+
926+
927+
class ForEach(Decorator):
928+
"""
929+
Run the child behavior for each item in an iterable.
930+
931+
On initialization, the iterable is loaded from the blackboard and an
932+
iterator is created. Every time the child succeeds, we advance to
933+
the next item. We keep running until the iterable is exhausted.
934+
"""
935+
936+
def __init__(
937+
self, name: str, child: behaviour.Behaviour, source_key: str, target_key: str
938+
):
939+
"""
940+
Initialise the ForEach decorator.
941+
942+
Args:
943+
name (:obj:`str`): name of the behaviour
944+
child (:obj:`Behaviour`): the child behaviour to decorate
945+
source_key (:obj:`str`): blackboard key to read the input iterable
946+
target_key (:obj:`str`): blackboard key to set for each iteration
947+
"""
948+
super().__init__(name=name, child=child)
949+
self.source_key = source_key
950+
self.target_key = target_key
951+
self.blackboard = blackboard.Client(name=name)
952+
self.blackboard.register_key(key=self.source_key, access=common.Access.READ)
953+
self.blackboard.register_key(key=self.target_key, access=common.Access.WRITE)
954+
self._iterator: typing.Iterator | None = None
955+
self._current_item: typing.Any | None = None
956+
957+
def initialise(self) -> None:
958+
"""Reset iteration on first tick."""
959+
iterable = self.blackboard.get(self.source_key) or []
960+
if not isinstance(iterable, Iterable):
961+
raise TypeError(
962+
f"[{self.name}] source_key '{self.source_key}' is not an iterable"
963+
)
964+
self._iterator = iter(iterable)
965+
self._advance()
966+
967+
def _advance(self) -> None:
968+
"""Advance to the next item in the iterator, or mark as finished."""
969+
try:
970+
if self._iterator is not None:
971+
self._current_item = next(self._iterator)
972+
self.blackboard.set(self.target_key, self._current_item)
973+
except StopIteration:
974+
self._current_item = None
975+
976+
def update(self) -> common.Status:
977+
"""Execute the child for the current item and manage iteration state."""
978+
if self._current_item is None:
979+
# no more items left
980+
return common.Status.SUCCESS
981+
982+
child_status = self.decorated.status
983+
984+
if child_status == common.Status.SUCCESS:
985+
# move to next item
986+
self._advance()
987+
if self._current_item is not None:
988+
return common.Status.RUNNING
989+
990+
return child_status
991+
992+
def terminate(self, new_status: common.Status) -> None:
993+
"""Reset on termination."""
994+
self._iterator = None
995+
self._current_item = None

tests/test_decorators.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
##############################################################################
1010

1111
import time
12+
import typing
1213

1314
import py_trees
1415
import py_trees.console as console
@@ -604,3 +605,159 @@ def test_status_to_blackboard() -> None:
604605
result=decorator.status,
605606
)
606607
assert decorator.status == py_trees.common.Status.SUCCESS
608+
609+
610+
@pytest.mark.parametrize(
611+
"iterable",
612+
[
613+
[1, 2, 3], # list
614+
(1, 2, 3), # tuple
615+
(i for i in range(1, 4)), # generator
616+
],
617+
ids=["list", "tuple", "generator"],
618+
)
619+
def test_for_each_fixed(iterable: typing.Iterable[int]) -> None:
620+
console.banner("ForEach - 3 elements on multiple iterables")
621+
child = py_trees.behaviours.StatusQueue(
622+
name="R-S",
623+
queue=[
624+
py_trees.common.Status.RUNNING,
625+
py_trees.common.Status.SUCCESS,
626+
],
627+
eventually=None,
628+
)
629+
blackboard = py_trees.blackboard.Client()
630+
blackboard.register_key(key="element", access=py_trees.common.Access.READ)
631+
blackboard.register_key(key="iterable", access=py_trees.common.Access.WRITE)
632+
blackboard.iterable = iterable
633+
decorator = py_trees.decorators.ForEach(
634+
name="ForEach", child=child, source_key="iterable", target_key="element"
635+
)
636+
637+
decorator.tick_once() # run first element
638+
print("\n--------- Tick 1 ---------\n")
639+
print("decorator.status == py_trees.common.Status.RUNNING")
640+
assert decorator.status == py_trees.common.Status.RUNNING
641+
print("child.status == py_trees.common.Status.RUNNING")
642+
assert child.status == py_trees.common.Status.RUNNING
643+
assert blackboard.element == 1
644+
645+
decorator.tick_once() # complete first element; load second
646+
print("\n--------- Tick 2 ---------\n")
647+
print("decorator.status == py_trees.common.Status.RUNNING")
648+
assert decorator.status == py_trees.common.Status.RUNNING
649+
print("child.status == py_trees.common.Status.SUCCESS")
650+
assert child.status == py_trees.common.Status.SUCCESS
651+
assert blackboard.element == 2 # type: ignore[unreachable]
652+
653+
decorator.tick_once() # run second element
654+
print("\n--------- Tick 3 ---------\n")
655+
print("decorator.status == py_trees.common.Status.RUNNING")
656+
assert decorator.status == py_trees.common.Status.RUNNING
657+
print("child.status == py_trees.common.Status.RUNNING")
658+
assert child.status == py_trees.common.Status.RUNNING
659+
assert blackboard.element == 2
660+
661+
decorator.tick_once() # complete second element; load third
662+
print("\n--------- Tick 4 ---------\n")
663+
print("decorator.status == py_trees.common.Status.RUNNING")
664+
assert decorator.status == py_trees.common.Status.RUNNING
665+
print("child.status == py_trees.common.Status.SUCCESS")
666+
assert child.status == py_trees.common.Status.SUCCESS
667+
assert blackboard.element == 3
668+
669+
decorator.tick_once() # run third element
670+
print("\n--------- Tick 5 ---------\n")
671+
print("decorator.status == py_trees.common.Status.RUNNING")
672+
assert decorator.status == py_trees.common.Status.RUNNING
673+
print("child.status == py_trees.common.Status.RUNNING")
674+
assert child.status == py_trees.common.Status.RUNNING
675+
assert blackboard.element == 3
676+
677+
decorator.tick_once() # complete third element
678+
print("\n--------- Tick 6 ---------\n")
679+
print("decorator.status == py_trees.common.Status.SUCCESS")
680+
assert decorator.status == py_trees.common.Status.SUCCESS
681+
print("child.status == py_trees.common.Status.SUCCESS")
682+
assert child.status == py_trees.common.Status.SUCCESS
683+
assert blackboard.element == 3
684+
685+
686+
def test_for_each_append() -> None:
687+
console.banner("ForEach - 3 + 2 elements")
688+
child = py_trees.behaviours.StatusQueue(
689+
name="S",
690+
queue=[
691+
py_trees.common.Status.SUCCESS,
692+
],
693+
eventually=None,
694+
)
695+
blackboard = py_trees.blackboard.Client()
696+
blackboard.register_key(key="element", access=py_trees.common.Access.READ)
697+
blackboard.register_key(key="iterable", access=py_trees.common.Access.WRITE)
698+
blackboard.iterable = [1, 2, 3]
699+
decorator = py_trees.decorators.ForEach(
700+
name="ForEach", child=child, source_key="iterable", target_key="element"
701+
)
702+
703+
decorator.tick_once()
704+
print("\n--------- Tick 1 ---------\n")
705+
assert blackboard.element == 2
706+
707+
decorator.tick_once()
708+
print("\n--------- Tick 2 ---------\n")
709+
assert blackboard.element == 3
710+
711+
# add two elements, so we need two more ticks to succeed
712+
blackboard.iterable.extend([4, 5])
713+
714+
decorator.tick_once()
715+
print("\n--------- Tick 3 ---------\n")
716+
assert blackboard.element == 4
717+
718+
decorator.tick_once()
719+
print("\n--------- Tick 4 ---------\n")
720+
assert blackboard.element == 5
721+
722+
decorator.tick_once()
723+
print("\n--------- Tick 5 ---------\n")
724+
print("decorator.status == py_trees.common.Status.SUCCESS")
725+
assert decorator.status == py_trees.common.Status.SUCCESS
726+
print("child.status == py_trees.common.Status.SUCCESS")
727+
assert child.status == py_trees.common.Status.SUCCESS
728+
729+
730+
def test_for_each_delete() -> None:
731+
console.banner("ForEach - 4 + 1 elements")
732+
child = py_trees.behaviours.StatusQueue(
733+
name="S",
734+
queue=[
735+
py_trees.common.Status.SUCCESS,
736+
],
737+
eventually=None,
738+
)
739+
blackboard = py_trees.blackboard.Client()
740+
blackboard.register_key(key="element", access=py_trees.common.Access.READ)
741+
blackboard.register_key(key="iterable", access=py_trees.common.Access.WRITE)
742+
blackboard.iterable = [1, 2, 3, 4]
743+
decorator = py_trees.decorators.ForEach(
744+
name="ForEach", child=child, source_key="iterable", target_key="element"
745+
)
746+
747+
decorator.tick_once()
748+
print("\n--------- Tick 1 ---------\n")
749+
assert blackboard.element == 2
750+
751+
decorator.tick_once()
752+
print("\n--------- Tick 2 ---------\n")
753+
assert blackboard.element == 3
754+
755+
# remove last element, so we skip one tick to succeed
756+
blackboard.iterable.pop()
757+
758+
decorator.tick_once()
759+
print("\n--------- Tick 3 ---------\n")
760+
print("decorator.status == py_trees.common.Status.SUCCESS")
761+
assert decorator.status == py_trees.common.Status.SUCCESS
762+
print("child.status == py_trees.common.Status.SUCCESS")
763+
assert child.status == py_trees.common.Status.SUCCESS

0 commit comments

Comments
 (0)