Skip to content

Commit 6b826b9

Browse files
[behaviours] Add input/output ports and XML parser (#487)
1 parent 591a9f3 commit 6b826b9

29 files changed

Lines changed: 6093 additions & 954 deletions

README.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# PyTrees
22

3-
[[About](#about)] [[What's New?](#whats-new)] [[Documentation](#documentation)] [[Getting Started](#getting-started)] [[Next Steps](#next-steps)] [[Releases](#releases)]
3+
[[About](#about)] [[What's New?](#whats-new)] [[Documentation](#documentation)] [[Getting Started](#getting-started)] [[Next Steps](#next-steps)] [[Maintainers](#maintainers)] [[Acknowledgments](#acknowledgments)] [[Releases](#releases)]
44

55
----
66

@@ -18,6 +18,7 @@ Brief feature list:
1818

1919
## What's New?
2020

21+
* [2026-05-19] Typed input/output ports for behaviours, with XML parser support.
2122
* [2025-11-13] New `ForEach` decorator.
2223
* [2025-11-13] New `CompareBlackboardVariables` behaviour, with comparison expressions that allow callables.
2324
* [2025-01-11] Support for Python 3.12 was added, and Python 3.8 was dropped.
@@ -85,6 +86,16 @@ Robotics:
8586
* [py_trees_ros](https://github.com/splintered-reality/py_trees_ros) - a tree manager and behaviours designed for use specifically with `ROS2`
8687
* [py_trees_ros_viewer](https://github.com/splintered-reality/py_trees_ros_viewer) - a `Qt/ROS2` implementation of `py_trees_js`
8788

89+
## Maintainers
90+
91+
Daniel Stonier ([@stonier](https://github.com/stonier)), Sebastian Castro ([@sea-bass](https://github.com/sea-bass)).
92+
93+
## Acknowledgments
94+
95+
Contributors are welcome to add themselves here in future PRs.
96+
97+
* Port declarations, type validation, and XML parser: contributed by [Sunrise Robotics](https://sunriserobotics.co/).
98+
8899
## Releases
89100

90101
* `2.4.x` - New ForEach decorator, new CompareBlackboardVariables behaviour, and callables in comparison expressions.

docs/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ Py Trees
1717
composites
1818
decorators
1919
blackboards
20+
ports
2021
idioms
2122
trees
2223
visualisation

docs/ports.rst

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
.. _ports-section-label:
2+
3+
Ports (Experimental)
4+
====================
5+
6+
.. note::
7+
8+
The ports module (:mod:`py_trees.ports`) and the XML parser
9+
(:mod:`py_trees.parsers.behaviour_tree_xml`) are **experimental**.
10+
Their API may change between releases.
11+
12+
Overview
13+
--------
14+
15+
**Ports** add a structured way of wiring up data exchange between nodes.
16+
Each node defines the data it reads and writes as **input** and **output** ports.
17+
The ports are wired to blackboard keys (the *remapping*), and port values are type-checked at runtime.
18+
This allows for a defined and constrained way of data exchange between nodes which is less error-prone and easier to debug than simply writing data on the blackboard and letting nodes read from and write to that entry directly.
19+
20+
Why use ports?
21+
~~~~~~~~~~~~~~
22+
23+
Using ports instead of ad-hoc blackboard reads and writes pays off in several concrete ways:
24+
25+
* **Explicit data contracts.**
26+
A node's ``input_ports()`` and ``output_ports()`` declarations *are* its data-flow API.
27+
A reader can see at a glance what a node consumes and produces without reading through its :meth:`~py_trees.behaviour.Behaviour.update` method.
28+
29+
* **Structured, early error detection.**
30+
Port values are type-checked at runtime (``TypeError`` on mismatched writes/reads), required inputs without data raise :class:`~py_trees.ports.NoDataAvailable` instead of returning ``None`` silently, and misconfiguration (a remap for a port that isn't declared, or a port type that can't accept the wiring) surfaces at setup time rather than deep in a tick.
31+
32+
* **XML authoring.**
33+
Once a library of port-enabled nodes exists, trees become declarative data.
34+
Non-programmers can read and edit tree structure (and the data wiring) without touching Python.
35+
See the :ref:`XML parser section <ports-xml-parser-label>` below.
36+
37+
* **Reusable subtrees via rewiring.**
38+
A subtree is configured from the outside by rewiring its port remappings.
39+
A subtree can be re-used with different inputs and outputs without touching the internal code.
40+
In combination with the XML parser, this concept of re-usable subtrees becomes really powerful to quickly put together new behaviors.
41+
42+
* **Automatic subtree isolation.**
43+
Sibling subtrees can freely reuse the same port names internally; the subtree namespace scopes them on the blackboard so they don't collide.
44+
45+
* **Refactoring safety.**
46+
Changing the blackboard key a data item lives under is a *rewiring* change at setup time, not a code change scattered across every node that used to read or write that key by name.
47+
48+
* **Easier to test.**
49+
A single node can be exercised in isolation by wiring its ports to known blackboard keys, seeding the inputs, and ticking once.
50+
No tree scaffolding required; the ports contract becomes the test surface.
51+
52+
The primary API is :class:`py_trees.ports.PortsMixin`.
53+
Concrete nodes typically inherit from the convenience base
54+
:class:`py_trees.ports.BehaviourWithPorts`, which combines the mixin with
55+
:class:`py_trees.behaviour.Behaviour`:
56+
57+
.. code-block:: python
58+
59+
import py_trees
60+
from py_trees.ports import BehaviourWithPorts, PortInformation
61+
62+
class Multiply(BehaviourWithPorts):
63+
@classmethod
64+
def input_ports(cls):
65+
return {
66+
"a": PortInformation(data_type=float, required=True),
67+
"b": PortInformation(data_type=float, required=True),
68+
}
69+
70+
@classmethod
71+
def output_ports(cls):
72+
return {
73+
"product": PortInformation(data_type=float, required=True),
74+
}
75+
76+
def update(self):
77+
self._set_output("product", self.get_input("a") * self.get_input("b"))
78+
return py_trees.common.Status.SUCCESS
79+
80+
Wiring (remapping) and type checking
81+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
82+
83+
Ports become usable after :meth:`~py_trees.ports.PortsMixin.setup_ports` has been called with the *port remappings* (the "wiring").
84+
Remappings map each port to an absolute or relative blackboard key.
85+
The purpose of remapping is to "wire" one node's output port(s) to another node's input port(s) so they can exchange data.
86+
87+
.. code-block:: python
88+
89+
node = Multiply(name="mul")
90+
node.setup_ports(
91+
port_remappings={
92+
"a": "/numbers/a",
93+
"b": "/numbers/b",
94+
"product": "/numbers/product",
95+
}
96+
)
97+
98+
In the example above, another node's output ports would typically be remapped to ``/numbers/a`` and ``/numbers/b`` and thereby provide the input for the ``Multiply`` node.
99+
100+
.. note:: Why is ``setup_ports()`` a separate call?
101+
Because the remapping table usually cannot be computed until the entire tree topology is known — either the user assembles it by hand or a parser generates it from e.g. XML (more on that next).
102+
See :class:`py_trees.ports.PortsMixin` for the full contract and semantics.
103+
104+
.. _ports-xml-parser-label:
105+
106+
Experimental XML parser
107+
-----------------------
108+
109+
The module :mod:`py_trees.parsers.behaviour_tree_xml` ships an (experimental) parser for the `BehaviorTree.CPP <https://www.behaviortree.dev/docs/learn-the-basics/main_concepts>`_ XML format.
110+
It builds a py_trees tree from an XML file and auto-generates the port remappings for every node.
111+
112+
.. code-block:: python
113+
114+
from py_trees.parsers.behaviour_tree_xml import parse_behaviour_tree_xml
115+
116+
root = parse_behaviour_tree_xml(
117+
"my_tree.xml",
118+
init_lookup={"MyNode": MyNode, "OtherNode": OtherNode, ...},
119+
)
120+
121+
See the :ref:`demos <ports-demos-section-label>` below for working examples.
122+
123+
.. _ports-xml-attributes-label:
124+
125+
XML attributes: ports *and* constructor arguments
126+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
127+
128+
Attributes on a node's XML tag serve **two** distinct purposes:
129+
130+
1. Attribute names that match a declared port (``input_ports()`` or ``output_ports()``) are treated as **port remappings**.
131+
Values may be ``{curly_key}`` references (wired to the remapping table) or literal constants (type-converted according to the port's declared type).
132+
133+
2. Attribute names that do **not** match any declared port are treated as **constructor keyword arguments** and forwarded to the class constructor.
134+
Values are type-converted based on the constructor's type annotations (strings to ``int`` / ``float`` / ``bool`` / enum / etc.); un-annotated parameters receive the raw string.
135+
``{curly_key}`` references are *not* allowed for constructor kwargs --- they raise a ``ValueError`` at parse time, because constructor kwargs can't be re-wired at runtime the way ports can.
136+
137+
Example::
138+
139+
class Greeting(BehaviourWithPorts):
140+
141+
@classmethod
142+
def input_ports(cls):
143+
return {"name_key": PortInformation(data_type=str, required=True)}
144+
145+
@classmethod
146+
def output_ports(cls):
147+
return {"greeting": PortInformation(data_type=str, required=True)}
148+
149+
def __init__(self, name: str, prefix: str = "Hello", **kwargs):
150+
super().__init__(name=name, **kwargs)
151+
self.prefix = prefix # not a port; populated from XML attribute
152+
153+
def update(self):
154+
self._set_output("greeting", f"{self.prefix}, {self.get_input('name_key')}!")
155+
return py_trees.common.Status.SUCCESS
156+
157+
.. code-block:: xml
158+
159+
<Greeting name="hello_node" name_key="{target}" prefix="Howdy"/>
160+
<!-- ^^^ behaviour name ^^^ port remap ^^^ ctor kwarg -->
161+
162+
Scope, limitations, and how to extend
163+
-------------------------------------
164+
165+
The current ports framework is deliberately minimal.
166+
It ships the :class:`~py_trees.ports.PortsMixin` contract, the convenience :class:`~py_trees.ports.BehaviourWithPorts` base, an experimental XML parser, and four demos.
167+
It is the base for extensions to be added in future.
168+
A few things you should be aware of, and suggestions on how to fill the gaps yourself:
169+
170+
**1. No port-aware behaviours, decorators, or composites are shipped.**
171+
172+
``py_trees.ports`` provides the *mechanism* for typed input/output ports.
173+
It does **not** currently ship any concrete behaviours that use ports (e.g. there is no ``Retry`` with ports).
174+
The library of port-enabled nodes is the user's domain: you define ``PortsMixin``-derived classes that actually *do something* with the input/output data.
175+
176+
**2. Built-in decorators and composites cannot be wired through ports from XML.**
177+
178+
The XML parser supports four built-in composite tags natively (``<Sequence>``, ``<Selector>`` / ``<Fallback>``, ``<Parallel>``).
179+
For these tags, only a **fixed** set of XML attributes is consumed:
180+
181+
* ``<Sequence>`` / ``<Selector>`` / ``<Fallback>``: ``name``, ``memory``
182+
* ``<Parallel>``: ``name``, ``policy`` (one of ``success_on_all``,
183+
``success_on_one``, ``success_on_selected``)
184+
185+
**Any other attribute on a built-in composite tag is silently ignored.**
186+
For example, ``<Parallel synchronise="true">`` has no effect, and port-style attributes on these tags are dropped without warning.
187+
This is also why you cannot take the number of attempts for a :class:`py_trees.decorators.Retry` from a port value via XML --- the parser only wires ports (and forwards constructor kwargs; see :ref:`ports-xml-attributes-label` above) on classes registered in ``init_lookup`` that derive from :class:`~py_trees.ports.PortsMixin`.
188+
Built-in decorators aren't recognised as XML tags at all.
189+
190+
**3. The pattern: port-aware wrapper classes.**
191+
192+
To make an existing upstream behaviour, decorator, or composite port-aware, wrap it in a small adapter class that combines :class:`~py_trees.ports.PortsMixin` with the upstream class and reads its runtime parameters from input ports.
193+
194+
The recommended naming is to keep the short upstream class name (``Retry``, ``Repeat``, ``Parallel``, …) and place the port-aware version under a ``ports`` submodule that mirrors the upstream layout (e.g. ``py_trees.ports.decorators.Retry`` alongside the upstream ``py_trees.decorators.Retry``).
195+
The import path carries the "ported" information, so the class name stays short and matches its upstream counterpart.
196+
197+
Example: a port-aware :class:`~py_trees.decorators.Retry` that takes its ``num_failures`` from an input port --- intended to live in ``py_trees.ports.decorators`` when contributed upstream, or in your own project's ``ports`` submodule:
198+
199+
.. code-block:: python
200+
201+
# py_trees/ports/decorators.py (or yourproject/ports/decorators.py)
202+
import py_trees
203+
from py_trees.ports import PortInformation, PortsMixin
204+
205+
class Retry(PortsMixin, py_trees.decorators.Retry):
206+
"""Retry that reads its failure budget from an input port."""
207+
208+
@classmethod
209+
def input_ports(cls):
210+
return {"num_failures": PortInformation(data_type=int, required=True)}
211+
212+
@classmethod
213+
def output_ports(cls):
214+
return {}
215+
216+
def __init__(
217+
self,
218+
name: str,
219+
child: py_trees.behaviour.Behaviour,
220+
**kwargs,
221+
):
222+
# Start with a safe default; it is overwritten on every initialise().
223+
super().__init__(
224+
name=name, child=child, num_failures=1, **kwargs
225+
)
226+
227+
def initialise(self) -> None:
228+
# Read the port value at tick boundary and apply it before the
229+
# upstream Retry logic runs.
230+
self.num_failures = self.get_input("num_failures")
231+
super().initialise()
232+
233+
Users import it as ``from py_trees.ports.decorators import Retry`` (or the equivalent path in their own project) --- the import path disambiguates it from the upstream ``py_trees.decorators.Retry``, so the class name stays clean.
234+
235+
The XML then can accept the input via (remapped) ports::
236+
237+
<Retry name="retry" num_failures="{retry_budget}">
238+
<SomeBehaviour name="worker" />
239+
</Retry>
240+
241+
.. note:: Contributing port-enabled extensions upstream is encouraged!
242+
243+
If you build a generally useful port-aware wrapper --- for example, a port-enabled :class:`~py_trees.decorators.Retry`, :class:`~py_trees.decorators.Repeat`, :class:`~py_trees.composites.Parallel`, or :class:`~py_trees.timers.Timer` --- please consider contributing it back to py_trees under the matching ``py_trees.ports.*`` subpackage (``py_trees.ports.decorators``, ``py_trees.ports.composites``, ``py_trees.ports.timers``, and so on, mirroring the upstream module layout).
244+
A shared library of canonical port-aware adapters saves every user from re-implementing the same wrappers.
245+
Open a PR against the `py_trees devel branch <https://github.com/splintered-reality/py_trees>`_ and we will happily review it.
246+
247+
.. _ports-demos-section-label:
248+
249+
Demos
250+
-----
251+
252+
Example programs live in ``py_trees.demos.ports``.
253+
Each demo module has a top-level docstring describing the scenario it demonstrates.
254+
255+
Module reference
256+
----------------
257+
258+
.. automodule:: py_trees.ports
259+
:members:
260+
:show-inheritance:
261+
:synopsis: typed input/output ports
262+
263+
.. automodule:: py_trees.parsers.behaviour_tree_xml
264+
:members:
265+
:show-inheritance:
266+
:synopsis: experimental XML parser for ports

0 commit comments

Comments
 (0)