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 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)
@@ -0,0 +1,7 @@
1
+ from enum import Enum
2
+
3
+
4
+ class TreeStatus(str, Enum):
5
+ RUNNING = "RUNNING"
6
+ SUCCESS = "SUCCESS"
7
+ FAILURE = "FAILURE"
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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.8.3
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any