btreeny 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- btreeny/__init__.py +400 -0
- btreeny/_ctx.py +14 -0
- btreeny/_get_name.py +10 -0
- btreeny/_tree_status.py +7 -0
- btreeny/py.typed +0 -0
- btreeny/viz.py +176 -0
- btreeny-0.1.0.dist-info/METADATA +163 -0
- btreeny-0.1.0.dist-info/RECORD +9 -0
- btreeny-0.1.0.dist-info/WHEEL +4 -0
btreeny/__init__.py
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
from copy import deepcopy
|
|
3
|
+
import functools
|
|
4
|
+
import itertools
|
|
5
|
+
from typing import (
|
|
6
|
+
Callable,
|
|
7
|
+
Generator,
|
|
8
|
+
Iterator,
|
|
9
|
+
Literal,
|
|
10
|
+
ParamSpec,
|
|
11
|
+
ContextManager,
|
|
12
|
+
TypeVar,
|
|
13
|
+
cast,
|
|
14
|
+
)
|
|
15
|
+
import uuid
|
|
16
|
+
|
|
17
|
+
from ._get_name import get_name
|
|
18
|
+
from ._tree_status import TreeStatus
|
|
19
|
+
from ._ctx import (
|
|
20
|
+
call_stack as __ctx_call_stack,
|
|
21
|
+
tree_graph as __ctx_tree_graph,
|
|
22
|
+
id_map as __ctx_id_map,
|
|
23
|
+
tree_status as __ctx_tree_status,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
BlackboardType = TypeVar("BlackboardType")
|
|
27
|
+
TreeTickFunction = Callable[[BlackboardType], TreeStatus]
|
|
28
|
+
TreeNode = ContextManager[TreeTickFunction[BlackboardType]]
|
|
29
|
+
|
|
30
|
+
RUNNING = TreeStatus.RUNNING
|
|
31
|
+
SUCCESS = TreeStatus.SUCCESS
|
|
32
|
+
FAILURE = TreeStatus.FAILURE
|
|
33
|
+
|
|
34
|
+
P = ParamSpec("P")
|
|
35
|
+
T = TypeVar("T")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@contextlib.contextmanager
|
|
39
|
+
def _manage_call_stack(id: uuid.UUID, name: str):
|
|
40
|
+
_id_map = deepcopy(__ctx_id_map.get())
|
|
41
|
+
_id_map[id] = name
|
|
42
|
+
__ctx_id_map.set(_id_map)
|
|
43
|
+
# When we setup this action, set it on the call stack
|
|
44
|
+
stack = __ctx_call_stack.get()
|
|
45
|
+
parent = None if len(stack) == 0 else stack[-1]
|
|
46
|
+
__ctx_call_stack.set(stack + [id])
|
|
47
|
+
# Add this to the node graph
|
|
48
|
+
_tree_graph = deepcopy(__ctx_tree_graph.get())
|
|
49
|
+
if parent not in _tree_graph:
|
|
50
|
+
_tree_graph[parent] = []
|
|
51
|
+
if id not in _tree_graph[parent]:
|
|
52
|
+
_tree_graph[parent].append(id)
|
|
53
|
+
__ctx_tree_graph.set(_tree_graph)
|
|
54
|
+
yield
|
|
55
|
+
# Drain all values including and after this ID from the stack
|
|
56
|
+
# Raises ValueError if ID not in stack
|
|
57
|
+
stack = __ctx_call_stack.get()
|
|
58
|
+
id_in_stack = stack.index(id)
|
|
59
|
+
__ctx_call_stack.set(stack[:id_in_stack])
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def action(
|
|
63
|
+
func: Callable[P, Iterator[TreeTickFunction[BlackboardType]]],
|
|
64
|
+
) -> Callable[P, TreeNode[BlackboardType]]:
|
|
65
|
+
self_name = get_name(func)
|
|
66
|
+
|
|
67
|
+
f = contextlib.contextmanager(func)
|
|
68
|
+
|
|
69
|
+
@contextlib.contextmanager
|
|
70
|
+
@functools.wraps(f)
|
|
71
|
+
def inner(*args: P.args, **kwargs: P.kwargs):
|
|
72
|
+
# Each invocation of the action function gets a new ID
|
|
73
|
+
self_id = uuid.uuid4()
|
|
74
|
+
with _manage_call_stack(self_id, self_name):
|
|
75
|
+
with f(*args, **kwargs) as action:
|
|
76
|
+
|
|
77
|
+
@functools.wraps(action)
|
|
78
|
+
def action_func(blackboard: BlackboardType):
|
|
79
|
+
result = action(blackboard)
|
|
80
|
+
_tree_status = deepcopy(__ctx_tree_status.get())
|
|
81
|
+
_tree_status[self_id] = result
|
|
82
|
+
__ctx_tree_status.set(_tree_status)
|
|
83
|
+
return result
|
|
84
|
+
|
|
85
|
+
yield action_func
|
|
86
|
+
|
|
87
|
+
return inner
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def simple_action(f: TreeTickFunction[BlackboardType]):
|
|
91
|
+
@action
|
|
92
|
+
@functools.wraps(f)
|
|
93
|
+
def _inner():
|
|
94
|
+
yield f
|
|
95
|
+
|
|
96
|
+
return _inner
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ------------------------------------------------------------------------------
|
|
100
|
+
# Control flow
|
|
101
|
+
# ------------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@action
|
|
105
|
+
def sequential(*children: TreeNode[BlackboardType]):
|
|
106
|
+
def gen() -> Generator[TreeStatus, BlackboardType, TreeStatus]:
|
|
107
|
+
blackboard = yield TreeStatus.RUNNING
|
|
108
|
+
for child_context_manager in children:
|
|
109
|
+
with child_context_manager as child_action:
|
|
110
|
+
# TODO: Pyrefly is not happy with blackboard typing - why?
|
|
111
|
+
while (
|
|
112
|
+
result := child_action(blackboard) # pyrefly: ignore
|
|
113
|
+
) == TreeStatus.RUNNING:
|
|
114
|
+
blackboard = yield TreeStatus.RUNNING
|
|
115
|
+
if result == TreeStatus.FAILURE:
|
|
116
|
+
return result
|
|
117
|
+
return TreeStatus.SUCCESS
|
|
118
|
+
|
|
119
|
+
stepper = gen()
|
|
120
|
+
next(stepper)
|
|
121
|
+
|
|
122
|
+
def inner(blackboard: BlackboardType) -> TreeStatus:
|
|
123
|
+
nonlocal stepper
|
|
124
|
+
try:
|
|
125
|
+
return stepper.send(blackboard)
|
|
126
|
+
except StopIteration as e:
|
|
127
|
+
# TODO: Raise an exception if we try to tick the tree when it's finished
|
|
128
|
+
return cast(TreeStatus, e.value)
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
yield inner
|
|
132
|
+
finally:
|
|
133
|
+
stepper.close()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@action
|
|
137
|
+
def fallback(*children: TreeNode[BlackboardType]):
|
|
138
|
+
def gen() -> Generator[TreeStatus, BlackboardType, TreeStatus]:
|
|
139
|
+
blackboard = yield TreeStatus.RUNNING
|
|
140
|
+
for child_context_manager in children:
|
|
141
|
+
with child_context_manager as child_action:
|
|
142
|
+
# TODO: Pyrefly is not happy with blackboard typing - why?
|
|
143
|
+
while (
|
|
144
|
+
result := child_action(blackboard) # pyrefly: ignore
|
|
145
|
+
) == TreeStatus.RUNNING:
|
|
146
|
+
blackboard = yield TreeStatus.RUNNING
|
|
147
|
+
if result == TreeStatus.SUCCESS:
|
|
148
|
+
return result
|
|
149
|
+
return TreeStatus.FAILURE
|
|
150
|
+
|
|
151
|
+
stepper = gen()
|
|
152
|
+
next(stepper)
|
|
153
|
+
|
|
154
|
+
def inner(blackboard: BlackboardType):
|
|
155
|
+
nonlocal stepper
|
|
156
|
+
try:
|
|
157
|
+
return stepper.send(blackboard)
|
|
158
|
+
except StopIteration as e:
|
|
159
|
+
# TODO: Raise an exception if we try to tick the tree when it's finished
|
|
160
|
+
return cast(TreeStatus, e.value)
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
yield inner
|
|
164
|
+
finally:
|
|
165
|
+
stepper.close()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@action
|
|
169
|
+
def repeat(
|
|
170
|
+
action_factory: Callable[[], TreeNode[BlackboardType]],
|
|
171
|
+
continue_if: Literal[TreeStatus.SUCCESS, TreeStatus.FAILURE],
|
|
172
|
+
count: int | None = None,
|
|
173
|
+
):
|
|
174
|
+
"""Repeat an action while it returns a specific value (success or failure).
|
|
175
|
+
|
|
176
|
+
Parameters
|
|
177
|
+
----------
|
|
178
|
+
action_factory: () -> TreeNode[BlackboardType]
|
|
179
|
+
A function used to generate the action node to repeat. This needs to be a
|
|
180
|
+
function as we perform action setup and teardown on each repeat.
|
|
181
|
+
count: int, default=None
|
|
182
|
+
The number of repeats to try. If `None` then repeat to failure.
|
|
183
|
+
continue_if: TreeStatus.SUCCESS | TreeStatus.FAILURE
|
|
184
|
+
The return value which should trigger a repeat
|
|
185
|
+
"""
|
|
186
|
+
# Create children which is an inf
|
|
187
|
+
if count is None:
|
|
188
|
+
children = map(lambda factory: factory(), itertools.repeat(action_factory))
|
|
189
|
+
else:
|
|
190
|
+
children = map(
|
|
191
|
+
lambda factory: factory(), itertools.repeat(action_factory, count)
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
def gen() -> Generator[TreeStatus, BlackboardType, TreeStatus]:
|
|
195
|
+
blackboard = yield TreeStatus.RUNNING
|
|
196
|
+
result = TreeStatus.SUCCESS
|
|
197
|
+
for i, child_context_manager in enumerate(children):
|
|
198
|
+
with child_context_manager as child_action:
|
|
199
|
+
while (result := child_action(blackboard)) == TreeStatus.RUNNING:
|
|
200
|
+
blackboard = yield TreeStatus.RUNNING
|
|
201
|
+
if result == continue_if:
|
|
202
|
+
# If this is the last child then return
|
|
203
|
+
if count is not None and i >= count - 1:
|
|
204
|
+
return result
|
|
205
|
+
blackboard = yield TreeStatus.RUNNING
|
|
206
|
+
else:
|
|
207
|
+
return result
|
|
208
|
+
return result
|
|
209
|
+
|
|
210
|
+
stepper = gen()
|
|
211
|
+
next(stepper)
|
|
212
|
+
|
|
213
|
+
def inner(blackboard: BlackboardType):
|
|
214
|
+
nonlocal stepper
|
|
215
|
+
try:
|
|
216
|
+
return stepper.send(blackboard)
|
|
217
|
+
except StopIteration as e:
|
|
218
|
+
# TODO: Raise an exception if we try to tick the tree when it's finished
|
|
219
|
+
return cast(TreeStatus, e.value)
|
|
220
|
+
|
|
221
|
+
try:
|
|
222
|
+
yield inner
|
|
223
|
+
finally:
|
|
224
|
+
stepper.close()
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
retry = functools.partial(repeat, continue_if=TreeStatus.FAILURE)
|
|
228
|
+
redo = functools.partial(repeat, continue_if=TreeStatus.SUCCESS)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@action
|
|
232
|
+
def remap(
|
|
233
|
+
child: TreeNode[BlackboardType],
|
|
234
|
+
mapping: dict[TreeStatus, TreeStatus],
|
|
235
|
+
):
|
|
236
|
+
with child as action:
|
|
237
|
+
|
|
238
|
+
def inner(blackboard: BlackboardType) -> TreeStatus:
|
|
239
|
+
result = action(blackboard)
|
|
240
|
+
return mapping.get(result, result)
|
|
241
|
+
|
|
242
|
+
yield inner
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@action
|
|
246
|
+
def swap(
|
|
247
|
+
child: TreeNode[BlackboardType],
|
|
248
|
+
*,
|
|
249
|
+
from_: TreeStatus,
|
|
250
|
+
to: TreeStatus,
|
|
251
|
+
):
|
|
252
|
+
if from_ == to:
|
|
253
|
+
raise ValueError(f"Cannot swap {from_} with itself")
|
|
254
|
+
with remap(child, {from_: to, to: from_}) as action:
|
|
255
|
+
yield action
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@action
|
|
259
|
+
def always_return(
|
|
260
|
+
child: TreeNode[BlackboardType],
|
|
261
|
+
*,
|
|
262
|
+
always_return: TreeStatus,
|
|
263
|
+
):
|
|
264
|
+
with child as action:
|
|
265
|
+
|
|
266
|
+
def inner(blackboard: BlackboardType) -> TreeStatus:
|
|
267
|
+
_ = action(blackboard)
|
|
268
|
+
return always_return
|
|
269
|
+
|
|
270
|
+
yield inner
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@action
|
|
274
|
+
def react(
|
|
275
|
+
condition: Callable[[BlackboardType], bool],
|
|
276
|
+
nominal: TreeNode[BlackboardType],
|
|
277
|
+
failure: TreeNode[BlackboardType],
|
|
278
|
+
):
|
|
279
|
+
"""A react node allows highly reactive behavior, switching between multiple
|
|
280
|
+
actions depending on the currently evaluated state of the `condition` function.
|
|
281
|
+
|
|
282
|
+
Parameters
|
|
283
|
+
----------
|
|
284
|
+
condition: (BlackboardType) -> bool
|
|
285
|
+
The function to call to determine if we are in the "nominal" or "failure" mode
|
|
286
|
+
nominal: Tree
|
|
287
|
+
"""
|
|
288
|
+
# We need to carefully manage the call stack, inserting and removing children
|
|
289
|
+
# as we switch between modes
|
|
290
|
+
initial_call_stack = __ctx_call_stack.get()
|
|
291
|
+
initial_graph = __ctx_tree_graph.get()
|
|
292
|
+
# Get the failure call stack
|
|
293
|
+
failure_action = failure.__enter__()
|
|
294
|
+
failure_call_stack = __ctx_call_stack.get()
|
|
295
|
+
failure_graph = __ctx_tree_graph.get()
|
|
296
|
+
__ctx_call_stack.set(initial_call_stack)
|
|
297
|
+
__ctx_tree_graph.set(initial_graph)
|
|
298
|
+
# Get the nominal call stack
|
|
299
|
+
nominal_action = nominal.__enter__()
|
|
300
|
+
nominal_call_stack = __ctx_call_stack.get()
|
|
301
|
+
nominal_graph = __ctx_tree_graph.get()
|
|
302
|
+
# Intial mode is nominal
|
|
303
|
+
mode: Literal["nominal", "failure"] = "nominal"
|
|
304
|
+
|
|
305
|
+
def inner(blackboard: BlackboardType) -> TreeStatus:
|
|
306
|
+
nonlocal \
|
|
307
|
+
nominal_call_stack, \
|
|
308
|
+
failure_call_stack, \
|
|
309
|
+
nominal_graph, \
|
|
310
|
+
failure_graph, \
|
|
311
|
+
mode
|
|
312
|
+
match (mode, condition(blackboard)):
|
|
313
|
+
# Mode is nominal, condtion passes
|
|
314
|
+
case ("nominal", True):
|
|
315
|
+
# Run the nominal action
|
|
316
|
+
return nominal_action(blackboard)
|
|
317
|
+
# Mode is nominal, condtion failes - transition to fail
|
|
318
|
+
case ("nominal", False):
|
|
319
|
+
mode = "failure"
|
|
320
|
+
# Update the nominal call stack
|
|
321
|
+
nominal_call_stack = __ctx_call_stack.get()
|
|
322
|
+
nominal_graph = __ctx_tree_graph.get()
|
|
323
|
+
# Set the current call stack as the failure stack
|
|
324
|
+
__ctx_call_stack.set(failure_call_stack)
|
|
325
|
+
__ctx_tree_graph.set(failure_graph)
|
|
326
|
+
# Run the failure action
|
|
327
|
+
return failure_action(blackboard)
|
|
328
|
+
# Mode is failure, but the condition has started passing - transition to nominal
|
|
329
|
+
case ("failure", True):
|
|
330
|
+
mode = "nominal"
|
|
331
|
+
failure_call_stack = __ctx_call_stack.get()
|
|
332
|
+
failure_graph = __ctx_tree_graph.get()
|
|
333
|
+
__ctx_call_stack.set(nominal_call_stack)
|
|
334
|
+
__ctx_tree_graph.set(nominal_graph)
|
|
335
|
+
return nominal_action(blackboard)
|
|
336
|
+
# Mode is failure and the condition is failing
|
|
337
|
+
case ("failure", False):
|
|
338
|
+
return failure_action(blackboard)
|
|
339
|
+
case _:
|
|
340
|
+
raise RuntimeError("Impossible branch")
|
|
341
|
+
|
|
342
|
+
try:
|
|
343
|
+
yield inner
|
|
344
|
+
finally:
|
|
345
|
+
# Cleanup - being careful to reset stack as appropriate
|
|
346
|
+
match mode:
|
|
347
|
+
case "nominal":
|
|
348
|
+
nominal.__exit__(None, None, None)
|
|
349
|
+
__ctx_call_stack.set(failure_call_stack)
|
|
350
|
+
failure.__exit__(None, None, None)
|
|
351
|
+
case "failure":
|
|
352
|
+
failure.__exit__(None, None, None)
|
|
353
|
+
__ctx_call_stack.set(nominal_call_stack)
|
|
354
|
+
nominal.__exit__(None, None, None)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
@action
|
|
358
|
+
def failsafe(
|
|
359
|
+
check: Callable[[BlackboardType], bool],
|
|
360
|
+
nominal: TreeNode[BlackboardType],
|
|
361
|
+
failure: TreeNode[BlackboardType],
|
|
362
|
+
):
|
|
363
|
+
"""Run a check on each tick, as soon as the check returns ``False`` move from a "nominal"
|
|
364
|
+
mode to an "error" mode.
|
|
365
|
+
"""
|
|
366
|
+
|
|
367
|
+
def gen() -> Generator[TreeStatus, BlackboardType, TreeStatus]:
|
|
368
|
+
nonlocal nominal
|
|
369
|
+
blackboard = yield TreeStatus.RUNNING
|
|
370
|
+
result = TreeStatus.SUCCESS
|
|
371
|
+
with nominal as nominal_action:
|
|
372
|
+
while check(blackboard):
|
|
373
|
+
result = nominal_action(blackboard)
|
|
374
|
+
match result:
|
|
375
|
+
case TreeStatus.RUNNING:
|
|
376
|
+
yield result
|
|
377
|
+
case _:
|
|
378
|
+
return result
|
|
379
|
+
with failure as failure_action:
|
|
380
|
+
while (
|
|
381
|
+
result := failure_action(blackboard) # pyrefly: ignore
|
|
382
|
+
) == TreeStatus.RUNNING:
|
|
383
|
+
blackboard = yield TreeStatus.RUNNING
|
|
384
|
+
return result
|
|
385
|
+
return TreeStatus.SUCCESS
|
|
386
|
+
|
|
387
|
+
stepper = gen()
|
|
388
|
+
next(stepper)
|
|
389
|
+
|
|
390
|
+
def inner(blackboard: BlackboardType):
|
|
391
|
+
nonlocal stepper
|
|
392
|
+
try:
|
|
393
|
+
return stepper.send(blackboard)
|
|
394
|
+
except StopIteration as e:
|
|
395
|
+
return cast(TreeStatus, e.value)
|
|
396
|
+
|
|
397
|
+
try:
|
|
398
|
+
yield inner
|
|
399
|
+
finally:
|
|
400
|
+
stepper.close()
|
btreeny/_ctx.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import contextvars
|
|
2
|
+
import uuid
|
|
3
|
+
from ._tree_status import TreeStatus
|
|
4
|
+
|
|
5
|
+
id_map = contextvars.ContextVar[dict[uuid.UUID, str]]("call_stack")
|
|
6
|
+
id_map.set({})
|
|
7
|
+
call_stack = contextvars.ContextVar[list[uuid.UUID]]("call_stack")
|
|
8
|
+
call_stack.set([])
|
|
9
|
+
tree_graph = contextvars.ContextVar[dict[uuid.UUID | None, list[uuid.UUID]]](
|
|
10
|
+
"tree_graph"
|
|
11
|
+
)
|
|
12
|
+
tree_graph.set({})
|
|
13
|
+
tree_status = contextvars.ContextVar[dict[uuid.UUID, TreeStatus]]("tree_status")
|
|
14
|
+
tree_status.set({})
|
btreeny/_get_name.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def get_name(obj: Any) -> str:
|
|
5
|
+
"""Attempt to fetch a human readable name for an object"""
|
|
6
|
+
if (name := getattr(obj, "__name__", None)) is not None:
|
|
7
|
+
return name
|
|
8
|
+
elif (cls := getattr(obj, "__class__", None)) is not None:
|
|
9
|
+
return getattr(cls, "__name__", str(obj))
|
|
10
|
+
return str(obj)
|
btreeny/_tree_status.py
ADDED
btreeny/py.typed
ADDED
|
File without changes
|
btreeny/viz.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
from collections import deque
|
|
2
|
+
from dataclasses import asdict, dataclass
|
|
3
|
+
from typing import Callable
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
from rich.pretty import pprint
|
|
7
|
+
from rich.tree import Tree
|
|
8
|
+
|
|
9
|
+
__has_rich = True
|
|
10
|
+
except ImportError:
|
|
11
|
+
from pprint import pprint
|
|
12
|
+
|
|
13
|
+
__has_rich = False
|
|
14
|
+
import uuid
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
import rerun as rr
|
|
18
|
+
|
|
19
|
+
__has_rerun = True
|
|
20
|
+
except ImportError:
|
|
21
|
+
__has_rerun = False
|
|
22
|
+
|
|
23
|
+
from ._ctx import (
|
|
24
|
+
tree_graph as __ctx_tree_graph,
|
|
25
|
+
id_map as __ctx_id_map,
|
|
26
|
+
tree_status as __ctx_tree_status,
|
|
27
|
+
)
|
|
28
|
+
from ._tree_status import TreeStatus
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def print_trace(print_func: Callable[[str], None] = print):
|
|
32
|
+
"""Print the current state of the tree using a specified function.
|
|
33
|
+
|
|
34
|
+
Parameters
|
|
35
|
+
----------
|
|
36
|
+
print_func: (str) -> None
|
|
37
|
+
The printing function to use, defaults to the builtin `print` function.
|
|
38
|
+
"""
|
|
39
|
+
_id_map = __ctx_id_map.get()
|
|
40
|
+
_tree_graph = __ctx_tree_graph.get()
|
|
41
|
+
_tree_status = __ctx_tree_status.get()
|
|
42
|
+
root_actions = _tree_graph[None]
|
|
43
|
+
q = deque()
|
|
44
|
+
print_func(f"\n{' Trace ':-^50}")
|
|
45
|
+
for action_id in root_actions:
|
|
46
|
+
q.append((action_id, 0))
|
|
47
|
+
while len(q) > 0:
|
|
48
|
+
action_id, indent_count = q.popleft()
|
|
49
|
+
indent = " " * indent_count * 4
|
|
50
|
+
action_name = _id_map[action_id]
|
|
51
|
+
action_status = _tree_status.get(action_id, None)
|
|
52
|
+
if action_status is not None:
|
|
53
|
+
action_status = action_status.value
|
|
54
|
+
print_func(f"{indent}{action_id} {action_name} - {action_status}")
|
|
55
|
+
for child in _tree_graph.get(action_id, [])[::-1]:
|
|
56
|
+
q.appendleft((child, indent_count + 1))
|
|
57
|
+
print_func("-" * 50 + "\n")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class TreeStatusGraph:
|
|
62
|
+
node: str
|
|
63
|
+
status: TreeStatus
|
|
64
|
+
children: "list[TreeStatusGraph]"
|
|
65
|
+
|
|
66
|
+
def pprint(self):
|
|
67
|
+
pprint(asdict(self))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def get_tree_status() -> "TreeStatusGraph":
|
|
71
|
+
"""Fetch the current state of the tree as a tree datastructure.
|
|
72
|
+
|
|
73
|
+
Returns
|
|
74
|
+
-------
|
|
75
|
+
TreeStatus:
|
|
76
|
+
The root of the behavior tree.
|
|
77
|
+
"""
|
|
78
|
+
_id_map = __ctx_id_map.get()
|
|
79
|
+
_tree_graph = __ctx_tree_graph.get()
|
|
80
|
+
_tree_status = __ctx_tree_status.get()
|
|
81
|
+
|
|
82
|
+
root_actions = _tree_graph[None]
|
|
83
|
+
assert len(root_actions) == 1
|
|
84
|
+
root_action = root_actions[0]
|
|
85
|
+
node_map: dict[uuid.UUID, TreeStatusGraph] = {}
|
|
86
|
+
node_map[root_action] = TreeStatusGraph(
|
|
87
|
+
node=_id_map[root_action], status=_tree_status[root_action], children=[]
|
|
88
|
+
)
|
|
89
|
+
q = deque[uuid.UUID]([])
|
|
90
|
+
q.append(root_action)
|
|
91
|
+
while len(q) > 0:
|
|
92
|
+
action = q.popleft()
|
|
93
|
+
try:
|
|
94
|
+
children = _tree_graph[action]
|
|
95
|
+
except KeyError:
|
|
96
|
+
continue
|
|
97
|
+
for child in children:
|
|
98
|
+
child_name = _id_map[child]
|
|
99
|
+
child_status = _tree_status[child]
|
|
100
|
+
node_map[child] = TreeStatusGraph(
|
|
101
|
+
node=child_name, status=child_status, children=[]
|
|
102
|
+
)
|
|
103
|
+
node_map[action].children.append(node_map[child])
|
|
104
|
+
q.append(child)
|
|
105
|
+
return node_map[root_action]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
if __has_rerun:
|
|
109
|
+
|
|
110
|
+
@dataclass
|
|
111
|
+
class RerunGraph:
|
|
112
|
+
nodes: rr.GraphNodes
|
|
113
|
+
edges: rr.GraphEdges
|
|
114
|
+
|
|
115
|
+
def rerun_tree_graph() -> RerunGraph:
|
|
116
|
+
_id_map = __ctx_id_map.get()
|
|
117
|
+
_tree_graph = __ctx_tree_graph.get()
|
|
118
|
+
_tree_status = __ctx_tree_status.get()
|
|
119
|
+
keys = list(_tree_status.keys())
|
|
120
|
+
|
|
121
|
+
def _color_from_status(s: TreeStatus) -> int:
|
|
122
|
+
match s:
|
|
123
|
+
case TreeStatus.SUCCESS:
|
|
124
|
+
return 0x119911FF
|
|
125
|
+
case TreeStatus.FAILURE:
|
|
126
|
+
return 0x991111FF
|
|
127
|
+
case TreeStatus.RUNNING:
|
|
128
|
+
return 0xBB6633FF
|
|
129
|
+
case _:
|
|
130
|
+
raise RuntimeError(f"Not a valid status {s}")
|
|
131
|
+
|
|
132
|
+
return RerunGraph(
|
|
133
|
+
nodes=rr.GraphNodes(
|
|
134
|
+
node_ids=list(map(str, keys)),
|
|
135
|
+
labels=[f"{_id_map[k]}\n{_tree_status[k]}" for k in keys],
|
|
136
|
+
colors=[_color_from_status(_tree_status[k]) for k in keys],
|
|
137
|
+
show_labels=True,
|
|
138
|
+
),
|
|
139
|
+
edges=rr.GraphEdges(
|
|
140
|
+
edges=[
|
|
141
|
+
(str(parent), str(child))
|
|
142
|
+
for parent in keys
|
|
143
|
+
for child in _tree_graph.get(parent, [])
|
|
144
|
+
],
|
|
145
|
+
graph_type="directed",
|
|
146
|
+
),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
if __has_rich:
|
|
151
|
+
|
|
152
|
+
def get_rich_tree() -> Tree:
|
|
153
|
+
_id_map = __ctx_id_map.get()
|
|
154
|
+
_tree_graph = __ctx_tree_graph.get()
|
|
155
|
+
_tree_status = __ctx_tree_status.get()
|
|
156
|
+
try:
|
|
157
|
+
root_actions = _tree_graph[None]
|
|
158
|
+
except KeyError:
|
|
159
|
+
return Tree("root")
|
|
160
|
+
assert len(root_actions) == 1, "Expected one root action"
|
|
161
|
+
root = root_actions[0]
|
|
162
|
+
q = deque[tuple[uuid.UUID, Tree]]()
|
|
163
|
+
tree = Tree(f"{_id_map[root]} - {_tree_status[root].value}")
|
|
164
|
+
q.append((root, tree))
|
|
165
|
+
while len(q) > 0:
|
|
166
|
+
action_id, parent_tree = q.popleft()
|
|
167
|
+
action_name = _id_map[action_id]
|
|
168
|
+
action_status = _tree_status.get(action_id, None)
|
|
169
|
+
if action_status is not None:
|
|
170
|
+
action_status = action_status.value
|
|
171
|
+
else:
|
|
172
|
+
action_status = "Not Run"
|
|
173
|
+
child_tree = parent_tree.add(f"{action_name} - {action_status}")
|
|
174
|
+
for child in _tree_graph.get(action_id, [])[::-1]:
|
|
175
|
+
q.appendleft((child, child_tree))
|
|
176
|
+
return tree
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: btreeny
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Author: Tim Lingard
|
|
6
|
+
Author-email: Tim Lingard <tklingard@gmail.com>
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# Welcome to BTreeny!
|
|
11
|
+
|
|
12
|
+
This package is a minimal(ish) implementation of [Behavior Trees](https://en.wikipedia.org/wiki/Behavior_tree_(artificial_intelligence,_robotics_and_control)) in Python.
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
import btreeny
|
|
17
|
+
|
|
18
|
+
# Generic over blackboards - use dataclasses to get nice type hints!
|
|
19
|
+
MyBlackboardType = dict[str, str]
|
|
20
|
+
|
|
21
|
+
# For the most simple case - define your actions as a function which takes a blackbaord
|
|
22
|
+
# and returns a tree
|
|
23
|
+
@btreeny.simple_action
|
|
24
|
+
def my_failing_action(blackboard: MyBlackboardType):
|
|
25
|
+
# You could modify the blackboard, or take actions here
|
|
26
|
+
return btreeny.TreeStatus.FAILURE
|
|
27
|
+
|
|
28
|
+
# For more complex actions
|
|
29
|
+
@btreeny.action
|
|
30
|
+
def my_running_action():
|
|
31
|
+
# Setup
|
|
32
|
+
# ...
|
|
33
|
+
|
|
34
|
+
# Yield a tick function
|
|
35
|
+
def _inner(blackboard: MyBlackboardType):
|
|
36
|
+
return btreeny.TreeStatus.RUNNING
|
|
37
|
+
try:
|
|
38
|
+
yield inner
|
|
39
|
+
finally:
|
|
40
|
+
# Teardown
|
|
41
|
+
# ...
|
|
42
|
+
|
|
43
|
+
# We support many standard control flow nodes - see below for options
|
|
44
|
+
root = btreeny.fallback(
|
|
45
|
+
my_failing_action(),
|
|
46
|
+
my_running_action(),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# Running the tree can be done manually
|
|
50
|
+
blackboard = {}
|
|
51
|
+
result = btreeny.TreeStatus.RUNNING
|
|
52
|
+
with root as tick_function:
|
|
53
|
+
while result == btreeny.TreeStatus.RUNNING:
|
|
54
|
+
# We expect trees to modify the blackboard in-place
|
|
55
|
+
result = tick_function(blackboard)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
## Writing an action
|
|
60
|
+
|
|
61
|
+
In `btreeny`, an action is specified as a context which returns a callable function to "tick" the action. This allows you to manage the setup and teardown of resources required by that action.
|
|
62
|
+
|
|
63
|
+
For example, an action which polls a URL until it gets a 200 status code, and will fail after some number of retries, might look like:
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
@btreeny.action
|
|
67
|
+
def poll_url(url: str, retries: int=10):
|
|
68
|
+
# setup a client to allow connection pooling
|
|
69
|
+
client = httpx.Client()
|
|
70
|
+
retry_count = 0
|
|
71
|
+
def tick(blackboard: Any):
|
|
72
|
+
# Since we're assigning to retry_count, we should declare it as nonlocal
|
|
73
|
+
# to this function's scope
|
|
74
|
+
nonlocal retry_count
|
|
75
|
+
if retry_count > retries:
|
|
76
|
+
return btreeny.TreeStatus.FAILURE
|
|
77
|
+
response = client.get(url)
|
|
78
|
+
retry_count += 1
|
|
79
|
+
if response.status_code == 200:
|
|
80
|
+
return btreeny.TreeStatus.SUCCESS
|
|
81
|
+
return btreeny.TreeStatus.RUNNING
|
|
82
|
+
# Use a try... finally block to ensure cleanup is run
|
|
83
|
+
try:
|
|
84
|
+
# yield the tick function
|
|
85
|
+
yield tick
|
|
86
|
+
finally:
|
|
87
|
+
# We can finish the function with our cleanup
|
|
88
|
+
client.close()
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Note that in this case it'd have been more ergonomic to use
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
with httpx.Client() as client:
|
|
95
|
+
def tick(...):
|
|
96
|
+
...
|
|
97
|
+
yield tick
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
As the client would have been closed for us!
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
## Controlling flow
|
|
104
|
+
|
|
105
|
+
### Sequential
|
|
106
|
+
Accepts multiple children to cycle through. When each child succeeds, move to the next action. If any child fails then the node fails.
|
|
107
|
+
|
|
108
|
+
### Fallback
|
|
109
|
+
Accepts multiple children to cycle through. If a child fails, move to the next action. If any child suceeds then the node fails.
|
|
110
|
+
|
|
111
|
+
### Repeat / Retry / Redo
|
|
112
|
+
Accepts a factory function and an optional number of retries. If the resulting action matches the specified `continue_if` value, recreate the action using the factory function and carry on.
|
|
113
|
+
|
|
114
|
+
- Retry wraps `repeat` with `continue_if=TreeStatus.FAILURE`
|
|
115
|
+
- Redo wraps `repeat` with `continue_if=TreeStatus.SUCCESS`
|
|
116
|
+
|
|
117
|
+
### Remap
|
|
118
|
+
Map output states to different values - e.g. convert all `SUCCESS` outputs into `FAILURE`. Note that this is not reciprocal! You could, for example, use this to convert all outputs to `RUNNING`.
|
|
119
|
+
|
|
120
|
+
`remap` has some utilities
|
|
121
|
+
- `swap`: Reciprocally map between two states (e.g. Failure <-> Success)
|
|
122
|
+
- `remap_to_always`: Convert the output of the action to always be this value.
|
|
123
|
+
|
|
124
|
+
### React
|
|
125
|
+
Given some condition check which runs on each tick with current blackboard, alternate between two different sub-trees.
|
|
126
|
+
|
|
127
|
+
Note that this function resumes a sub-tree where it has left off, rather than trying to restart it. This can create undesirable behavior without careful planning.
|
|
128
|
+
|
|
129
|
+
### Failsafe
|
|
130
|
+
Given some condition check which runs on each tick with the current blackboard, if the check ever fails move to a failure tree.
|
|
131
|
+
|
|
132
|
+
Useful when combined with `redo` to allow failsafe behaviour which can recover to continue normal operations.
|
|
133
|
+
|
|
134
|
+
This action allows fallback to a charging state on low battery in the `waypoint_navigation` example script.
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
## Logging and Visualization
|
|
138
|
+
|
|
139
|
+
Understanding what's going on in your behavior tree is crucial for debugging and triaging issues - btreeny has an (opinionated) set of logging utilities, but lets you access the underlying data to write your own.
|
|
140
|
+
|
|
141
|
+
The simplest way to log the current tree state is simply to use the
|
|
142
|
+
|
|
143
|
+
### Rich
|
|
144
|
+
|
|
145
|
+
Rich is a great library for pretty printing in the terminal, to get the current tree state as a [rich.Tree](https://rich.readthedocs.io/en/stable/tree.html) renderable, use `btree.viz.get_rich_tree()`.
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
from rich.print import print
|
|
149
|
+
tree = btree.viz.get_rich_tree()
|
|
150
|
+
print(tree)
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### Rerun
|
|
154
|
+
|
|
155
|
+
Rerun is a great tool for visualizing robotics applications - and we want to make it easy for you to add your `btreeny` state to each timestep.
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
import rerun as rr
|
|
159
|
+
# fetch the current tree state as a dataclass with Rerun `rr.GraphNodes` and `rr.GraphEdges`
|
|
160
|
+
graph = btreeny.viz.rerun_tree_graph()
|
|
161
|
+
# Log to Rerun
|
|
162
|
+
rr.log("tree", graph.nodes, graph.edges)
|
|
163
|
+
```
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
btreeny/__init__.py,sha256=042d5eebd6f7d654f7eddb87ffe3dc0b5846d4c7cce13fd9c86b919c239ec709,12823
|
|
2
|
+
btreeny/_ctx.py,sha256=3c6f6a5e50c0965b5119adbc7980fc04727de56ed8148aee20a6fd5c010c58df,455
|
|
3
|
+
btreeny/_get_name.py,sha256=baeeb9f6fa771ab010c0961208544f4a54d8045c40a09c19f7fbfac52c8f2725,333
|
|
4
|
+
btreeny/_tree_status.py,sha256=496505ff7506a51ac47062795ad0e44d07ebdf4190f6dd2c22628a1422db400e,125
|
|
5
|
+
btreeny/py.typed,sha256=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855,0
|
|
6
|
+
btreeny/viz.py,sha256=5149bf1ee07198886b93c5a9bdb724d61cca7d1adf5ae52005f71355c4855604,5363
|
|
7
|
+
btreeny-0.1.0.dist-info/WHEEL,sha256=b70116f4076fa664af162441d2ba3754dbb4ec63e09d563bdc1e9ab023cce400,78
|
|
8
|
+
btreeny-0.1.0.dist-info/METADATA,sha256=004c3c101b2acd5c6b155bb3ffa243e6180f098bf89c1429d9cf5268b98c874e,5602
|
|
9
|
+
btreeny-0.1.0.dist-info/RECORD,,
|