diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 98c65cfc..5849c3d7 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -23,6 +23,7 @@ "bierner.markdown-preview-github-styles", "bungcip.better-toml", "eamodio.gitlens", + "joaompinto.vscode-graphviz", "ms-python.python", "omnilib.ufmt", "redhat.vscode-yaml", diff --git a/.devcontainer/py310/.devcontainer/devcontainer.json b/.devcontainer/py310/.devcontainer/devcontainer.json index 7a97b807..7668893c 100644 --- a/.devcontainer/py310/.devcontainer/devcontainer.json +++ b/.devcontainer/py310/.devcontainer/devcontainer.json @@ -19,11 +19,15 @@ "vscode": { "extensions": [ "bierner.github-markdown-preview", + "bierner.markdown-preview-github-styles", "bungcip.better-toml", - "streetsidesoftware.code-spell-checker", - "lextudio.restructuredtext", + "eamodio.gitlens", + "joaompinto.vscode-graphviz", "ms-python.python", - "omnilib.ufmt" + "omnilib.ufmt", + "redhat.vscode-yaml", + "streetsidesoftware.code-spell-checker", + "tht13.rst-vscode" ] } }, diff --git a/.vscode/settings.json b/.vscode/settings.json index b96bb6fa..49b2588c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -12,10 +12,15 @@ "behaviours", "bierner", "bungcip", + "epilog", + "graphviz", + "literalinclude", + "noodly", "omnilib", "py_trees", "pydot", "pypi", + "seealso", "ufmt", "usort" ] diff --git a/CHANGELOG.rst b/CHANGELOG.rst index f411867b..635bab5d 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -3,7 +3,7 @@ Release Notes Forthcoming ----------- -* ... +* [decorators] finally-style decorators and idioms, `#427 `_ 2.2.3 (2023-02-08) ------------------ diff --git a/docs/demos.rst b/docs/demos.rst index a0154cc4..8f7735bb 100644 --- a/docs/demos.rst +++ b/docs/demos.rst @@ -147,6 +147,38 @@ py-trees-demo-eternal-guard :linenos: :caption: py_trees/demos/eternal_guard.py +.. _py-trees-demo-eventually-program: + +py-trees-demo-eventually +------------------------ + +.. automodule:: py_trees.demos.eventually + :members: + :special-members: + :show-inheritance: + :synopsis: demo the eventually idiom + +.. literalinclude:: ../py_trees/demos/eventually.py + :language: python + :linenos: + :caption: py_trees/demos/eventually.py + +.. _py-trees-demo-eventually-swiss-program: + +py-trees-demo-eventually-swiss +------------------------------ + +.. automodule:: py_trees.demos.eventually_swiss + :members: + :special-members: + :show-inheritance: + :synopsis: demo the general purpose eventually idiom + +.. literalinclude:: ../py_trees/demos/eventually_swiss.py + :language: python + :linenos: + :caption: py_trees/demos/eventually_swiss.py + .. _py-trees-demo-logging-program: py-trees-demo-logging diff --git a/docs/dot/demo-eventually-swiss.dot b/docs/dot/demo-eventually-swiss.dot new file mode 100644 index 00000000..088dac4b --- /dev/null +++ b/docs/dot/demo-eventually-swiss.dot @@ -0,0 +1,19 @@ +digraph pastafarianism { +ordering=out; +graph [fontname="times-roman"]; +node [fontname="times-roman"]; +edge [fontname="times-roman"]; +"Count with Result" [fillcolor=cyan, fontcolor=black, fontsize=9, label="Count with Result", shape=octagon, style=filled]; +"Work to Success" [fillcolor=orange, fontcolor=black, fontsize=9, label="Ⓜ Work to Success", shape=box, style=filled]; +"Count with Result" -> "Work to Success"; +Counter [fillcolor=gray, fontcolor=black, fontsize=9, label=Counter, shape=ellipse, style=filled]; +"Work to Success" -> Counter; +SetResultTrue [fillcolor=gray, fontcolor=black, fontsize=9, label=SetResultTrue, shape=ellipse, style=filled]; +"Work to Success" -> SetResultTrue; +"On Failure" [fillcolor=orange, fontcolor=black, fontsize=9, label="Ⓜ On Failure", shape=box, style=filled]; +"Count with Result" -> "On Failure"; +SetResultFalse [fillcolor=gray, fontcolor=black, fontsize=9, label=SetResultFalse, shape=ellipse, style=filled]; +"On Failure" -> SetResultFalse; +Failure [fillcolor=gray, fontcolor=black, fontsize=9, label=Failure, shape=ellipse, style=filled]; +"On Failure" -> Failure; +} diff --git a/docs/dot/demo-eventually.dot b/docs/dot/demo-eventually.dot new file mode 100644 index 00000000..0711f6b4 --- /dev/null +++ b/docs/dot/demo-eventually.dot @@ -0,0 +1,17 @@ +digraph pastafarianism { +ordering=out; +graph [fontname="times-roman"]; +node [fontname="times-roman"]; +edge [fontname="times-roman"]; +"Count and Record" [fillcolor=gold, fontcolor=black, fontsize=9, label="Count and Record\nSuccessOnOne", shape=parallelogram, style=filled]; +Counting [fillcolor=orange, fontcolor=black, fontsize=9, label="Ⓜ Counting", shape=box, style=filled]; +"Count and Record" -> Counting; +SetCountingFlagTrue [fillcolor=gray, fontcolor=black, fontsize=9, label=SetCountingFlagTrue, shape=ellipse, style=filled]; +Counting -> SetCountingFlagTrue; +Counter [fillcolor=gray, fontcolor=black, fontsize=9, label=Counter, shape=ellipse, style=filled]; +Counting -> Counter; +Eventually [fillcolor=ghostwhite, fontcolor=black, fontsize=9, label=Eventually, shape=ellipse, style=filled]; +"Count and Record" -> Eventually; +SetCountingFlagFalse [fillcolor=gray, fontcolor=black, fontsize=9, label=SetCountingFlagFalse, shape=ellipse, style=filled]; +Eventually -> SetCountingFlagFalse; +} diff --git a/docs/dot/demo-finally-single-tick.dot b/docs/dot/demo-finally-single-tick.dot new file mode 100644 index 00000000..12ee71f3 --- /dev/null +++ b/docs/dot/demo-finally-single-tick.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/dot/eventually-swiss.dot b/docs/dot/eventually-swiss.dot new file mode 100644 index 00000000..914642f0 --- /dev/null +++ b/docs/dot/eventually-swiss.dot @@ -0,0 +1,19 @@ +digraph pastafarianism { +ordering=out; +graph [fontname="times-roman"]; +node [fontname="times-roman"]; +edge [fontname="times-roman"]; +"Eventually (Swiss)" [fillcolor=cyan, fontcolor=black, fontsize=9, label="Eventually (Swiss)", shape=octagon, style=filled]; +"Work to Success" [fillcolor=orange, fontcolor=black, fontsize=9, label="Ⓜ Work to Success", shape=box, style=filled]; +"Eventually (Swiss)" -> "Work to Success"; +Worker [fillcolor=gray, fontcolor=black, fontsize=9, label=Worker, shape=ellipse, style=filled]; +"Work to Success" -> Worker; +"On Success" [fillcolor=gray, fontcolor=black, fontsize=9, label="On Success", shape=ellipse, style=filled]; +"Work to Success" -> "On Success"; +"On Failure" [fillcolor=orange, fontcolor=black, fontsize=9, label="Ⓜ On Failure", shape=box, style=filled]; +"Eventually (Swiss)" -> "On Failure"; +"On Failure*" [fillcolor=gray, fontcolor=black, fontsize=9, label="On Failure*", shape=ellipse, style=filled]; +"On Failure" -> "On Failure*"; +Failure [fillcolor=gray, fontcolor=black, fontsize=9, label=Failure, shape=ellipse, style=filled]; +"On Failure" -> Failure; +} diff --git a/docs/dot/eventually.dot b/docs/dot/eventually.dot new file mode 100644 index 00000000..0f80d3d8 --- /dev/null +++ b/docs/dot/eventually.dot @@ -0,0 +1,13 @@ +digraph pastafarianism { +ordering=out; +graph [fontname="times-roman"]; +node [fontname="times-roman"]; +edge [fontname="times-roman"]; +Eventually [fillcolor=gold, fontcolor=black, fontsize=9, label="Eventually\nSuccessOnOne", shape=parallelogram, style=filled]; +Worker [fillcolor=gray, fontcolor=black, fontsize=9, label=Worker, shape=ellipse, style=filled]; +Eventually -> Worker; +"Eventually*" [fillcolor=ghostwhite, fontcolor=black, fontsize=9, label="Eventually*", shape=ellipse, style=filled]; +Eventually -> "Eventually*"; +"On Completion" [fillcolor=gray, fontcolor=black, fontsize=9, label="On Completion", shape=ellipse, style=filled]; +"Eventually*" -> "On Completion"; +} diff --git a/docs/examples/eventually.py b/docs/examples/eventually.py new file mode 100755 index 00000000..38481ed7 --- /dev/null +++ b/docs/examples/eventually.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import py_trees + +if __name__ == "__main__": + worker = py_trees.behaviours.Success(name="Worker") + on_completion = py_trees.behaviours.Success(name="On Completion") + root = py_trees.idioms.eventually( + name="Eventually", + worker=worker, + on_completion=on_completion, + ) + py_trees.display.render_dot_tree( + root, py_trees.common.string_to_visibility_level("all") + ) diff --git a/docs/examples/eventually_swiss.py b/docs/examples/eventually_swiss.py new file mode 100755 index 00000000..11be7668 --- /dev/null +++ b/docs/examples/eventually_swiss.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import py_trees + +if __name__ == "__main__": + worker = py_trees.behaviours.Success(name="Worker") + on_failure = py_trees.behaviours.Success(name="On Failure") + on_success = py_trees.behaviours.Success(name="On Success") + root = py_trees.idioms.eventually_swiss( + name="Eventually (Swiss)", + workers=[worker], + on_failure=on_failure, + on_success=on_success, + ) + py_trees.display.render_dot_tree( + root, py_trees.common.string_to_visibility_level("all") + ) diff --git a/docs/idioms.rst b/docs/idioms.rst index 8c9ee41d..6d7824c9 100644 --- a/docs/idioms.rst +++ b/docs/idioms.rst @@ -30,6 +30,18 @@ Either Or .. _oneshot-section: +Eventually +---------- + +.. automethod:: py_trees.idioms.eventually + :noindex: + +Eventually - Swiss Variant +-------------------------- + +.. automethod:: py_trees.idioms.eventually_swiss + :noindex: + Oneshot ------- diff --git a/docs/images/demo-eventually-swiss.png b/docs/images/demo-eventually-swiss.png new file mode 100644 index 00000000..d1fb8e39 Binary files /dev/null and b/docs/images/demo-eventually-swiss.png differ diff --git a/docs/images/demo-eventually.png b/docs/images/demo-eventually.png new file mode 100644 index 00000000..b814fbae Binary files /dev/null and b/docs/images/demo-eventually.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..d35ce63c 100644 --- a/py_trees/decorators.py +++ b/py_trees/decorators.py @@ -38,6 +38,7 @@ * :class:`py_trees.decorators.EternalGuard` * :class:`py_trees.decorators.Inverter` * :class:`py_trees.decorators.OneShot` +* :class:`py_trees.decorators.OnTerminate` * :class:`py_trees.decorators.Repeat` * :class:`py_trees.decorators.Retry` * :class:`py_trees.decorators.StatusToBlackboard` @@ -920,3 +921,64 @@ def update(self) -> common.Status: the behaviour's new status :class:`~py_trees.common.Status` """ return self.decorated.status + + +class OnTerminate(Decorator): + """ + Trigger the child for a single tick on :meth:`terminate`. + + Always return :data:`~py_trees.common.Status.RUNNING` and on + on :meth:`terminate`, call the child's + :meth:`~py_trees.behaviour.Behaviour.update` method, once. + + This is useful to cleanup, restore a context switch or to + implement a finally-like behaviour. + + .. seealso:: :meth:`py_trees.idioms.eventually` + """ + + 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(OnTerminate, 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) -> common.Status: + """ + 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..9495902d 100644 --- a/py_trees/demos/__init__.py +++ b/py_trees/demos/__init__.py @@ -21,6 +21,8 @@ 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 eventually # usort:skip # noqa: F401 +from . import eventually_swiss # 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/eventually.py b/py_trees/demos/eventually.py new file mode 100644 index 00000000..55f771ee --- /dev/null +++ b/py_trees/demos/eventually.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 :meth:`py_trees.idioms.eventually`. + +.. argparse:: + :module: py_trees.demos.eventually + :func: command_line_argument_parser + :prog: py-trees-demo-eventually-program + +.. graphviz:: dot/demo-eventually.dot + +.. image:: images/demo-eventually.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 = "The single tick version of the 'eventually' idiom.\n\n" + content += "A counter is started and a variable 'counting' is set\n" + content += "to True to indicate it is in progress. On completion,\n" + content += "regardless of success or failure, the variable is\n" + content += "set back to False\n" + content += "\n" + content += "NB: The demo is run twice - on the first, the count\n" + content += "terminates with SUCCESS and on the second, with FAILURE.\n" + content += "\n" + content += "EVENTS\n" + content += " - 1 : counter is started\n" + content += " - 2a : count completes with SUCCESS||FAILURE\n" + content += " - 2b : the eventually pathway is triggered\n" + content += " - 2c : blackboard 'counting' variable is set to False\n" + content += "\n" + if py_trees.console.has_colours: + banner_line = console.green + "*" * 79 + "\n" + console.reset + s = banner_line + s += console.bold_white + "Eventually".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 + """ + set_counting_flag_true = py_trees.behaviours.SetBlackboardVariable( + name="SetCountingFlagTrue", + variable_name="counting", + variable_value=True, + overwrite=True, + ) + set_counting_flag_false = py_trees.behaviours.SetBlackboardVariable( + name="SetCountingFlagFalse", + variable_name="counting", + variable_value=False, + overwrite=True, + ) + counter = py_trees.behaviours.TickCounter( + name="Counter", duration=1, completion_status=expected_work_termination_result + ) + counting = py_trees.composites.Sequence( + name="Counting", memory=True, children=[set_counting_flag_true, counter] + ) + root = py_trees.idioms.eventually( + name="Count and Record", + worker=counting, + on_completion=set_counting_flag_false, + ) + 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/py_trees/demos/eventually_swiss.py b/py_trees/demos/eventually_swiss.py new file mode 100644 index 00000000..933e7c9c --- /dev/null +++ b/py_trees/demos/eventually_swiss.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python +# +# License: BSD +# https://raw.githubusercontent.com/splintered-reality/py_trees/devel/LICENSE +# +############################################################################## +# Documentation +############################################################################## + +""" +Demonstrate a swiss-knife variant of the eventually idiom. + +.. argparse:: + :module: py_trees.demos.eventually_swiss + :func: command_line_argument_parser + :prog: py-trees-demo-eventually-swiss-program + +.. graphviz:: dot/demo-eventually-swiss.dot + +.. image:: images/demo-eventually-swiss.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 = "The swiss-knife version of the 'eventually' idiom.\n\n" + content += "A counter is started and on successful completion\n" + content += "(or otherwise), the result is recorded on the blackboard.\n" + content += "\n" + content += "NB: The demo is run twice - on the first, the count\n" + content += "terminates with SUCCESS and on the second, with FAILURE.\n" + content += "\n" + content += "EVENTS\n" + content += " - 1 : counter is started\n" + content += " - 2a : count completes with SUCCESS||FAILURE\n" + content += " - 2b : eventually pathways are triggered\n" + content += " - 2c : blackboard count variable is set to true||false\n" + content += "\n" + if py_trees.console.has_colours: + banner_line = console.green + "*" * 79 + "\n" + console.reset + s = banner_line + s += ( + console.bold_white + + "Eventually - Swiss Variant".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 + """ + set_result_true = py_trees.behaviours.SetBlackboardVariable( + name="SetResultTrue", + variable_name="result", + variable_value=True, + overwrite=True, + ) + set_result_false = py_trees.behaviours.SetBlackboardVariable( + name="SetResultFalse", + variable_name="result", + variable_value=False, + overwrite=True, + ) + worker = py_trees.behaviours.TickCounter( + name="Counter", duration=1, completion_status=expected_work_termination_result + ) + root = py_trees.idioms.eventually_swiss( + name="Count with Result", + workers=[worker], + on_failure=set_result_false, + on_success=set_result_true, + ) + 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/py_trees/idioms.py b/py_trees/idioms.py index 1189f5e9..515a18d7 100644 --- a/py_trees/idioms.py +++ b/py_trees/idioms.py @@ -19,7 +19,7 @@ from . import behaviour, behaviours, blackboard, common, composites, decorators ############################################################################## -# Creational Methods +# Idioms ############################################################################## @@ -276,3 +276,86 @@ def oneshot( ) subtree_root.add_children([oneshot_with_guard, oneshot_result]) return subtree_root + + +def eventually( + name: str, + worker: behaviour.Behaviour, + on_completion: behaviour.Behaviour, +) -> behaviour.Behaviour: + """ + Implement a single-tick 'try-finally'-like pattern. + + This idiom uses the :class:`~py_trees.decorators.OnTerminate` + decorator along with a parallel to trigger an + on_completion behaviour (or subtree) as soon as the + worker returns :data:`~py_trees.common.Status.SUCCESS` or + :data:`~py_trees.common.Status.FAILURE`). + + .. note: The on_completion behaviour is only ticked once + + .. tip: + + If you have multiple workers, put them in a sequence and + pass that to the idiom. + + .. graphviz:: dot/eventually.dot + + Args: + worker: the worker behaviour or subtree + name: the name to use for the idiom root + on_completion: the behaviour or subtree to tick on work completion + + Returns: + :class:`~py_trees.behaviour.Behaviour`: the root of the oneshot subtree + + .. seealso:: :ref:`py-trees-demo-eventually-program`, :meth:`py_trees.idioms.eventually_swiss` + """ + subtree_root = composites.Parallel( + name=name, + policy=common.ParallelPolicy.SuccessOnOne(), + children=[], + ) + decorator = decorators.OnTerminate(name="Eventually", child=on_completion) + subtree_root.add_children([worker, decorator]) + return subtree_root + + +def eventually_swiss( + name: str, + workers: typing.List[behaviour.Behaviour], + on_failure: behaviour.Behaviour, + on_success: behaviour.Behaviour, +) -> behaviour.Behaviour: + """ + Implement a multi-tick, general purpose 'try-except-else'-like pattern. + + This is a swiss knife version of the eventually idiom + that facilitates a multi-tick response for specialised + handling work sequence's completion status. + + .. graphviz:: dot/eventually-swiss.dot + + Args: + name: the name to use for the idiom root + workers: the worker behaviours or subtrees + on_success: the behaviour or subtree to tick on work success + on_failure: the behaviour or subtree to tick on work failure + + Returns: + :class:`~py_trees.behaviour.Behaviour`: the root of the oneshot subtree + + .. seealso:: :meth:`py_trees.idioms.eventually`, :ref:`py-trees-demo-eventually-swiss-program` + """ + on_success_sequence = composites.Sequence( + name="Work to Success", memory=True, children=workers + [on_success] + ) + on_failure_sequence = composites.Sequence( + name="On Failure", + memory=True, + children=[on_failure, behaviours.Failure(name="Failure")], + ) + subtree_root = composites.Selector( + name=name, memory=False, children=[on_success_sequence, on_failure_sequence] + ) + return subtree_root diff --git a/pyproject.toml b/pyproject.toml index 494a7708..c3904919 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,6 +66,8 @@ 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-eventually = "py_trees.demos.eventually:main" +py-trees-demo-eventually-swiss = "py_trees.demos.eventually_swiss: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..b5200b97 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_on_terminate() -> None: + console.banner("OnTerminate") + + blackboard = py_trees.blackboard.Client() + blackboard.register_key(key="flag", access=py_trees.common.Access.WRITE) + blackboard.flag = False + + set_flag_true = py_trees.behaviours.SetBlackboardVariable( + name="SetFlag", variable_name="flag", variable_value=True, overwrite=True + ) + 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=[], + ) + decorator = py_trees.decorators.OnTerminate(name="Eventually", child=set_flag_true) + parallel.add_children([worker, decorator]) + 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 diff --git a/tests/test_eventually.py b/tests/test_eventually.py new file mode 100644 index 00000000..3e3dbf0c --- /dev/null +++ b/tests/test_eventually.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python +# +# License: BSD +# https://raw.githubusercontent.com/splintered-reality/py_trees/devel/LICENSE +# +############################################################################## +# Imports +############################################################################## + +import py_trees +import py_trees.console as console +import py_trees.tests +import pytest + +############################################################################## +# Eternal Guard +############################################################################## + + +def test_eventually() -> None: + console.banner("Eventually") + + blackboard = py_trees.blackboard.Client() + blackboard.register_key(key="flag", access=py_trees.common.Access.WRITE) + blackboard.flag = False + + set_flag_true = py_trees.behaviours.SetBlackboardVariable( + name="SetFlag", variable_name="flag", variable_value=True, overwrite=True + ) + worker = py_trees.behaviours.TickCounter( + name="Counter-1", duration=1, completion_status=py_trees.common.Status.SUCCESS + ) + parallel = py_trees.idioms.eventually( + name="Parallel", worker=worker, on_completion=set_flag_true + ) + 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 + + +def test_eventually_swiss() -> None: + console.banner("Eventually (Swiss)") + set_result_true = py_trees.behaviours.SetBlackboardVariable( + name="SetResultTrue", + variable_name="result", + variable_value=True, + overwrite=True, + ) + set_result_false = py_trees.behaviours.SetBlackboardVariable( + name="SetResultFalse", + variable_name="result", + variable_value=False, + overwrite=True, + ) + worker = py_trees.behaviours.TickCounter( + name="Counter", duration=1, completion_status=py_trees.common.Status.FAILURE + ) + root = py_trees.idioms.eventually_swiss( + name="Count with Result", + workers=[worker], + on_failure=set_result_false, + on_success=set_result_true, + ) + blackboard = py_trees.blackboard.Client() + blackboard.register_key(key="result", access=py_trees.common.Access.READ) + + root.tick_once() + print(py_trees.display.unicode_tree(root=root, show_status=True)) + print(py_trees.display.unicode_blackboard()) + + py_trees.tests.print_assert_banner() + with pytest.raises(KeyError) as context: # if raised, context survives + print("With blackboard.get('result') ...") + print("Expecting a KeyError with substring 'does not yet exist'\n") + blackboard.get("result") + # py_trees.tests.print_assert_details("KeyError raised", "raised", "not raised") + py_trees.tests.print_assert_details("KeyError raised", "yes", "yes") + assert "KeyError" == context.typename + py_trees.tests.print_assert_details( + " substring match", "does not yet exist", f"{context.value}" + ) + assert "does not yet exist" in str(context.value) + + root.tick_once() + print(py_trees.display.unicode_tree(root=root, show_status=True)) + print(py_trees.display.unicode_blackboard()) + + py_trees.tests.print_assert_banner() + py_trees.tests.print_assert_details( + text="BB Variable (flag)", + expected=False, + result=blackboard.result, + ) + assert not blackboard.result