py_branches 1.2.1__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.
@@ -0,0 +1,9 @@
1
+ from . import alternating
2
+ from . import blackboard
3
+ from . import cooldown
4
+ from . import counter
5
+ from . import latch
6
+ from . import pause
7
+ from . import random
8
+ from . import retry
9
+ from . import timeout
@@ -0,0 +1,215 @@
1
+ #!/usr/bin/env python3
2
+ import py_trees
3
+ import random
4
+ from typing import List
5
+ from typing import Tuple
6
+
7
+
8
+ class ActivateBehavior(py_trees.decorators.Decorator):
9
+ '''
10
+ Enables activation of a behavior from an external source as long as it has a handle to
11
+ this decorator.
12
+
13
+ Args:
14
+ child(Behavior): The child behavior that is being activated or not activated.
15
+ name(str): Name of this behavior
16
+ activate(bool): Whether or not to start this behavior activated or not.
17
+ '''
18
+ def __init__(self, child: py_trees.behaviour.Behaviour,
19
+ name: str,
20
+ activate: bool,
21
+ success_if_skip:bool=False):
22
+ super(ActivateBehavior, self).__init__(name=name, child=child)
23
+ self._activate = activate
24
+ self._success_if_skip = success_if_skip
25
+
26
+ @property
27
+ def activate(self):
28
+ return self._activate
29
+
30
+ @activate.setter
31
+ def activate(self, activate: bool):
32
+ self._activate = activate
33
+
34
+ def tick(self):
35
+ if not self._activate:
36
+ if self._success_if_skip:
37
+ self.stop(py_trees.common.Status.SUCCESS)
38
+ else:
39
+ self.stop(py_trees.common.Status.FAILURE)
40
+ yield self
41
+ else:
42
+ for node in super().tick():
43
+ yield node
44
+
45
+ def update(self) -> py_trees.common.Status:
46
+ return self.decorated.status
47
+
48
+ class _RunAlternatingHelper(py_trees.behaviour.Behaviour):
49
+ def __init__(self, name: str, activatable_behaviors: List[ActivateBehavior], counts: List[int]):
50
+ self._counts = counts
51
+ self._current_behavior_idx = 0
52
+ self._current_behavior_num_consecutive_runs = 0
53
+ self._activatable_behaviors = activatable_behaviors
54
+ self._activatable_behaviors[0].activate = True
55
+
56
+ super().__init__(name)
57
+
58
+ def initialise(self) -> None:
59
+ if self._current_behavior_num_consecutive_runs >= self._counts[self._current_behavior_idx]:
60
+ self._activatable_behaviors[self._current_behavior_idx].activate = False
61
+ self._current_behavior_idx = (self._current_behavior_idx + 1) % len(self._counts)
62
+ self._activatable_behaviors[self._current_behavior_idx].activate = True
63
+ self._current_behavior_num_consecutive_runs = 0
64
+
65
+ self._current_behavior_num_consecutive_runs += 1
66
+
67
+ def update(self) -> py_trees.common.Status:
68
+ return py_trees.common.Status.FAILURE
69
+
70
+ def run_alternating(name: str, behaviors: List[py_trees.behaviour.Behaviour], counts: List[int]):
71
+ '''
72
+ Args:
73
+ name(str): Name of the behavior
74
+ behaviors(List[Behaviors]): List of all the behaviors to run alternating.
75
+ count(List[int]): A list of how many times the corresponding behavior ought to be ran.
76
+
77
+ Example:
78
+ A if behaviors is [behavior_a, behavior_b, behavior_c] and count is [3,2,4] then
79
+ behavior_a will run 3 times in a row, behavior_b will run 2 times in a row, and
80
+ behavior_c will run 4 times in a row before repeating.
81
+ '''
82
+ if 0 in counts:
83
+ raise ValueError(f'counts({counts}) can not have 0 in the list.')
84
+ if len(counts) != len(behaviors):
85
+ raise ValueError('len(counts) != len(behaviors), two lists must be of same length.')
86
+
87
+ alternating_behaviors = []
88
+ for idx, behavior in enumerate(behaviors):
89
+ activate_decorator = ActivateBehavior(behavior, f'activate_{behavior.name}', False)
90
+ alternating_behaviors.append(activate_decorator)
91
+ run_alternating_helper = _RunAlternatingHelper(f'{name}_helper', alternating_behaviors, counts)
92
+
93
+ children = []
94
+ children.append(run_alternating_helper)
95
+ children += alternating_behaviors
96
+
97
+ run_alternating_selector = py_trees.composites.Selector(name, False, children)
98
+ return run_alternating_selector
99
+
100
+ class RunEveryRange(py_trees.decorators.Decorator):
101
+ '''
102
+ Enables the activation of child every for a range of calls within
103
+
104
+ Args:
105
+ child(Behavior): The child behavior that is being activated or not activated.
106
+ name(str): Name of this behavior.
107
+ max_range(int): Maximum number of runs before the iterations resets to 1.
108
+ run_range(Tuple[int, int]): Determines which range of iterations that the child
109
+ will execute for. Range is inclusive.
110
+
111
+ Example:
112
+ E: Executes that cycle.
113
+ S: Skips that cycle.
114
+ if max_range and run_range is:
115
+ 6 and (4,6) the the child will run on the 4th, 5th, and 6th cycle.
116
+ S, S, S, E, E, E, S, S, S, E, E, E, S, S, S, ...
117
+ 6 and (2,4) then the child will run on the 2nd, 3rd, and 4th cycle.
118
+ S, E, E, E, S, S, S, E, E, E, S, S, S, E, E, ...
119
+ '''
120
+ def __init__(self, child: py_trees.behaviour.Behaviour,
121
+ name: str,
122
+ max_range: int,
123
+ run_range: Tuple[int, int],
124
+ success_if_skip: bool = False):
125
+ if run_range[0] > run_range[1]:
126
+ raise ValueError('run_range must be a tuple with (smaller_number, bigger_number)')
127
+ if run_range[0] < 1:
128
+ raise ValueError('Lower run range must be greater or equal to 1')
129
+ if run_range[1] > max_range:
130
+ raise ValueError(f'Upper run range must be lower or equal to {max_range}')
131
+
132
+ super(RunEveryRange, self).__init__(name=name, child=child)
133
+ self._max_range = max_range
134
+ self._run_range = run_range
135
+ self._success_if_skip = success_if_skip
136
+ self._iteration = 1
137
+
138
+ def tick(self):
139
+ if self._run_range[0] <= self._iteration <= self._run_range[1]:
140
+ for node in super().tick():
141
+ yield node
142
+ else:
143
+ if self._success_if_skip:
144
+ self.stop(py_trees.common.Status.SUCCESS)
145
+ else:
146
+ self.stop(py_trees.common.Status.FAILURE)
147
+ yield self
148
+
149
+ def terminate(self, new_status: py_trees.common.Status) -> None:
150
+ self._iteration += 1
151
+ if self._iteration > self._max_range:
152
+ self._iteration = 1
153
+
154
+ def update(self) -> py_trees.common.Status:
155
+ return self.decorated.status
156
+
157
+ class RunEveryX(py_trees.decorators.Decorator):
158
+ '''
159
+ Enables the activation of child every X calls. X can falls within a range
160
+ and gets recalculated every success.
161
+
162
+ Args:
163
+ child(Behavior): The child behavior that is being activated or not activated.
164
+ name(str): Name of this behavior
165
+ every_x_range(Tuple[int, int]): Run the child ever however many cycles. Number of
166
+ cycles is within a range. Range is inclusive.
167
+
168
+ Example:
169
+ E: Executes that cycle.
170
+ S: Skips that cycle.
171
+ if every_x_range is:
172
+ (1,1) then the child behavior will run every cycle.
173
+ E, E, E, E, E, E, E, E, E, E, E, E, E, E, E, ...
174
+ (5,5) then the child behavior will run every 5th cycle.
175
+ S, S, S, S, E, S, S, S, S, E, S, S, S, S, E, ...
176
+ (1,5) then the child behavior will run randomly between every
177
+ cycle or every 5th cycle. Changes every times it child gets executed.
178
+ S, S, E, S, S, S, S, E, E, S, S, S, E, S, S, ...
179
+ The execute cycle above goes:
180
+ 3: S, S, E
181
+ 5: S, S, S, S, E
182
+ 1: E
183
+ 4: S, S, S, E
184
+ '''
185
+ def __init__(self, child: py_trees.behaviour.Behaviour,
186
+ name: str,
187
+ every_x_range: Tuple[int, int],
188
+ success_if_skip:bool=False):
189
+ if every_x_range[0] > every_x_range[1]:
190
+ raise ValueError('every_x_range must be a tuple with (smaller_number, bigger_number)')
191
+ if every_x_range[0] < 1:
192
+ raise ValueError('Can not have range be lower than 1.')
193
+
194
+ super(RunEveryX, self).__init__(name=name, child=child)
195
+ self._every_x_range = every_x_range
196
+ self._cycles_remaining = random.randint(*self._every_x_range)-1
197
+ self._success_if_skip = success_if_skip
198
+
199
+ def initialise(self):
200
+ self._cycles_remaining = random.randint(*self._every_x_range)-1
201
+
202
+ def tick(self):
203
+ if self._cycles_remaining > 0:
204
+ self._cycles_remaining -= 1
205
+ if self._success_if_skip:
206
+ self.stop(py_trees.common.Status.SUCCESS)
207
+ else:
208
+ self.stop(py_trees.common.Status.FAILURE)
209
+ yield self
210
+ else:
211
+ for node in super().tick():
212
+ yield node
213
+
214
+ def update(self):
215
+ return self.decorated.status
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env python3
2
+ from typing import Any
3
+ from typing import Optional
4
+ import py_trees
5
+
6
+
7
+ def _get_and_check(bb: py_trees.blackboard.Client, var: str, types: Optional[list], logger):
8
+ try:
9
+ value = bb.get(var)
10
+ except KeyError:
11
+ logger.warning(f'Tried to access blackboard variable {var} but it does not exist.')
12
+ return None
13
+ if value is None:
14
+ logger.warning(f'Tried to access blackboard variable {var} but it does not exist.')
15
+ return None
16
+ if types is not None and type(value) not in types:
17
+ logger.warning(f'Tried to access blackboard variable {var} ' +
18
+ f'of type {type(value)}, variable must be one of {types}.')
19
+ return None
20
+ return value
21
+
22
+ class IncrementBlackboardVariable(py_trees.behaviour.Behaviour):
23
+ def __init__(self, name: str, variable_name: str, increment_by: float=1.0):
24
+ super(IncrementBlackboardVariable, self).__init__(name)
25
+ self._variable_name = variable_name
26
+ self._increment_by = increment_by
27
+ self._return_sucess = False
28
+ self._blackboard = py_trees.blackboard.Client()
29
+ self._blackboard.register_key(key=variable_name, access=py_trees.common.Access.WRITE)
30
+
31
+ def initialise(self):
32
+ self._return_sucess = False
33
+ current_value = _get_and_check(self._blackboard, self._variable_name, [int, float], self.logger)
34
+ if current_value is None:
35
+ self.logger.warning(
36
+ f'Failed to increment blackboard variable {self._variable_name}: value missing or invalid.'
37
+ )
38
+ return
39
+ self._blackboard.set(self._variable_name, current_value+self._increment_by)
40
+ self._return_sucess = True
41
+
42
+ def update(self):
43
+ if self._return_sucess:
44
+ return py_trees.common.Status.SUCCESS
45
+ else:
46
+ return py_trees.common.Status.FAILURE
47
+
48
+ class IncrementBlackboardVariableIfCondition(py_trees.decorators.Decorator):
49
+ def __init__(self, child, name: str, variable_name: str, condition: py_trees.common.Status, increment_by: float=1.0):
50
+ super(IncrementBlackboardVariableIfCondition, self).__init__(name=name, child=child)
51
+ self._variable_name = variable_name
52
+ self._condition = condition
53
+ self._increment_by = increment_by
54
+ self._blackboard = py_trees.blackboard.Client()
55
+ self._blackboard.register_key(key=variable_name, access=py_trees.common.Access.WRITE)
56
+
57
+ def update(self):
58
+ if self.decorated.status == self._condition:
59
+ current_value = _get_and_check(self._blackboard, self._variable_name, [int, float], self.logger)
60
+ if current_value is not None:
61
+ self._blackboard.set(self._variable_name, current_value+self._increment_by, overwrite=True)
62
+
63
+ return self.decorated.status
64
+
65
+ class SetBlackboardVariableIfCondition(py_trees.decorators.Decorator):
66
+ def __init__(self, child, name: str, variable_name: str, condition: py_trees.common.Status, set_to: Any):
67
+ super(SetBlackboardVariableIfCondition, self).__init__(name=name, child=child)
68
+ self._variable_name = variable_name
69
+ self._condition = condition
70
+ self._set_to = set_to
71
+ self._blackboard = py_trees.blackboard.Client()
72
+ self._blackboard.register_key(key=variable_name, access=py_trees.common.Access.WRITE)
73
+
74
+ def update(self):
75
+ if self.decorated.status == self._condition:
76
+ self._blackboard.set(self._variable_name, self._set_to, overwrite=True)
77
+
78
+ return self.decorated.status
79
+
80
+ class RunIfBlackboardVariableEquals(py_trees.decorators.Decorator):
81
+ def __init__(self, child, name: str, variable_name: str, equals: Any, success_if_skip: bool=True):
82
+ super(RunIfBlackboardVariableEquals, self).__init__(name=name, child=child)
83
+ self._variable_name = variable_name
84
+ self._equals = equals
85
+ self._blackboard = py_trees.blackboard.Client()
86
+ self._blackboard.register_key(key=variable_name, access=py_trees.common.Access.READ)
87
+ self._run_child = False
88
+ self._ret_status_on_failure = py_trees.common.Status.SUCCESS if success_if_skip else py_trees.common.Status.FAILURE
89
+
90
+ def tick(self):
91
+ # Re-evaluate the condition on each fresh entry; preserve it while child is RUNNING.
92
+ if self.status != py_trees.common.Status.RUNNING:
93
+ current_value = _get_and_check(self._blackboard, self._variable_name, None, self.logger)
94
+ self._run_child = current_value == self._equals
95
+
96
+ if self._run_child:
97
+ for node in py_trees.decorators.Decorator.tick(self):
98
+ yield node
99
+ else:
100
+ for node in py_trees.behaviour.Behaviour.tick(self):
101
+ yield node
102
+
103
+ def update(self):
104
+ if self._run_child:
105
+ if self.decorated.status != py_trees.common.Status.RUNNING:
106
+ self._run_child = False
107
+ return self.decorated.status
108
+ else:
109
+ self._run_child = False
110
+ return self._ret_status_on_failure
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env python3
2
+ import time
3
+ import py_trees
4
+
5
+
6
+ class Cooldown(py_trees.decorators.Decorator):
7
+ '''
8
+ Prevents a child from running again until a cooldown period has elapsed
9
+ after its last completion.
10
+
11
+ The child runs normally on the first tick. Once it completes (SUCCESS or
12
+ FAILURE), a cooldown timer starts. During the cooldown, the child is not
13
+ ticked and this decorator returns FAILURE (or SUCCESS if
14
+ success_if_cooling=True). After the cooldown expires the child may run
15
+ again.
16
+
17
+ A child that stays RUNNING is never subject to the cooldown — the timer
18
+ only starts once the child actually finishes.
19
+
20
+ Args:
21
+ child (Behaviour): The child behavior to rate-limit.
22
+ name (str): Name of this decorator.
23
+ duration (float): Cooldown period in seconds after each completion.
24
+ success_if_cooling (bool): Return SUCCESS instead of FAILURE while
25
+ cooling down. Default False.
26
+
27
+ Example:
28
+ child = py_trees.behaviours.Success(name="Expensive")
29
+ # Run child freely, but enforce a 5-second gap between executions.
30
+ cooled = Cooldown(child, name="Cooldown", duration=5.0)
31
+ '''
32
+ def __init__(self, child: py_trees.behaviour.Behaviour,
33
+ name: str,
34
+ duration: float,
35
+ success_if_cooling: bool = False):
36
+ if duration <= 0.0:
37
+ raise ValueError(f'duration({duration}) must be positive.')
38
+ super(Cooldown, self).__init__(name=name, child=child)
39
+ self._duration = duration
40
+ self._success_if_cooling = success_if_cooling
41
+ self._cooling = False
42
+ self._cool_start = None
43
+
44
+ def tick(self):
45
+ if self._cooling:
46
+ elapsed = time.time() - self._cool_start
47
+ if elapsed < self._duration:
48
+ if self._success_if_cooling:
49
+ self.stop(py_trees.common.Status.SUCCESS)
50
+ else:
51
+ self.stop(py_trees.common.Status.FAILURE)
52
+ yield self
53
+ return
54
+ else:
55
+ self._cooling = False
56
+
57
+ for node in super().tick():
58
+ yield node
59
+
60
+ def update(self) -> py_trees.common.Status:
61
+ status = self.decorated.status
62
+ if status != py_trees.common.Status.RUNNING:
63
+ self._cooling = True
64
+ self._cool_start = time.time()
65
+ return status
py_branches/counter.py ADDED
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env python3
2
+ import py_trees
3
+
4
+
5
+ class Counter(py_trees.decorators.Decorator):
6
+ '''
7
+ Runs a child behavior exactly num_runs times (total completions), then
8
+ permanently returns completion_status without ever running the child again.
9
+
10
+ A "completion" is any tick where the child returns SUCCESS or FAILURE.
11
+ RUNNING ticks do not count — the counter waits for the child to finish each
12
+ run before counting it.
13
+
14
+ The run count and done flag persist across tree re-entries (i.e. they are
15
+ NOT reset by initialise()). This makes Counter suitable for one-time
16
+ initialization sequences. To reset and re-count, call reset() explicitly.
17
+
18
+ Args:
19
+ child (Behaviour): The child behavior to count.
20
+ name (str): Name of this decorator.
21
+ num_runs (int): Total number of child completions to allow.
22
+ completion_status (Status): Status returned permanently once num_runs
23
+ completions have occurred. Default SUCCESS.
24
+
25
+ Example:
26
+ child = InitializationBehavior(name="Init")
27
+ # Run Init exactly once; after it completes, always return SUCCESS.
28
+ counted = Counter(child, name="RunOnce", num_runs=1)
29
+
30
+ child = CalibrateStep(name="Calibrate")
31
+ # Run calibration exactly 3 times, then always return SUCCESS.
32
+ counted = Counter(child, name="Calibrate3x", num_runs=3)
33
+ '''
34
+ def __init__(self, child: py_trees.behaviour.Behaviour,
35
+ name: str,
36
+ num_runs: int,
37
+ completion_status: py_trees.common.Status = py_trees.common.Status.SUCCESS):
38
+ if num_runs < 1:
39
+ raise ValueError(f'num_runs({num_runs}) must be greater than 0.')
40
+ super(Counter, self).__init__(name=name, child=child)
41
+ self._num_runs = num_runs
42
+ self._completion_status = completion_status
43
+ self._runs_completed = 0
44
+ self._done = False
45
+
46
+ def reset(self) -> None:
47
+ '''Reset the run count so the child will be run num_runs times again.'''
48
+ self._runs_completed = 0
49
+ self._done = False
50
+
51
+ def tick(self):
52
+ if self._done:
53
+ self.stop(self._completion_status)
54
+ yield self
55
+ else:
56
+ for node in super().tick():
57
+ yield node
58
+
59
+ def update(self) -> py_trees.common.Status:
60
+ status = self.decorated.status
61
+ if status == py_trees.common.Status.RUNNING:
62
+ return py_trees.common.Status.RUNNING
63
+
64
+ # Child completed this tick (SUCCESS or FAILURE).
65
+ self._runs_completed += 1
66
+ if self._runs_completed >= self._num_runs:
67
+ self._done = True
68
+ self.decorated.stop(py_trees.common.Status.INVALID)
69
+ return self._completion_status
70
+
71
+ # More runs remain — reset child so it re-runs next tick.
72
+ self.decorated.stop(py_trees.common.Status.INVALID)
73
+ return py_trees.common.Status.RUNNING
py_branches/latch.py ADDED
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env python3
2
+ import py_trees
3
+
4
+
5
+ class Latch(py_trees.decorators.Decorator):
6
+ '''
7
+ Once the child returns SUCCESS, latches to SUCCESS and stops re-running
8
+ the child until reset() is called.
9
+
10
+ - While unlatched: child is ticked normally and its status is passed through.
11
+ - On first SUCCESS from child: latch engages.
12
+ - While latched: child is NOT ticked; this decorator returns SUCCESS.
13
+ - On reset(): latch disengages and the child will run again on the next tick.
14
+
15
+ The latch state persists across tree re-entries (i.e. it is NOT cleared by
16
+ initialise()). To get a fresh latch, call reset() explicitly.
17
+
18
+ Args:
19
+ child (Behaviour): The child behavior to latch.
20
+ name (str): Name of this decorator.
21
+
22
+ Example:
23
+ child = ExpensiveSetupBehavior(name="Setup")
24
+ # Run setup once; once it succeeds, always return SUCCESS without
25
+ # re-running it.
26
+ latched = Latch(child, name="Latch")
27
+
28
+ # Later, to trigger a re-run:
29
+ latched.reset()
30
+ '''
31
+ def __init__(self, child: py_trees.behaviour.Behaviour,
32
+ name: str):
33
+ super(Latch, self).__init__(name=name, child=child)
34
+ self._latched = False
35
+
36
+ def reset(self) -> None:
37
+ '''Disengage the latch so the child will run again on the next tick.'''
38
+ self._latched = False
39
+
40
+ def tick(self):
41
+ if self._latched:
42
+ self.stop(py_trees.common.Status.SUCCESS)
43
+ yield self
44
+ else:
45
+ for node in super().tick():
46
+ yield node
47
+
48
+ def update(self) -> py_trees.common.Status:
49
+ if self.decorated.status == py_trees.common.Status.SUCCESS:
50
+ self._latched = True
51
+ return self.decorated.status
py_branches/pause.py ADDED
@@ -0,0 +1,134 @@
1
+ #!/usr/bin/env python3
2
+ import logging
3
+ import time
4
+ import py_trees
5
+ import datetime
6
+ import random
7
+ import yaml
8
+ import os
9
+ from typing import Dict
10
+ from typing import List
11
+
12
+
13
+ HOUR2SEC = 3600
14
+ MIN2SEC = 60
15
+
16
+
17
+ class PauseUniform(py_trees.behaviour.Behaviour):
18
+ def __init__(self, name: str, low: float, high: float):
19
+ super(PauseUniform, self).__init__(name=name)
20
+ self._high = high
21
+ self._low = low
22
+
23
+ def initialise(self):
24
+ self._pause_t = random.uniform(self._low, self._high)
25
+ self._start_t = time.time()
26
+
27
+ def update(self):
28
+ t_elapse = time.time() - self._start_t
29
+ if t_elapse < self._pause_t:
30
+ return py_trees.common.Status.RUNNING
31
+ else:
32
+ return py_trees.common.Status.SUCCESS
33
+
34
+ def load_schedule_file(schedule_filepath: str):
35
+ if not os.path.isfile(schedule_filepath):
36
+ raise FileNotFoundError(f'schedule_filepath: {schedule_filepath} is not a valid file')
37
+
38
+ with open(schedule_filepath, 'r') as schedule_file:
39
+ schedule_raw = yaml.safe_load(schedule_file)
40
+
41
+ schedule = []
42
+ for schedule_element_raw in schedule_raw:
43
+ start_pause_time = datetime.datetime.strptime(schedule_element_raw['start_pause_time'], '%H:%M:%S').time()
44
+ stop_pause_time = datetime.datetime.strptime(schedule_element_raw['stop_pause_time'], '%H:%M:%S').time()
45
+ variance_time = datetime.datetime.strptime(schedule_element_raw['variance'], '%H:%M:%S').time()
46
+ schedule.append({'start_pause_time': start_pause_time,
47
+ 'stop_pause_time': stop_pause_time,
48
+ 'variance_time': variance_time,
49
+ 'start_plus_variance_time': add_variance_to_datetime_time(start_pause_time, variance_time),
50
+ 'stop_plus_variance_time': add_variance_to_datetime_time(stop_pause_time, variance_time)})
51
+ return schedule
52
+
53
+ class CheckPauseSchedule(py_trees.behaviour.Behaviour):
54
+ def __init__(self, name: str, schedule):
55
+ self._schedule = schedule
56
+ self._last_schedule_idx = None
57
+ super(CheckPauseSchedule, self).__init__(name=name)
58
+
59
+ def update(self):
60
+ now_time = datetime.datetime.now().time()
61
+ matched_schedule_idx = None
62
+ for idx, schedule_element in enumerate(self._schedule):
63
+ start = schedule_element['start_plus_variance_time']
64
+ stop = schedule_element['stop_plus_variance_time']
65
+ if (start < stop and start < now_time < stop) or \
66
+ (start > stop and (now_time > start or now_time < stop)):
67
+ matched_schedule_idx = idx
68
+ break
69
+
70
+ # Re-arm once we've left all windows.
71
+ if matched_schedule_idx is None:
72
+ self._last_schedule_idx = None
73
+ return py_trees.common.Status.FAILURE
74
+
75
+ # Prevent repeated SUCCESS ticks while remaining in the same window.
76
+ if matched_schedule_idx == self._last_schedule_idx:
77
+ return py_trees.common.Status.FAILURE
78
+
79
+ self._last_schedule_idx = matched_schedule_idx
80
+ return py_trees.common.Status.SUCCESS
81
+
82
+ def datetime_time_to_sec(t: datetime.time):
83
+ sec = t.hour*HOUR2SEC+t.minute*MIN2SEC+t.second
84
+ return sec
85
+
86
+ def add_variance_to_datetime_time(t: datetime.time, variance_time: datetime.time) -> datetime.time:
87
+ variance_sec = datetime_time_to_sec(variance_time)
88
+ variance_timedelta = datetime.timedelta(seconds=random.uniform(0.0, variance_sec))
89
+ time_to_datetime = datetime.datetime.combine(datetime.date.today(), t)
90
+ time_with_variance = (time_to_datetime + variance_timedelta).time()
91
+ return time_with_variance
92
+
93
+ class PauseSchedule(py_trees.behaviour.Behaviour):
94
+ def __init__(self, name: str, schedule: List[Dict[str, datetime.time]]):
95
+ self._schedule = schedule
96
+ super(PauseSchedule, self).__init__(name=name)
97
+
98
+ def initialise(self):
99
+ super().initialise()
100
+ self._t_wait = None
101
+ self._t_start = time.time()
102
+ now_time = datetime.datetime.now().time()
103
+ for schedule_element in self._schedule:
104
+ start = schedule_element['start_plus_variance_time']
105
+ stop = schedule_element['stop_plus_variance_time']
106
+ variance = schedule_element['variance_time']
107
+ if (start < stop and start < now_time < stop) or \
108
+ (start > stop and (now_time > start or now_time < stop)):
109
+ if now_time < stop:
110
+ self._t_wait = datetime_time_to_sec(stop) - \
111
+ datetime_time_to_sec(now_time)
112
+ else:
113
+ self._t_wait = datetime_time_to_sec(datetime.time(23, 59, 59)) + 1 - \
114
+ datetime_time_to_sec(now_time) + \
115
+ datetime_time_to_sec(stop)
116
+ self._t_start = time.time()
117
+ logging.info(f'Wait has been scheduled for {self._t_wait:.3f} sec')
118
+ schedule_element['start_plus_variance_time'] = \
119
+ add_variance_to_datetime_time(schedule_element['start_pause_time'], variance)
120
+ schedule_element['stop_plus_variance_time'] = \
121
+ add_variance_to_datetime_time(schedule_element['stop_pause_time'], variance)
122
+ logging.info(f'new start_plus_variance_time: {schedule_element["start_plus_variance_time"]}')
123
+ logging.info(f'new stop_plus_variance_time: {schedule_element["stop_plus_variance_time"]}')
124
+ break
125
+
126
+ def update(self):
127
+ if self._t_wait is None:
128
+ return py_trees.common.Status.SUCCESS
129
+
130
+ t_elapse = time.time() - self._t_start
131
+ if t_elapse < self._t_wait:
132
+ return py_trees.common.Status.RUNNING
133
+ else:
134
+ return py_trees.common.Status.SUCCESS
py_branches/random.py ADDED
@@ -0,0 +1,131 @@
1
+ #!/usr/bin/env python3
2
+
3
+ import py_trees
4
+ import random
5
+ import logging
6
+ import time
7
+ from typing import List
8
+
9
+
10
+ class RandomRun(py_trees.decorators.Decorator):
11
+ '''
12
+ Random chance of running the child of this decorator.
13
+ '''
14
+ def __init__(self, child, name, probability: float, success_if_skip: bool = False):
15
+ if not (0 <= probability <= 1.0):
16
+ raise ValueError(f'Probability == {probability} but needs to be in range [0, 1.0]')
17
+ super(RandomRun, self).__init__(name=name, child=child)
18
+ self._probability = probability
19
+ self._run = None # rolled on first tick; terminate() handles all subsequent rolls
20
+ self._success_if_skip = success_if_skip
21
+
22
+ def tick(self):
23
+ if self._run is None:
24
+ self._run = random.random() <= self._probability
25
+ if not self._run:
26
+ for node in py_trees.behaviour.Behaviour.tick(self):
27
+ yield node
28
+ else:
29
+ for node in super().tick():
30
+ yield node
31
+
32
+ def update(self):
33
+ if self._run:
34
+ return self.decorated.status
35
+ else:
36
+ if self._success_if_skip:
37
+ return py_trees.common.Status.SUCCESS
38
+ else:
39
+ return py_trees.common.Status.FAILURE
40
+
41
+ def terminate(self, new_status: py_trees.common.Status) -> None:
42
+ self._run = random.random() <= self._probability
43
+
44
+ class RandomDelay(py_trees.decorators.Decorator):
45
+ '''
46
+ Waits a random duration before running the child on each fresh entry.
47
+
48
+ On every fresh entry (i.e. when the decorator was not already RUNNING),
49
+ a delay is sampled uniformly from [low, high] seconds. The decorator
50
+ stays RUNNING without ticking the child until the delay has elapsed, then
51
+ passes through to the child normally.
52
+
53
+ The delay is re-sampled on every new entry, so repeated executions each
54
+ get independent jitter. This is useful for desynchronising multiple
55
+ agents that share the same tree structure.
56
+
57
+ Args:
58
+ child (Behaviour): The child behavior to delay.
59
+ name (str): Name of this decorator.
60
+ low (float): Minimum delay in seconds (>= 0).
61
+ high (float): Maximum delay in seconds (>= low).
62
+
63
+ Example:
64
+ child = py_trees.behaviours.Success(name="Action")
65
+ # Pause 0.5–2.0 seconds before running the child each time.
66
+ delayed = RandomDelay(child, name="RandomDelay", low=0.5, high=2.0)
67
+ '''
68
+ def __init__(self, child: py_trees.behaviour.Behaviour,
69
+ name: str,
70
+ low: float,
71
+ high: float):
72
+ if low < 0.0:
73
+ raise ValueError(f'low({low}) must be >= 0.')
74
+ if low > high:
75
+ raise ValueError(f'low({low}) must be <= high({high}).')
76
+ super(RandomDelay, self).__init__(name=name, child=child)
77
+ self._low = low
78
+ self._high = high
79
+ self._delay = 0.0
80
+ self._start_time = None
81
+ self._waiting = False
82
+
83
+ def tick(self):
84
+ # Fresh entry: sample a new delay and start the timer.
85
+ if self.status != py_trees.common.Status.RUNNING:
86
+ self._delay = random.uniform(self._low, self._high)
87
+ self._start_time = time.time()
88
+ self._waiting = True
89
+
90
+ if self._waiting:
91
+ if time.time() - self._start_time < self._delay:
92
+ self.status = py_trees.common.Status.RUNNING
93
+ yield self
94
+ return
95
+ self._waiting = False
96
+
97
+ for node in super().tick():
98
+ yield node
99
+
100
+ def update(self) -> py_trees.common.Status:
101
+ return self.decorated.status
102
+
103
+
104
+ def random_selector(name, behaviors: List[py_trees.behaviour.Behaviour], probabilities: List[float]):
105
+ if abs(sum(probabilities) - 1.0) >= 1e-9:
106
+ raise ValueError(f'sum(probabilities) must add up to 1.0, got {sum(probabilities)}')
107
+ if len(probabilities) != len(behaviors):
108
+ raise ValueError('len(probabilities) != len(behaviors), two lists must be of same length.')
109
+
110
+ children = []
111
+ new_probabilities = []
112
+ cumulative_prob = 0.0
113
+ for behavior, raw_prob in zip(behaviors, probabilities):
114
+ new_prob = raw_prob/(1.0-cumulative_prob)
115
+ if new_prob >= 1.0:
116
+ children.append(behavior)
117
+ break
118
+
119
+ new_probabilities.append(new_prob)
120
+ decorated_behavior_name = f'random_run_{behavior.name}'
121
+ decorated_behavior = RandomRun(name=decorated_behavior_name,
122
+ child=behavior,
123
+ probability=new_prob)
124
+ children.append(decorated_behavior)
125
+
126
+ cumulative_prob += raw_prob
127
+
128
+ logging.debug(f'behaviors->new_probabilities: {[b.name for b in behaviors]}->{new_probabilities}')
129
+
130
+ selector = py_trees.composites.Selector(name, False, children)
131
+ return selector
py_branches/retry.py ADDED
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env python3
2
+ import time
3
+ import py_trees
4
+
5
+
6
+ class Retry(py_trees.decorators.Decorator):
7
+ '''
8
+ Retries a child behavior on FAILURE up to max_attempts times.
9
+
10
+ Returns SUCCESS if the child ever succeeds, FAILURE once all attempts
11
+ are exhausted. Stays RUNNING between attempts (and during optional
12
+ delay between retries).
13
+
14
+ Args:
15
+ child (Behaviour): The child behavior to retry.
16
+ name (str): Name of this decorator.
17
+ max_attempts (int): Maximum number of times to attempt the child.
18
+ delay (float): Seconds to wait between retry attempts. Default 0.0.
19
+
20
+ Example:
21
+ child = py_trees.behaviours.Failure(name="Flaky")
22
+ # Try up to 3 times; fails permanently after 3 failures.
23
+ retry = Retry(child, name="Retry", max_attempts=3)
24
+
25
+ child = py_trees.behaviours.Failure(name="Flaky")
26
+ # Try up to 3 times with 1 second between each attempt.
27
+ retry = Retry(child, name="RetryWithDelay", max_attempts=3, delay=1.0)
28
+ '''
29
+ def __init__(self, child: py_trees.behaviour.Behaviour,
30
+ name: str,
31
+ max_attempts: int,
32
+ delay: float = 0.0):
33
+ if max_attempts < 1:
34
+ raise ValueError(f'max_attempts({max_attempts}) must be greater than 0.')
35
+ if delay < 0.0:
36
+ raise ValueError(f'delay({delay}) must be non-negative.')
37
+ super(Retry, self).__init__(name=name, child=child)
38
+ self._max_attempts = max_attempts
39
+ self._delay = delay
40
+ self._attempts = 0
41
+ self._waiting = False
42
+ self._wait_start = None
43
+
44
+ def initialise(self) -> None:
45
+ self._attempts = 0
46
+ self._waiting = False
47
+ self._wait_start = None
48
+
49
+ def tick(self):
50
+ if self._waiting:
51
+ elapsed = time.time() - self._wait_start
52
+ if elapsed < self._delay:
53
+ self.status = py_trees.common.Status.RUNNING
54
+ yield self
55
+ return
56
+ else:
57
+ self._waiting = False
58
+ self.decorated.stop(py_trees.common.Status.INVALID)
59
+ for node in super().tick():
60
+ yield node
61
+
62
+ def update(self) -> py_trees.common.Status:
63
+ if self.decorated.status == py_trees.common.Status.SUCCESS:
64
+ return py_trees.common.Status.SUCCESS
65
+ elif self.decorated.status == py_trees.common.Status.RUNNING:
66
+ return py_trees.common.Status.RUNNING
67
+ else: # FAILURE
68
+ self._attempts += 1
69
+ if self._attempts < self._max_attempts:
70
+ if self._delay > 0.0:
71
+ self._waiting = True
72
+ self._wait_start = time.time()
73
+ else:
74
+ self.decorated.stop(py_trees.common.Status.INVALID)
75
+ return py_trees.common.Status.RUNNING
76
+ else:
77
+ return py_trees.common.Status.FAILURE
py_branches/timeout.py ADDED
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env python3
2
+ import time
3
+ import py_trees
4
+
5
+
6
+ class Timeout(py_trees.decorators.Decorator):
7
+ '''
8
+ Fails a child behavior if it stays RUNNING beyond the specified duration.
9
+
10
+ - If the child returns SUCCESS or FAILURE before the timeout, that
11
+ status is passed through unchanged.
12
+ - If the child is still RUNNING when the timeout expires, the child is
13
+ stopped and FAILURE is returned.
14
+
15
+ Args:
16
+ child (Behaviour): The child behavior to wrap with a timeout.
17
+ name (str): Name of this decorator.
18
+ duration (float): Maximum seconds the child may remain RUNNING.
19
+
20
+ Example:
21
+ child = LongRunningBehavior(name="Slow")
22
+ # Fail if child does not complete within 5 seconds.
23
+ guarded = Timeout(child, name="Timeout", duration=5.0)
24
+ '''
25
+ def __init__(self, child: py_trees.behaviour.Behaviour,
26
+ name: str,
27
+ duration: float):
28
+ if duration <= 0.0:
29
+ raise ValueError(f'duration({duration}) must be positive.')
30
+ super(Timeout, self).__init__(name=name, child=child)
31
+ self._duration = duration
32
+ self._start_time = None
33
+
34
+ def initialise(self) -> None:
35
+ self._start_time = time.time()
36
+
37
+ def update(self) -> py_trees.common.Status:
38
+ if self.decorated.status != py_trees.common.Status.RUNNING:
39
+ return self.decorated.status
40
+
41
+ elapsed = time.time() - self._start_time
42
+ if elapsed >= self._duration:
43
+ self.decorated.stop(py_trees.common.Status.INVALID)
44
+ return py_trees.common.Status.FAILURE
45
+ return py_trees.common.Status.RUNNING
@@ -0,0 +1,31 @@
1
+ # Software License Agreement (BSD License)
2
+ #
3
+ # Copyright (c) 2024 Shunong Wu
4
+ # All rights reserved.
5
+ #
6
+ # Redistribution and use in source and binary forms, with or without
7
+ # modification, are permitted provided that the following conditions
8
+ # are met:
9
+ #
10
+ # * Redistributions of source code must retain the above copyright
11
+ # notice, this list of conditions and the following disclaimer.
12
+ # * Redistributions in binary form must reproduce the above
13
+ # copyright notice, this list of conditions and the following
14
+ # disclaimer in the documentation and/or other materials provided
15
+ # with the distribution.
16
+ # * Neither the name of the copyright holder nor the names of its
17
+ # contributors may be used to endorse or promote products derived
18
+ # from this software without specific prior written permission.
19
+ #
20
+ # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
21
+ # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
22
+ # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
23
+ # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
24
+ # COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
25
+ # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
26
+ # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
27
+ # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
28
+ # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
29
+ # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
30
+ # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
31
+ # POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,172 @@
1
+ Metadata-Version: 2.1
2
+ Name: py_branches
3
+ Version: 1.2.1
4
+ Summary: Useful behaviors built on top of the py_trees library.
5
+ Home-page: https://github.com/snwu1996/py_branches
6
+ License: BSD
7
+ Author: Shunong Wu
8
+ Author-email: shunongwu@gmail.com
9
+ Requires-Python: >=3.8
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: BSD License
13
+ Classifier: License :: Other/Proprietary License
14
+ Classifier: Programming Language :: Python
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.8
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
22
+ Classifier: Topic :: Software Development :: Libraries
23
+ Requires-Dist: py-trees
24
+ Requires-Dist: pyyaml
25
+ Project-URL: Repository, https://github.com/snwu1996/py_branches
26
+ Description-Content-Type: text/markdown
27
+
28
+ # py_branches
29
+
30
+ `py_branches` provides higher-level functionality designed to sit on top of the [py_trees](https://py-trees.readthedocs.io/) library. It extends py_trees with reusable behaviors and decorators for common patterns such as alternating execution, probabilistic selection, blackboard-driven conditionals, and time-based pausing.
31
+
32
+ ## Installation
33
+
34
+ **From PyPI:**
35
+ ```bash
36
+ pip install py_branches
37
+ ```
38
+
39
+ **From source (editable):**
40
+ ```bash
41
+ git clone https://github.com/snwu1996/py_branches.git
42
+ cd py_branches
43
+ pip install -e .
44
+ ```
45
+
46
+ ## Modules
47
+
48
+ | Module | Description |
49
+ |---|---|
50
+ | `alternating` | Cycle through behaviors in fixed patterns or run a child every N ticks |
51
+ | `blackboard` | Read/write/gate behaviors based on py_trees blackboard variables |
52
+ | `pause` | Time-based pauses — uniform random duration or YAML-defined schedules |
53
+ | `random` | Probabilistic behavior execution and weighted random selectors |
54
+
55
+ ## Basic Usage
56
+
57
+ ### Alternating — cycle through behaviors
58
+
59
+ ```python
60
+ import py_trees
61
+ from py_branches.alternating import run_alternating
62
+
63
+ a = py_trees.behaviours.Success(name="A")
64
+ b = py_trees.behaviours.Success(name="B")
65
+ c = py_trees.behaviours.Success(name="C")
66
+
67
+ # Run A for 3 ticks, then B for 2 ticks, then C for 4 ticks, then repeat
68
+ root = run_alternating("Alternating", [a, b, c], [3, 2, 4])
69
+ ```
70
+
71
+ ### Alternating — run a child every N ticks
72
+
73
+ ```python
74
+ from py_branches.alternating import RunEveryX, RunEveryRange
75
+
76
+ child = py_trees.behaviours.Success(name="Child")
77
+
78
+ # Run child every 5th tick
79
+ every_5 = RunEveryX(child, name="Every5", every_x_range=(5, 5))
80
+
81
+ # Run child only on iterations 4–6 out of every 10
82
+ windowed = RunEveryRange(child, name="Window", max_range=10, run_range=(4, 6))
83
+ ```
84
+
85
+ ### Blackboard — conditional execution and variable management
86
+
87
+ ```python
88
+ import py_trees
89
+ from py_branches.blackboard import (
90
+ IncrementBlackboardVariable,
91
+ RunIfBlackboardVariableEquals,
92
+ SetBlackboardVariableIfCondition,
93
+ )
94
+
95
+ # Set up blackboard
96
+ py_trees.blackboard.Blackboard.enable_activity_stream()
97
+ client = py_trees.blackboard.Client(name="setup")
98
+ client.register_key("counter", access=py_trees.common.Access.WRITE)
99
+ client.counter = 0
100
+
101
+ # Increment a blackboard counter each tick
102
+ increment = IncrementBlackboardVariable(
103
+ name="Increment", variable_name="counter", increment_by=1
104
+ )
105
+
106
+ # Only run a child behavior when counter == 5
107
+ child = py_trees.behaviours.Success(name="AtFive")
108
+ gate = RunIfBlackboardVariableEquals(
109
+ child, name="RunAt5", variable_name="counter", equals=5
110
+ )
111
+ ```
112
+
113
+ ### Pause — random duration pause
114
+
115
+ ```python
116
+ from py_branches.pause import PauseUniform
117
+
118
+ # Pause for a random duration between 1.0 and 3.0 seconds
119
+ pause = PauseUniform(name="RandomPause", low=1.0, high=3.0)
120
+ ```
121
+
122
+ ### Pause — schedule-based pause
123
+
124
+ ```python
125
+ from py_branches.pause import load_schedule_file, CheckPauseSchedule, PauseSchedule
126
+
127
+ schedule = load_schedule_file("configs/schedules/example_schedule.yaml")
128
+
129
+ # Returns SUCCESS when the current time falls inside a scheduled window
130
+ check = CheckPauseSchedule(name="CheckSchedule", schedule=schedule)
131
+
132
+ # Pauses until the current scheduled window ends
133
+ pause = PauseSchedule(name="PauseSchedule", schedule=schedule)
134
+ ```
135
+
136
+ ### Random — probabilistic execution
137
+
138
+ ```python
139
+ import py_trees
140
+ from py_branches.random import RandomRun, random_selector
141
+
142
+ child = py_trees.behaviours.Success(name="Child")
143
+
144
+ # Execute child with 70% probability; return FAILURE otherwise
145
+ maybe = RandomRun(child, name="Maybe", probability=0.7)
146
+
147
+ # Weighted random selector: a=20%, b=30%, c=50%
148
+ a = py_trees.behaviours.Success(name="A")
149
+ b = py_trees.behaviours.Success(name="B")
150
+ c = py_trees.behaviours.Success(name="C")
151
+ selector = random_selector("WeightedSel", [a, b, c], [0.2, 0.3, 0.5])
152
+ ```
153
+
154
+ ## Running Tests
155
+
156
+ ```bash
157
+ pytest tests/
158
+ ```
159
+
160
+ ## Documentation
161
+
162
+ Detailed documentation for each module is in the [`docs/`](docs/) folder:
163
+
164
+ - [alternating.md](docs/alternating.md) — Alternating and periodic execution
165
+ - [blackboard.md](docs/blackboard.md) — Blackboard-driven behaviors
166
+ - [pause.md](docs/pause.md) — Time-based pausing and schedules
167
+ - [random.md](docs/random.md) — Probabilistic execution
168
+
169
+ ## License
170
+
171
+ BSD License. See [LICENSE](LICENSE) for details.
172
+
@@ -0,0 +1,14 @@
1
+ py_branches/__init__.py,sha256=f9w3Ws8Mp0lFK2bzr7EqGpxAfLLTQZ11htZSXUm7C5s,199
2
+ py_branches/alternating.py,sha256=E9myfYWO3ObYQHEvb9IP7JwhUvlaPCQnPcAT3wMQVtQ,8666
3
+ py_branches/blackboard.py,sha256=Kc_7EQmmz4VPpPGdJ-KhlIGY_Z2z3iLmrjZKSXHJqLY,5032
4
+ py_branches/cooldown.py,sha256=gpN_UAfYXd9h4K6IM_zkWqWzlyy5KqYweCxUuvFuiX8,2423
5
+ py_branches/counter.py,sha256=zI0umT3ZS8Qd68e9SetrXwhPCeQTFlJQSWgadOp_GHo,2934
6
+ py_branches/latch.py,sha256=mKpBucyzO7YI-h5tsLXG6dsaozBKuRjWgkR4vLRaqFw,1754
7
+ py_branches/pause.py,sha256=a3FLDw6spmDHFpFny19QMJqmzjrX150-EqBT6htw9SQ,5801
8
+ py_branches/random.py,sha256=CivVLpzfYGMPWT04SdQz8p9um8OAXz6KRk7hLAU6uJE,4874
9
+ py_branches/retry.py,sha256=PRUlIAKHSwItuTL4fZM8UEaFCzDV_DGWJvIggwwe1JY,2923
10
+ py_branches/timeout.py,sha256=O1ih80zPbN4yMEzftzrQ2JaPCMuyShnhUUv1icKVMXY,1647
11
+ py_branches-1.2.1.dist-info/LICENSE,sha256=6_acK5mJZz0h1RKJeUWRAgI-X-z8Swz3d3TjFwu4SQw,1624
12
+ py_branches-1.2.1.dist-info/METADATA,sha256=qVG5wEB4javTVwnC8VUbQTvRwzqrNZepbmW9jPiaJIM,5286
13
+ py_branches-1.2.1.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
14
+ py_branches-1.2.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 1.9.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any