From 3d51465e3abad97631ca81be954c1d372438d0b5 Mon Sep 17 00:00:00 2001 From: Sebastian Castro Date: Fri, 14 Aug 2026 19:50:32 -0500 Subject: [PATCH 1/3] Add default input ports to subtrees --- py_trees/parsers/behaviour_tree_xml.py | 26 +++++++++++--- tests/test_ports_xml_parser.py | 49 ++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 5 deletions(-) diff --git a/py_trees/parsers/behaviour_tree_xml.py b/py_trees/parsers/behaviour_tree_xml.py index 13639002..97667f33 100644 --- a/py_trees/parsers/behaviour_tree_xml.py +++ b/py_trees/parsers/behaviour_tree_xml.py @@ -444,6 +444,7 @@ def add_new_key_to_remapping_table(value: str, remapping_table: dict[str, str], def build_subtree_remapping( elem: ET.Element, + bt_index: dict[str, ET.Element], remapping_table: dict[str, str], parent_namespace: str, logger: PortsLogger = NOOP_LOGGER, @@ -451,11 +452,13 @@ def build_subtree_remapping( """ Process the XML element. - The remapping table is updated to include the remappings from the or element. + The remapping table is updated to include the remappings from the or element, + as well as any default port values inherited from the underlying definition. The subtree is then instantiated with the new remapping table. Args: elem: The (or ) XML element. + bt_index (dict[str, ET.Element]): dictionary {ID: BehaviorTree element} for subtree lookup. remapping_table: Parent remapping table (logical name -> absolute key). parent_namespace: Absolute namespace of the parent tree. logger: Optional logger. @@ -478,6 +481,15 @@ def build_subtree_remapping( continue logger.debug(f"Checking to add new key for SubTree attribute: {k} -> {v}") add_new_key_to_remapping_table(v, remapping_table=remapping_table, subtree_namespace=parent_namespace) + + # Also include any default args in the behavior tree definition that were not remapped in the subtree instance. + bt_elem = bt_index[elem.attrib["ID"]] + for k, v in bt_elem.attrib.items(): + if k in ("ID", "name"): + continue + logger.debug(f"Checking to add new key for default BehaviorTree attribute: {k} -> {v}") + add_new_key_to_remapping_table(v, remapping_table=remapping_table, subtree_namespace=parent_namespace) + logger.debug(f"Updated remapping table: {remapping_table}") # Build the new remapping table for this subtree. We build a new remapping table because we need to ensure that @@ -489,7 +501,9 @@ def build_subtree_remapping( # If the value is a key and it is already in the remapping table, we resolve it to its absolute path. # Example: `` - we need resolve {other_key} to its absolute # path (using the parent remapping) and then add `in -> resolved({other_key})` to the new remapping table. - for k, v in elem.attrib.items(): + attrib_dict = bt_elem.attrib.copy() # the copy is necessary! + attrib_dict.update(elem.attrib) + for k, v in attrib_dict.items(): if k in ("ID", "name"): continue logger.debug(f"Processing SubTree attribute: {k} -> {v}") @@ -506,6 +520,7 @@ def build_subtree_remapping( v = resolve_direct_value_remapping(v, remapping_table) logger.debug(f"[Direct value] Adding remapping from parent remapping table: {k} -> {v}") new_remapping[k] = v + logger.debug(f"Subtree '{elem.attrib['ID']}' new remapping table: {new_remapping}") return new_remapping @@ -678,7 +693,7 @@ def build_tree_from_xml( elem: ET.Element, remapping_table: dict[str, str], node_registry: dict, - bt_index: dict, + bt_index: dict[str, ET.Element], logger: PortsLogger = NOOP_LOGGER, subtree_namespace: str = "/", parent_names_str: str = "", @@ -691,7 +706,7 @@ def build_tree_from_xml( remapping_table (dict[str, str]): Remapping table. node_registry (dict): Mapping from class names (str) to callables (constructors or partials) that return ``PortsMixin``-derived instances. - bt_index (dict[str, BehaviorTree]): dictionary {ID: BehaviorTree element} for subtree lookup. + bt_index (dict[str, ET.Element]): dictionary {ID: BehaviorTree element} for subtree lookup. subtree_namespace (str): current blackboard namespace. logger: Optional logger-like object. parent_names_str (str): Dot-separated string of parent names for logging context and generating node names. @@ -880,6 +895,7 @@ def build_tree_from_xml( f" must have exactly one child (the root node), " f"but found {len(child_elems)} children." ) + return build_tree_from_xml( child_elems[0], remapping_table, @@ -904,7 +920,7 @@ def build_tree_from_xml( # Build the new namespace by prepending the subtree ID new_namespace = get_absolute_reference(subtree_name, subtree_namespace) # Build the new remapping table for this subtree. - new_remapping = build_subtree_remapping(elem, remapping_table, subtree_namespace, logger) + new_remapping = build_subtree_remapping(elem, bt_index, remapping_table, subtree_namespace, logger) # Recursively build the subtree with the new remapping table return build_tree_from_xml( subtree_elem, diff --git a/tests/test_ports_xml_parser.py b/tests/test_ports_xml_parser.py index 0e62b0b2..cba53292 100644 --- a/tests/test_ports_xml_parser.py +++ b/tests/test_ports_xml_parser.py @@ -418,6 +418,55 @@ def test_subtree_parsing_direct_values_to_XML(self) -> None: self.assertEqual(node.consumed_value, expected_value) + def test_subtree_default_ports(self) -> None: + """Verify that subtree default ports take effect as intended.""" + self.xml = """ + + + + + + + + + + + + + + + + """ + + self.tempfile = tempfile.NamedTemporaryFile(delete=False, mode="w", suffix=".xml") + self.tempfile.write(self.xml) + self.tempfile.close() + + root_node = parse_behaviour_tree_xml(self.tempfile.name, logger=StdoutLogger()) + btree = py_trees.trees.BehaviourTree(root_node) + btree.tick() + + node = find_node_by_name(root_node, "AllDefaults.Consumer") + self.assertEqual(node.consumed_value, "default_str") + node = find_node_by_name(root_node, "AllDefaults.FloatConsumer") + self.assertEqual(node.consumed_value, 42) + + node = find_node_by_name(root_node, "FirstInputSet.Consumer") + self.assertEqual(node.consumed_value, "override_str") + node = find_node_by_name(root_node, "FirstInputSet.FloatConsumer") + self.assertEqual(node.consumed_value, 42) + + node = find_node_by_name(root_node, "SecondInputSet.Consumer") + self.assertEqual(node.consumed_value, "default_str") + node = find_node_by_name(root_node, "SecondInputSet.FloatConsumer") + self.assertEqual(node.consumed_value, 67) + + node = find_node_by_name(root_node, "BothInputsSet.Consumer") + self.assertEqual(node.consumed_value, "another_str") + node = find_node_by_name(root_node, "BothInputsSet.FloatConsumer") + self.assertEqual(node.consumed_value, 9001) + + def test_wait_node(self) -> None: """Verify that the duration value is successfully used by the Wait node.""" wait_duration_ms = 500 From d6df3c9c1176f6adff39a8dd59c5d0cc17419aea Mon Sep 17 00:00:00 2001 From: Sebastian Castro Date: Fri, 14 Aug 2026 20:25:19 -0500 Subject: [PATCH 2/3] Add top-level behavior tree port mappings --- py_trees/parsers/behaviour_tree_xml.py | 14 ++++++- tests/test_ports_xml_parser.py | 51 +++++++++++++++++++++++++- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/py_trees/parsers/behaviour_tree_xml.py b/py_trees/parsers/behaviour_tree_xml.py index 97667f33..a83014b2 100644 --- a/py_trees/parsers/behaviour_tree_xml.py +++ b/py_trees/parsers/behaviour_tree_xml.py @@ -301,6 +301,7 @@ def parse_behaviour_tree_xml( node_registry: dict | str = "auto", logger: PortsLogger | None = None, search_paths: list[str] | None = None, + input_mappings: dict[str, str] | None = None, ) -> py_trees.behaviour.Behaviour: """ Parse the XML file and build the behavior tree. @@ -335,6 +336,9 @@ def parse_behaviour_tree_xml( ``{tag: class/partial}`` dict to use exclusively. Defaults to ``"auto"``. logger (PortsLogger | None): Optional logger (NoOp if None). search_paths (list[str] | None): Optional extra directories to resolve imports. + input_mappings (dict[str, str]): Optional input key-value mappings that can override default + top-level behaviour tree port values. Note that the values must be strings, to match how + they would be defined in XML. Returns: The root py_trees.behaviour.Behaviour for the requested tree. @@ -387,9 +391,17 @@ def parse_behaviour_tree_xml( logger.debug(f"[DEBUG] Starting parse of main tree ID='{main_tree_id}'") bt_elem = bt_index[main_tree_id] + # Apply any input mappings by just modifying the XML element. + if input_mappings is not None: + for k, v in input_mappings.items(): + if not isinstance(v, str): + raise ValueError(f"Value in input mapping {k} -> {v} must be a string.") + bt_elem.attrib[k] = v + + remapping_table = build_subtree_remapping(bt_elem, bt_index, {}, "/", logger) tree = build_tree_from_xml( bt_elem, - remapping_table={}, + remapping_table=remapping_table, node_registry=node_registry, bt_index=bt_index, logger=logger, diff --git a/tests/test_ports_xml_parser.py b/tests/test_ports_xml_parser.py index cba53292..a6399233 100644 --- a/tests/test_ports_xml_parser.py +++ b/tests/test_ports_xml_parser.py @@ -19,7 +19,13 @@ import py_trees from py_trees.parsers.behaviour_tree_xml import is_key, parse_behaviour_tree_xml -from py_trees.ports import BehaviourWithPorts, PortInformation, PortsMixin, get_ports_registry +from py_trees.ports import ( + BehaviourWithPorts, + NoDataAvailable, + PortInformation, + PortsMixin, + get_ports_registry, +) from py_trees.ports_utils import ( find_node_by_class, find_node_by_name, @@ -466,6 +472,49 @@ def test_subtree_default_ports(self) -> None: node = find_node_by_name(root_node, "BothInputsSet.FloatConsumer") self.assertEqual(node.consumed_value, 9001) + def test_top_level_default_ports(self) -> None: + """Verify that top-level behaviour default ports take effect as intended.""" + # In this tree, input1 has a default value but input2 does not. + self.xml = """ + + + + + + + """ + + self.tempfile = tempfile.NamedTemporaryFile(delete=False, mode="w", suffix=".xml") + self.tempfile.write(self.xml) + self.tempfile.close() + + # Run without mappings. This should fail in FloatConsumer because no port was set. + root_node = parse_behaviour_tree_xml(self.tempfile.name, logger=StdoutLogger()) + btree = py_trees.trees.BehaviourTree(root_node) + btree.tick() + + node = find_node_by_name(root_node, "Consumer", strip_prefix=True) + self.assertEqual(node.consumed_value, "default_str") + node = find_node_by_name(root_node, "FloatConsumer", strip_prefix=True) + with self.assertRaises(NoDataAvailable): + self.assertEqual(node.consumed_value, 42) + + # Setting input values should override the inputs appropriately. + root_node = parse_behaviour_tree_xml( + self.tempfile.name, + logger=StdoutLogger(), + input_mappings={"input1": "override_str", "input2": "67"}, + ) + btree = py_trees.trees.BehaviourTree(root_node) + btree.tick() + + node = find_node_by_name(root_node, "Consumer", strip_prefix=True) + self.assertEqual(node.consumed_value, "override_str") + node = find_node_by_name(root_node, "FloatConsumer", strip_prefix=True) + self.assertEqual(node.consumed_value, 67) + + + def test_wait_node(self) -> None: """Verify that the duration value is successfully used by the Wait node.""" From 7dd928952843098c5b31c18a27f34b4380c3df01 Mon Sep 17 00:00:00 2001 From: Sebastian Castro Date: Sat, 15 Aug 2026 08:03:42 -0400 Subject: [PATCH 3/3] Add docs --- docs/ports.rst | 74 ++++++++++++++++++++++++++ py_trees/parsers/behaviour_tree_xml.py | 2 +- tests/test_ports_xml_parser.py | 49 ++++++++--------- 3 files changed, 98 insertions(+), 27 deletions(-) diff --git a/docs/ports.rst b/docs/ports.rst index 2942d608..bcd7d17f 100644 --- a/docs/ports.rst +++ b/docs/ports.rst @@ -227,6 +227,80 @@ Example:: +.. _ports-xml-default-inputs-label: + +Setting default inputs for subtrees +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +A ```` definition can carry attributes of its own. +Every attribute other than ``ID`` and ``name`` is a **default input** for that tree: +a value used for the matching key whenever an instance of the tree does not supply one itself. +This lets a reusable subtree ship with sensible values baked in, so callers only have to override what they actually care about. + +.. code-block:: xml + + + + + + + + + + + + + + + + + +Resolution is per key: an attribute on the ```` element wins, and every key it leaves out +falls back to the default on the ```` definition. +Defaults are written exactly like any other XML attribute value. +A literal constant is type-converted according to the declared port type +(so ``volume="5"`` reaches an ``int`` port as ``5``), and a ``{curly_key}`` reference is wired through the remapping table as usual. +Note that a ``{curly_key}`` default is resolved in the scope of whoever *instantiates* the subtree, +not inside the subtree itself, so it behaves like a remapping the caller would have written by hand. +Literal defaults are usually the clearer choice. + +Defaults for the top-level tree +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The same mechanism applies to the tree named by ``main_tree_to_execute``. +Attributes on *its* ```` element are the defaults for the tree as a whole. +Since there is no enclosing ```` element to override them from, those defaults are overridden +from Python instead, via the ``input_mappings`` argument of +:func:`~py_trees.parsers.behaviour_tree_xml.parse_behaviour_tree_xml`: + +.. code-block:: xml + + + + + + + + + +.. code-block:: python + + root = parse_behaviour_tree_xml( + "my_tree.xml", + input_mappings={"greeting": "Howdy", "volume": "11"}, + ) + +This makes the top-level tree parameterisable from the calling application without having to +edit (or template) the XML for each run. + +A few rules to be aware of: + +* ``input_mappings`` values must be **strings**, matching how they would have been written as + XML attributes (pass ``"11"``, not ``11``). A non-string value raises ``ValueError`` at parse time. +* ``input_mappings`` both overrides existing defaults and supplies keys the XML never defaulted at all (``volume`` in the example above). +* A required input port left with neither a default nor a mapping is not an error at parse time. + It raises :class:`~py_trees.ports.NoDataAvailable` if and when the node first reads it during a tick. + .. _ports-xml-includes-label: Composing trees from multiple files diff --git a/py_trees/parsers/behaviour_tree_xml.py b/py_trees/parsers/behaviour_tree_xml.py index a83014b2..ffc199fc 100644 --- a/py_trees/parsers/behaviour_tree_xml.py +++ b/py_trees/parsers/behaviour_tree_xml.py @@ -397,7 +397,7 @@ def parse_behaviour_tree_xml( if not isinstance(v, str): raise ValueError(f"Value in input mapping {k} -> {v} must be a string.") bt_elem.attrib[k] = v - + remapping_table = build_subtree_remapping(bt_elem, bt_index, {}, "/", logger) tree = build_tree_from_xml( bt_elem, diff --git a/tests/test_ports_xml_parser.py b/tests/test_ports_xml_parser.py index a6399233..b6167dab 100644 --- a/tests/test_ports_xml_parser.py +++ b/tests/test_ports_xml_parser.py @@ -33,7 +33,7 @@ strip_trailing_uuid4, ) -from .test_ports_helpers import Consumer, Producer +from .test_ports_helpers import Consumer, FloatConsumer, Producer class StdoutLogger: @@ -452,25 +452,19 @@ def test_subtree_default_ports(self) -> None: btree = py_trees.trees.BehaviourTree(root_node) btree.tick() - node = find_node_by_name(root_node, "AllDefaults.Consumer") - self.assertEqual(node.consumed_value, "default_str") - node = find_node_by_name(root_node, "AllDefaults.FloatConsumer") - self.assertEqual(node.consumed_value, 42) - - node = find_node_by_name(root_node, "FirstInputSet.Consumer") - self.assertEqual(node.consumed_value, "override_str") - node = find_node_by_name(root_node, "FirstInputSet.FloatConsumer") - self.assertEqual(node.consumed_value, 42) - - node = find_node_by_name(root_node, "SecondInputSet.Consumer") - self.assertEqual(node.consumed_value, "default_str") - node = find_node_by_name(root_node, "SecondInputSet.FloatConsumer") - self.assertEqual(node.consumed_value, 67) - - node = find_node_by_name(root_node, "BothInputsSet.Consumer") - self.assertEqual(node.consumed_value, "another_str") - node = find_node_by_name(root_node, "BothInputsSet.FloatConsumer") - self.assertEqual(node.consumed_value, 9001) + for subtree_name, expected_str, expected_float in ( + ("AllDefaults", "default_str", 42), + ("FirstInputSet", "override_str", 42), + ("SecondInputSet", "default_str", 67), + ("BothInputsSet", "another_str", 9001), + ): + node = find_node_by_name(root_node, f"{subtree_name}.Consumer") + assert isinstance(node, Consumer) + self.assertEqual(node.consumed_value, expected_str) + + node = find_node_by_name(root_node, f"{subtree_name}.FloatConsumer") + assert isinstance(node, FloatConsumer) + self.assertEqual(node.consumed_value, expected_float) def test_top_level_default_ports(self) -> None: """Verify that top-level behaviour default ports take effect as intended.""" @@ -494,10 +488,13 @@ def test_top_level_default_ports(self) -> None: btree.tick() node = find_node_by_name(root_node, "Consumer", strip_prefix=True) + assert isinstance(node, Consumer) self.assertEqual(node.consumed_value, "default_str") - node = find_node_by_name(root_node, "FloatConsumer", strip_prefix=True) + + float_node = find_node_by_name(root_node, "FloatConsumer", strip_prefix=True) + assert isinstance(float_node, FloatConsumer) with self.assertRaises(NoDataAvailable): - self.assertEqual(node.consumed_value, 42) + self.assertEqual(float_node.consumed_value, 42) # Setting input values should override the inputs appropriately. root_node = parse_behaviour_tree_xml( @@ -509,12 +506,12 @@ def test_top_level_default_ports(self) -> None: btree.tick() node = find_node_by_name(root_node, "Consumer", strip_prefix=True) + assert isinstance(node, Consumer) self.assertEqual(node.consumed_value, "override_str") - node = find_node_by_name(root_node, "FloatConsumer", strip_prefix=True) - self.assertEqual(node.consumed_value, 67) - - + float_node = find_node_by_name(root_node, "FloatConsumer", strip_prefix=True) + assert isinstance(float_node, FloatConsumer) + self.assertEqual(float_node.consumed_value, 67) def test_wait_node(self) -> None: """Verify that the duration value is successfully used by the Wait node."""