Skip to content

Commit 15d3060

Browse files
committed
[decorators] finally-style decorators and idioms
1 parent 0d5b39f commit 15d3060

16 files changed

+411
-3
lines changed

.vscode/settings.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,15 @@
1212
"behaviours",
1313
"bierner",
1414
"bungcip",
15+
"epilog",
16+
"graphviz",
17+
"literalinclude",
18+
"noodly",
1519
"omnilib",
1620
"py_trees",
1721
"pydot",
1822
"pypi",
23+
"seealso",
1924
"ufmt",
2025
"usort"
2126
]

CHANGELOG.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ Release Notes
33

44
Forthcoming
55
-----------
6-
* ...
6+
* [decorators] finally-style decorators and idioms, `#427 <https://github.com/splintered-reality/py_trees/pull/427>`_
77

88
2.2.3 (2023-02-08)
99
------------------

docs/demos.rst

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,22 @@ py-trees-demo-eternal-guard
147147
:linenos:
148148
:caption: py_trees/demos/eternal_guard.py
149149

150+
.. _py-trees-demo-eventually-program:
151+
152+
py-trees-demo-eventually
153+
------------------------
154+
155+
.. automodule:: py_trees.demos.eventually
156+
:members:
157+
:special-members:
158+
:show-inheritance:
159+
:synopsis: demo the finally-like decorator
160+
161+
.. literalinclude:: ../py_trees/demos/eventually.py
162+
:language: python
163+
:linenos:
164+
:caption: py_trees/demos/eventually.py
165+
150166
.. _py-trees-demo-logging-program:
151167

152168
py-trees-demo-logging

docs/dot/demo-eventually.dot

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
digraph pastafarianism {
2+
ordering=out;
3+
graph [fontname="times-roman"];
4+
node [fontname="times-roman"];
5+
edge [fontname="times-roman"];
6+
root [fillcolor=orange, fontcolor=black, fontsize=9, label="Ⓜ root", shape=box, style=filled];
7+
SetFlagFalse [fillcolor=gray, fontcolor=black, fontsize=9, label=SetFlagFalse, shape=ellipse, style=filled];
8+
root -> SetFlagFalse;
9+
Parallel [fillcolor=gold, fontcolor=black, fontsize=9, label="Parallel\nSuccessOnOne", shape=parallelogram, style=filled];
10+
root -> Parallel;
11+
Counter [fillcolor=gray, fontcolor=black, fontsize=9, label=Counter, shape=ellipse, style=filled];
12+
Parallel -> Counter;
13+
Eventually [fillcolor=ghostwhite, fontcolor=black, fontsize=9, label=Eventually, shape=ellipse, style=filled];
14+
Parallel -> Eventually;
15+
SetFlagTrue [fillcolor=gray, fontcolor=black, fontsize=9, label=SetFlagTrue, shape=ellipse, style=filled];
16+
Eventually -> SetFlagTrue;
17+
}

docs/dot/demo-finally-single-tick.dot

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
digraph pastafarianism {
2+
ordering=out;
3+
graph [fontname="times-roman"];
4+
node [fontname="times-roman"];
5+
edge [fontname="times-roman"];
6+
root [fillcolor=orange, fontcolor=black, fontsize=9, label="Ⓜ root", shape=box, style=filled];
7+
SetFlagFalse [fillcolor=gray, fontcolor=black, fontsize=9, label=SetFlagFalse, shape=ellipse, style=filled];
8+
root -> SetFlagFalse;
9+
Parallel [fillcolor=gold, fontcolor=black, fontsize=9, label="Parallel\nSuccessOnOne", shape=parallelogram, style=filled];
10+
root -> Parallel;
11+
Counter [fillcolor=gray, fontcolor=black, fontsize=9, label=Counter, shape=ellipse, style=filled];
12+
Parallel -> Counter;
13+
Finally [fillcolor=ghostwhite, fontcolor=black, fontsize=9, label=Finally, shape=ellipse, style=filled];
14+
Parallel -> Finally;
15+
SetFlagTrue [fillcolor=gray, fontcolor=black, fontsize=9, label=SetFlagTrue, shape=ellipse, style=filled];
16+
Finally -> SetFlagTrue;
17+
}

docs/dot/eventually.dot

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
digraph pastafarianism {
2+
ordering=out;
3+
graph [fontname="times-roman"];
4+
node [fontname="times-roman"];
5+
edge [fontname="times-roman"];
6+
Parallel [fillcolor=gold, fontcolor=black, fontsize=9, label="Parallel\nSuccessOnOne", shape=parallelogram, style=filled];
7+
Worker [fillcolor=orange, fontcolor=black, fontsize=9, label="Ⓜ Worker", shape=box, style=filled];
8+
Parallel -> Worker;
9+
Glory [fillcolor=gray, fontcolor=black, fontsize=9, label=Glory, shape=ellipse, style=filled];
10+
Worker -> Glory;
11+
Infamy [fillcolor=gray, fontcolor=black, fontsize=9, label=Infamy, shape=ellipse, style=filled];
12+
Worker -> Infamy;
13+
Eventually [fillcolor=ghostwhite, fontcolor=black, fontsize=9, label=Eventually, shape=ellipse, style=filled];
14+
Parallel -> Eventually;
15+
Colander [fillcolor=gray, fontcolor=black, fontsize=9, label=Colander, shape=ellipse, style=filled];
16+
Eventually -> Colander;
17+
}

docs/examples/eventually.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
#!/usr/bin/env python3
2+
# -*- coding: utf-8 -*-
3+
4+
import py_trees
5+
6+
if __name__ == "__main__":
7+
root = py_trees
8+
task_one = py_trees.behaviours.StatusQueue(
9+
name="Glory",
10+
queue=[
11+
py_trees.common.Status.RUNNING,
12+
],
13+
eventually=py_trees.common.Status.SUCCESS,
14+
)
15+
task_two = py_trees.behaviours.Success(name="Infamy")
16+
worker = py_trees.composites.Sequence(
17+
name="Worker", memory=True, children=[task_one, task_two]
18+
)
19+
root = py_trees.idioms.eventually(
20+
name="Parallel",
21+
worker=worker,
22+
eventually=py_trees.behaviours.Success("Colander"),
23+
)
24+
py_trees.display.render_dot_tree(
25+
root, py_trees.common.string_to_visibility_level("all")
26+
)

docs/images/finally_single_tick.png

49.6 KB
Loading

py_trees/behaviour.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -344,7 +344,7 @@ def iterate(self, direct_descendants: bool = False) -> typing.Iterator[Behaviour
344344
yield child
345345
yield self
346346

347-
# TODO: better type refinement of 'viso=itor'
347+
# TODO: better type refinement of 'visitor'
348348
def visit(self, visitor: typing.Any) -> None:
349349
"""
350350
Introspect on this behaviour with a visitor.

py_trees/behaviours.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,7 @@ def update(self) -> common.Status:
280280
:data:`~py_trees.common.Status.RUNNING` while not expired, the given completion status otherwise
281281
"""
282282
self.counter += 1
283+
self.feedback_message = f"count: {self.counter}"
283284
if self.counter <= self.duration:
284285
return common.Status.RUNNING
285286
else:

py_trees/decorators.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
* :class:`py_trees.decorators.EternalGuard`
3939
* :class:`py_trees.decorators.Inverter`
4040
* :class:`py_trees.decorators.OneShot`
41+
* :class:`py_trees.decorators.OnTerminate`
4142
* :class:`py_trees.decorators.Repeat`
4243
* :class:`py_trees.decorators.Retry`
4344
* :class:`py_trees.decorators.StatusToBlackboard`
@@ -920,3 +921,64 @@ def update(self) -> common.Status:
920921
the behaviour's new status :class:`~py_trees.common.Status`
921922
"""
922923
return self.decorated.status
924+
925+
926+
class OnTerminate(Decorator):
927+
"""
928+
Trigger the child for a single tick on :meth:`terminate`.
929+
930+
Always return :data:`~py_trees.common.Status.RUNNING` and on
931+
on :meth:`terminate`, call the child's
932+
:meth:`~py_trees.behaviour.Behaviour.update` method, once.
933+
934+
This is useful to cleanup, restore a context switch or to
935+
implement a finally-like behaviour.
936+
937+
.. seealso:: :meth:`py_trees.idioms.eventually`
938+
"""
939+
940+
def __init__(self, name: str, child: behaviour.Behaviour):
941+
"""
942+
Initialise with the standard decorator arguments.
943+
944+
Args:
945+
name: the decorator name
946+
child: the child to be decorated
947+
"""
948+
super(OnTerminate, self).__init__(name=name, child=child)
949+
950+
def tick(self) -> typing.Iterator[behaviour.Behaviour]:
951+
"""
952+
Bypass the child when ticking.
953+
954+
Yields:
955+
a reference to itself
956+
"""
957+
self.logger.debug(f"{self.__class__.__name__}.tick()")
958+
self.status = self.update()
959+
yield self
960+
961+
def update(self):
962+
"""
963+
Return with :data:`~py_trees.common.Status.RUNNING`.
964+
965+
Returns:
966+
the behaviour's new status :class:`~py_trees.common.Status`
967+
"""
968+
return common.Status.RUNNING
969+
970+
def terminate(self, new_status: common.Status) -> None:
971+
"""Tick the child behaviour once."""
972+
self.logger.debug(
973+
"{}.terminate({})".format(
974+
self.__class__.__name__,
975+
"{}->{}".format(self.status, new_status)
976+
if self.status != new_status
977+
else f"{new_status}",
978+
)
979+
)
980+
if new_status == common.Status.INVALID:
981+
self.decorated.tick_once()
982+
# Do not need to stop the child here - this method
983+
# is only called by Decorator.stop() which will handle
984+
# that responsibility immediately after this method returns.

py_trees/demos/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from . import display_modes # usort:skip # noqa: F401
2222
from . import dot_graphs # usort:skip # noqa: F401
2323
from . import either_or # usort:skip # noqa: F401
24+
from . import eventually # usort:skip # noqa: F401
2425
from . import lifecycle # usort:skip # noqa: F401
2526
from . import selector # usort:skip # noqa: F401
2627
from . import sequence # usort:skip # noqa: F401

py_trees/demos/eventually.py

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
#!/usr/bin/env python
2+
#
3+
# License: BSD
4+
# https://raw.githubusercontent.com/splintered-reality/py_trees/devel/LICENSE
5+
#
6+
##############################################################################
7+
# Documentation
8+
##############################################################################
9+
10+
"""
11+
Trigger 'finally'-like behaviour with :meth:`py_trees.idioms.eventually`.
12+
13+
.. argparse::
14+
:module: py_trees.demos.eventually
15+
:func: command_line_argument_parser
16+
:prog: py-trees-demo-eventually
17+
18+
.. graphviz:: dot/demo-eventually.dot
19+
20+
.. image:: images/finally_single_tick.png
21+
22+
"""
23+
24+
##############################################################################
25+
# Imports
26+
##############################################################################
27+
28+
import argparse
29+
import sys
30+
import typing
31+
32+
import py_trees
33+
import py_trees.console as console
34+
35+
##############################################################################
36+
# Classes
37+
##############################################################################
38+
39+
40+
def description(root: py_trees.behaviour.Behaviour) -> str:
41+
"""
42+
Print description and usage information about the program.
43+
44+
Returns:
45+
the program description string
46+
"""
47+
content = "Trigger python-like 'finally' behaviour with the 'Eventually' idiom.\n\n"
48+
content += "A blackboard flag is set to false prior to commencing work. \n"
49+
content += "Once the work terminates, the decorator and it's child\n"
50+
content += "child will also terminate and toggle the flag to true.\n"
51+
content += "\n"
52+
content += "The demonstration is run twice - on the first occasion\n"
53+
content += "the work terminates with SUCCESS and on the second, it\n"
54+
content += "terminates with FAILURE.\n"
55+
content += "\n"
56+
content += "EVENTS\n"
57+
content += "\n"
58+
content += " - 1 : flag is set to false, worker is running\n"
59+
content += " - 2 : worker completes (with SUCCESS||FAILURE)\n"
60+
content += " - 2 : eventually is triggered, flag is set to true\n"
61+
content += "\n"
62+
if py_trees.console.has_colours:
63+
banner_line = console.green + "*" * 79 + "\n" + console.reset
64+
s = banner_line
65+
s += console.bold_white + "Finally".center(79) + "\n" + console.reset
66+
s += banner_line
67+
s += "\n"
68+
s += content
69+
s += "\n"
70+
s += banner_line
71+
else:
72+
s = content
73+
return s
74+
75+
76+
def epilog() -> typing.Optional[str]:
77+
"""
78+
Print a noodly epilog for --help.
79+
80+
Returns:
81+
the noodly message
82+
"""
83+
if py_trees.console.has_colours:
84+
return (
85+
console.cyan
86+
+ "And his noodly appendage reached forth to tickle the blessed...\n"
87+
+ console.reset
88+
)
89+
else:
90+
return None
91+
92+
93+
def command_line_argument_parser() -> argparse.ArgumentParser:
94+
"""
95+
Process command line arguments.
96+
97+
Returns:
98+
the argument parser
99+
"""
100+
parser = argparse.ArgumentParser(
101+
description=description(create_root(py_trees.common.Status.SUCCESS)),
102+
epilog=epilog(),
103+
formatter_class=argparse.RawDescriptionHelpFormatter,
104+
)
105+
group = parser.add_mutually_exclusive_group()
106+
group.add_argument(
107+
"-r", "--render", action="store_true", help="render dot tree to file"
108+
)
109+
return parser
110+
111+
112+
def create_root(
113+
expected_work_termination_result: py_trees.common.Status,
114+
) -> py_trees.behaviour.Behaviour:
115+
"""
116+
Create the root behaviour and it's subtree.
117+
118+
Returns:
119+
the root behaviour
120+
"""
121+
root = py_trees.composites.Sequence(name="Root", memory=True)
122+
set_flag_to_false = py_trees.behaviours.SetBlackboardVariable(
123+
name="SetFlagFalse",
124+
variable_name="flag",
125+
variable_value=False,
126+
overwrite=True,
127+
)
128+
set_flag_to_true = py_trees.behaviours.SetBlackboardVariable(
129+
name="SetFlagTrue", variable_name="flag", variable_value=True, overwrite=True
130+
)
131+
worker = py_trees.behaviours.TickCounter(
132+
name="Counter", duration=1, completion_status=expected_work_termination_result
133+
)
134+
parallel = py_trees.idioms.eventually(
135+
name="Parallel",
136+
worker=worker,
137+
eventually=set_flag_to_true,
138+
)
139+
root.add_children([set_flag_to_false, parallel])
140+
return root
141+
142+
143+
##############################################################################
144+
# Main
145+
##############################################################################
146+
147+
148+
def main() -> None:
149+
"""Entry point for the demo script."""
150+
args = command_line_argument_parser().parse_args()
151+
# py_trees.logging.level = py_trees.logging.Level.DEBUG
152+
print(description(create_root(py_trees.common.Status.SUCCESS)))
153+
154+
####################
155+
# Rendering
156+
####################
157+
if args.render:
158+
py_trees.display.render_dot_tree(create_root(py_trees.common.Status.SUCCESS))
159+
sys.exit()
160+
161+
for status in (py_trees.common.Status.SUCCESS, py_trees.common.Status.FAILURE):
162+
py_trees.blackboard.Blackboard.clear()
163+
console.banner(f"Experiment - Terminate with {status}")
164+
root = create_root(status)
165+
root.tick_once()
166+
print(py_trees.display.unicode_tree(root=root, show_status=True))
167+
print(py_trees.display.unicode_blackboard())
168+
root.tick_once()
169+
print(py_trees.display.unicode_tree(root=root, show_status=True))
170+
print(py_trees.display.unicode_blackboard())
171+
172+
print("\n")

0 commit comments

Comments
 (0)