diff --git a/CHANGELOG.rst b/CHANGELOG.rst index f411867b..2f10ec71 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -3,7 +3,7 @@ Release Notes Forthcoming ----------- -* ... +* [decorators] a finally-style decorator, `#427 `_ 2.2.3 (2023-02-08) ------------------ diff --git a/docs/demos.rst b/docs/demos.rst index a0154cc4..6793a845 100644 --- a/docs/demos.rst +++ b/docs/demos.rst @@ -147,6 +147,22 @@ py-trees-demo-eternal-guard :linenos: :caption: py_trees/demos/eternal_guard.py +.. _py-trees-demo-finally-program: + +py-trees-demo-finally +--------------------- + +.. automodule:: py_trees.demos.decorator_finally + :members: + :special-members: + :show-inheritance: + :synopsis: demo the finally-like decorator + +.. literalinclude:: ../py_trees/demos/decorator_finally.py + :language: python + :linenos: + :caption: py_trees/demos/decorator_finally.py + .. _py-trees-demo-logging-program: py-trees-demo-logging diff --git a/docs/dot/demo-finally.dot b/docs/dot/demo-finally.dot new file mode 100644 index 00000000..12ee71f3 --- /dev/null +++ b/docs/dot/demo-finally.dot @@ -0,0 +1,17 @@ +digraph pastafarianism { +ordering=out; +graph [fontname="times-roman"]; +node [fontname="times-roman"]; +edge [fontname="times-roman"]; +root [fillcolor=orange, fontcolor=black, fontsize=9, label="Ⓜ root", shape=box, style=filled]; +SetFlagFalse [fillcolor=gray, fontcolor=black, fontsize=9, label=SetFlagFalse, shape=ellipse, style=filled]; +root -> SetFlagFalse; +Parallel [fillcolor=gold, fontcolor=black, fontsize=9, label="Parallel\nSuccessOnOne", shape=parallelogram, style=filled]; +root -> Parallel; +Counter [fillcolor=gray, fontcolor=black, fontsize=9, label=Counter, shape=ellipse, style=filled]; +Parallel -> Counter; +Finally [fillcolor=ghostwhite, fontcolor=black, fontsize=9, label=Finally, shape=ellipse, style=filled]; +Parallel -> Finally; +SetFlagTrue [fillcolor=gray, fontcolor=black, fontsize=9, label=SetFlagTrue, shape=ellipse, style=filled]; +Finally -> SetFlagTrue; +} diff --git a/docs/images/finally.png b/docs/images/finally.png new file mode 100644 index 00000000..b8e5cdee Binary files /dev/null and b/docs/images/finally.png differ diff --git a/py_trees/behaviour.py b/py_trees/behaviour.py index 0cfd16d1..5974b65b 100644 --- a/py_trees/behaviour.py +++ b/py_trees/behaviour.py @@ -344,7 +344,7 @@ def iterate(self, direct_descendants: bool = False) -> typing.Iterator[Behaviour yield child yield self - # TODO: better type refinement of 'viso=itor' + # TODO: better type refinement of 'visitor' def visit(self, visitor: typing.Any) -> None: """ Introspect on this behaviour with a visitor. diff --git a/py_trees/behaviours.py b/py_trees/behaviours.py index 6fbd3df1..c52c6608 100644 --- a/py_trees/behaviours.py +++ b/py_trees/behaviours.py @@ -280,6 +280,7 @@ def update(self) -> common.Status: :data:`~py_trees.common.Status.RUNNING` while not expired, the given completion status otherwise """ self.counter += 1 + self.feedback_message = f"count: {self.counter}" if self.counter <= self.duration: return common.Status.RUNNING else: diff --git a/py_trees/decorators.py b/py_trees/decorators.py index c984e16f..a869136d 100644 --- a/py_trees/decorators.py +++ b/py_trees/decorators.py @@ -36,6 +36,7 @@ * :class:`py_trees.decorators.Condition` * :class:`py_trees.decorators.Count` * :class:`py_trees.decorators.EternalGuard` +* :class:`py_trees.decorators.Finally` * :class:`py_trees.decorators.Inverter` * :class:`py_trees.decorators.OneShot` * :class:`py_trees.decorators.Repeat` @@ -920,3 +921,89 @@ def update(self) -> common.Status: the behaviour's new status :class:`~py_trees.common.Status` """ return self.decorated.status + + +class Finally(Decorator): + """ + Implement a 'finally'-like pattern with behaviours. + + This implements a pattern + Always return :data:`~py_trees.common.Status.RUNNING` and + on :meth:`terminate`, call the child's :meth:`~py_trees.behaviour.Behaviour.update` + method, once. The return status of the child is unused as both decorator + and child will be in the process of terminating with status + :data:`~py_trees.common.Status.INVALID`. + + This decorator is usually used underneath a parallel with a sibling + that represents the 'try' part of the behaviour. + + .. code-block:: + + /_/ Parallel + --> Work + -^- Finally (Decorator) + --> Finally (Implementation) + + .. seealso:: :ref:`py-trees-demo-finally-program` + + NB: If you need to persist the execution of the 'finally'-like block for more + than a single tick, you'll need to build that explicitly into your tree. There + are various ways of doing so (with and without the blackboard). One pattern + that works: + + .. code-block:: + + [o] Selector + {-} Sequence + --> Work + --> Finally (Triggers on Success) + {-} Sequence + --> Finally (Triggers on Failure) + --> Failure + """ + + def __init__(self, name: str, child: behaviour.Behaviour): + """ + Initialise with the standard decorator arguments. + + Args: + name: the decorator name + child: the child to be decorated + """ + super(Finally, self).__init__(name=name, child=child) + + def tick(self) -> typing.Iterator[behaviour.Behaviour]: + """ + Bypass the child when ticking. + + Yields: + a reference to itself + """ + self.logger.debug(f"{self.__class__.__name__}.tick()") + self.status = self.update() + yield self + + def update(self): + """ + Return with :data:`~py_trees.common.Status.RUNNING`. + + Returns: + the behaviour's new status :class:`~py_trees.common.Status` + """ + return common.Status.RUNNING + + def terminate(self, new_status: common.Status) -> None: + """Tick the child behaviour once.""" + self.logger.debug( + "{}.terminate({})".format( + self.__class__.__name__, + "{}->{}".format(self.status, new_status) + if self.status != new_status + else f"{new_status}", + ) + ) + if new_status == common.Status.INVALID: + self.decorated.tick_once() + # Do not need to stop the child here - this method + # is only called by Decorator.stop() which will handle + # that responsibility immediately after this method returns. diff --git a/py_trees/demos/__init__.py b/py_trees/demos/__init__.py index fc9d44e6..b896a666 100644 --- a/py_trees/demos/__init__.py +++ b/py_trees/demos/__init__.py @@ -21,6 +21,7 @@ from . import display_modes # usort:skip # noqa: F401 from . import dot_graphs # usort:skip # noqa: F401 from . import either_or # usort:skip # noqa: F401 +from . import decorator_finally # usort:skip # noqa: F401 from . import lifecycle # usort:skip # noqa: F401 from . import selector # usort:skip # noqa: F401 from . import sequence # usort:skip # noqa: F401 diff --git a/py_trees/demos/decorator_finally.py b/py_trees/demos/decorator_finally.py new file mode 100644 index 00000000..56010622 --- /dev/null +++ b/py_trees/demos/decorator_finally.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python +# +# License: BSD +# https://raw.githubusercontent.com/splintered-reality/py_trees/devel/LICENSE +# +############################################################################## +# Documentation +############################################################################## + +""" +Trigger 'finally'-like behaviour with :class:`~py_trees.decorators.Finally`. + +.. argparse:: + :module: py_trees.demos.decorator_finally + :func: command_line_argument_parser + :prog: py-trees-demo-finally + +.. graphviz:: dot/demo-finally.dot + +.. image:: images/finally.png + +""" + +############################################################################## +# Imports +############################################################################## + +import argparse +import sys +import typing + +import py_trees +import py_trees.console as console + +############################################################################## +# Classes +############################################################################## + + +def description(root: py_trees.behaviour.Behaviour) -> str: + """ + Print description and usage information about the program. + + Returns: + the program description string + """ + content = ( + "Trigger python-like 'finally' behaviour with the 'Finally' decorator.\n\n" + ) + content += "A blackboard flag is set to false prior to commencing work. \n" + content += "Once the work terminates, the decorator and it's child\n" + content += "child will also terminate and toggle the flag to true.\n" + content += "\n" + content += "The demonstration is run twice - on the first occasion\n" + content += "the work terminates with SUCCESS and on the second, it\n" + content + "terminates with FAILURE\n" + content += "\n" + content += "EVENTS\n" + content += "\n" + content += " - 1 : flag is set to false, worker is running\n" + content += " - 2 : worker completes (with SUCCESS||FAILURE)\n" + content += " - 2 : finally is triggered, flag is set to true\n" + content += "\n" + if py_trees.console.has_colours: + banner_line = console.green + "*" * 79 + "\n" + console.reset + s = banner_line + s += console.bold_white + "Finally".center(79) + "\n" + console.reset + s += banner_line + s += "\n" + s += content + s += "\n" + s += banner_line + else: + s = content + return s + + +def epilog() -> typing.Optional[str]: + """ + Print a noodly epilog for --help. + + Returns: + the noodly message + """ + if py_trees.console.has_colours: + return ( + console.cyan + + "And his noodly appendage reached forth to tickle the blessed...\n" + + console.reset + ) + else: + return None + + +def command_line_argument_parser() -> argparse.ArgumentParser: + """ + Process command line arguments. + + Returns: + the argument parser + """ + parser = argparse.ArgumentParser( + description=description(create_root(py_trees.common.Status.SUCCESS)), + epilog=epilog(), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + group = parser.add_mutually_exclusive_group() + group.add_argument( + "-r", "--render", action="store_true", help="render dot tree to file" + ) + return parser + + +def create_root( + expected_work_termination_result: py_trees.common.Status, +) -> py_trees.behaviour.Behaviour: + """ + Create the root behaviour and it's subtree. + + Returns: + the root behaviour + """ + root = py_trees.composites.Sequence(name="root", memory=True) + set_flag_to_false = py_trees.behaviours.SetBlackboardVariable( + name="SetFlagFalse", + variable_name="flag", + variable_value=False, + overwrite=True, + ) + set_flag_to_true = py_trees.behaviours.SetBlackboardVariable( + name="SetFlagTrue", variable_name="flag", variable_value=True, overwrite=True + ) + parallel = py_trees.composites.Parallel( + name="Parallel", + policy=py_trees.common.ParallelPolicy.SuccessOnOne(), + children=[], + ) + worker = py_trees.behaviours.TickCounter( + name="Counter", duration=1, completion_status=expected_work_termination_result + ) + well_finally = py_trees.decorators.Finally(name="Finally", child=set_flag_to_true) + parallel.add_children([worker, well_finally]) + root.add_children([set_flag_to_false, parallel]) + return root + + +############################################################################## +# Main +############################################################################## + + +def main() -> None: + """Entry point for the demo script.""" + args = command_line_argument_parser().parse_args() + # py_trees.logging.level = py_trees.logging.Level.DEBUG + print(description(create_root(py_trees.common.Status.SUCCESS))) + + #################### + # Rendering + #################### + if args.render: + py_trees.display.render_dot_tree(create_root(py_trees.common.Status.SUCCESS)) + sys.exit() + + for status in (py_trees.common.Status.SUCCESS, py_trees.common.Status.FAILURE): + py_trees.blackboard.Blackboard.clear() + console.banner(f"Experiment - Terminate with {status}") + root = create_root(status) + root.tick_once() + print(py_trees.display.unicode_tree(root=root, show_status=True)) + print(py_trees.display.unicode_blackboard()) + root.tick_once() + print(py_trees.display.unicode_tree(root=root, show_status=True)) + print(py_trees.display.unicode_blackboard()) + + print("\n") diff --git a/pyproject.toml b/pyproject.toml index 494a7708..00744c73 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,6 +66,7 @@ py-trees-demo-display-modes = "py_trees.demos.display_modes:main" py-trees-demo-dot-graphs = "py_trees.demos.dot_graphs:main" py-trees-demo-either-or = "py_trees.demos.either_or:main" py-trees-demo-eternal-guard = "py_trees.demos.eternal_guard:main" +py-trees-demo-finally = "py_trees.demos.decorator_finally:main" py-trees-demo-logging = "py_trees.demos.logging:main" py-trees-demo-pick-up-where-you-left-off = "py_trees.demos.pick_up_where_you_left_off:main" py-trees-demo-selector = "py_trees.demos.selector:main" diff --git a/tests/test_decorators.py b/tests/test_decorators.py index c9a8b367..3bdac0a4 100644 --- a/tests/test_decorators.py +++ b/tests/test_decorators.py @@ -604,3 +604,44 @@ def test_status_to_blackboard() -> None: result=decorator.status, ) assert decorator.status == py_trees.common.Status.SUCCESS + + +def test_finally() -> None: + console.banner("Finally") + + blackboard = py_trees.blackboard.Client() + blackboard.register_key(key="flag", access=py_trees.common.Access.WRITE) + blackboard.flag = False + + child = py_trees.behaviours.SetBlackboardVariable( + name="SetFlag", variable_name="flag", variable_value=True, overwrite=True + ) + well_finally = py_trees.decorators.Finally(name="Finally", child=child) + worker = py_trees.behaviours.TickCounter( + name="Counter-1", duration=1, completion_status=py_trees.common.Status.SUCCESS + ) + parallel = py_trees.composites.Parallel( + name="Parallel", + policy=py_trees.common.ParallelPolicy.SuccessOnOne(), + children=[worker, well_finally], + ) + parallel.tick_once() + py_trees.tests.print_assert_banner() + py_trees.tests.print_assert_details( + text="BB Variable (flag)", + expected=False, + result=blackboard.flag, + ) + print(py_trees.display.unicode_tree(root=parallel, show_status=True)) + assert not blackboard.flag + + parallel.tick_once() + py_trees.tests.print_assert_banner() + py_trees.tests.print_assert_details( + text="BB Variable (flag)", + expected=True, + result=blackboard.flag, + ) + print(py_trees.display.unicode_tree(root=parallel, show_status=True)) + + assert blackboard.flag