Skip to content

[ports] Improve handling of decorator nodes in XML parser#498

Merged
sea-bass merged 6 commits into
develfrom
improved-decorator-xml-handling
Jul 12, 2026
Merged

[ports] Improve handling of decorator nodes in XML parser#498
sea-bass merged 6 commits into
develfrom
improved-decorator-xml-handling

Conversation

@sea-bass

Copy link
Copy Markdown
Member

This makes decorators actually work with the new auto-registration, since otherwise the code yells about these decorators not being part of the node registry. Small regression for the greater good!

It's also generally less hard-coded, though not perfect.

I tested this on a Repeat decorator, including passing in num_success="-1" in the XML.

@sea-bass
sea-bass requested a review from JenniferBuehler June 29, 2026 15:45
@sea-bass sea-bass changed the title Improve handling of decorator nodes in XML parser [ports] Improve handling of decorator nodes in XML parser Jun 29, 2026
@sea-bass
sea-bass force-pushed the improved-decorator-xml-handling branch from a96ecd8 to 2e14e81 Compare June 29, 2026 16:41
@sea-bass
sea-bass force-pushed the parents-ctor-type-hints branch from e3202a3 to 4e1cb3a Compare July 8, 2026 23:56
@sea-bass
sea-bass force-pushed the improved-decorator-xml-handling branch from ad68c9a to d682c27 Compare July 8, 2026 23:57
Base automatically changed from parents-ctor-type-hints to devel July 9, 2026 23:06
@sea-bass
sea-bass force-pushed the improved-decorator-xml-handling branch from d682c27 to c2b25a2 Compare July 9, 2026 23:08

@JenniferBuehler JenniferBuehler left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great idea to fix this up! 👏 And we definitely need to address it. However just for some background. I don't think it was the new design with auto registration that broke the decorators - they were intended to be covered differently. PARENT_NODES_WITH_PORTS_TAGS is actually designed to contain decorators too - in previous uses, we had other nodes in the list, including decorators - but they were also PortsMixin derived (e.g. had number of retries as port). Built-in py_trees decorators (Repeat, Retry, …) aren't PortsMixin classes, so they aren't auto-registered, so get_class_from_registry("Repeat", …) would fail, and that was an issue that was there already.

I agree that we'll have to find a way to support also non-PortMixin derived classes more wholistic, at least for composites and decorators. Right now, the handling is awkward as you saw 🥴

  if tag == "sequence":
      node = py_trees.composites.Sequence(name=node_name, memory=memory, children=children)
  elif tag == "selector" or tag == "fallback":
      node = py_trees.composites.Selector(name=node_name, memory=memory, children=children)
  elif tag == "parallel":
      node = py_trees.composites.Parallel(name=node_name, policy=..., children=children)

Urgh, there has to be a better way, and this PR is a good start!

What I think we'll can improve is

  • this PR removes the path of a parent node that is also a PortsMixin. That is useful for parent tags which have ports (e.g. a repeat node that has the number of repeats as input port). If we ever add one (and that's the plan!), this will fall through as "leaf" and just drop its children. We should cater in the fact that we'll later have decorators that are also PortsMixin derived. That was in the code before this PR, but not actively used, so it probably didn't appear meaningful.
  • We should add tests for this 😄 For example for <Repeat num_success="-1">

I think this is a great step in the right direction, and we can go ahead with it. I left a few comments for improvements. Once they are addressed, let's go with it. I would then do the "more wholistic" approach in a separate follow-up.

Thanks again for this improvement, this is the right direction for changes that need to be added before this is more mature! 🙏

Comment thread py_trees/parsers/behaviour_tree_xml.py Outdated
for name, obj in inspect.getmembers(py_trees.decorators, inspect.isclass)
if obj.__module__ == py_trees.decorators.__name__
and issubclass(obj, py_trees.decorators.Decorator)
and obj is not py_trees.decorators

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I read this right, but this compares a class to a module, so it never excludes anything? The intent was to exclude the abstract base Decorator.
Like this, <Decorator> becomes a "valid" tag?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no good catch, this is supposed to be obj is not py_trees.decorators.Decorator which invalidates the tag you mentioned!

Comment thread py_trees/parsers/behaviour_tree_xml.py Outdated
node = py_trees.composites.Parallel(
name=node_name,
policy=mapping.get(policy, py_trees.common.ParallelPolicy.SuccessOnAll)(), # type: ignore
policy=mapping[policy](), # type: ignore

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How is this related, did this sneak in?
I guess this would mean a bad policy value now crashes it?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no, but I should have commented. A few lines above the code says:

policy = elem.attrib.get("policy", "success_on_one")

so basically policy was already defined and defaulted to a different fallback than the one I just removed. And I think success_on_one is the more reasonable default.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What I meant was when an invalid value like "my_bad_policy" is posted, before it was then falling back to mapping.get(policy, SuccessOnAll). Now mapping[policy] would raise a KeyError.

I verified by adding below unit test to test_ports_xml_parser.py. It's fine to raise on an invalid value but perhaps the KeyError isn't ideal. Try catch maybe?

    def test_parallel_invalid_policy_falls_back(self) -> None:
        """An unknown <Parallel> policy value falls back to SuccessOnAll."""
        xml = """<root main_tree_to_execute="MainTree">
          <BehaviorTree ID="MainTree">
            <Parallel policy="my_bad_policy">
              <Producer name="prod" output="{out}" />
            </Parallel>
          </BehaviorTree>
        </root>"""

        with tempfile.NamedTemporaryFile(delete=False, mode="w", suffix=".xml") as tf:
            tf.write(xml)
            path = tf.name

        try:
            root = parse_behaviour_tree_xml(path, logger=StdoutLogger())
            self.assertIsInstance(root.policy, py_trees.common.ParallelPolicy.SuccessOnAll)
        finally:
            os.unlink(path)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah I see, I'll restore it but keep the same sensible default

Comment thread py_trees/parsers/behaviour_tree_xml.py
Comment thread py_trees/parsers/behaviour_tree_xml.py Outdated
Comment thread py_trees/parsers/behaviour_tree_xml.py
@sea-bass

sea-bass commented Jul 10, 2026

Copy link
Copy Markdown
Member Author

Thanks @JenniferBuehler!

I added a test and addressed your other comment by rearranging the logic so that the node_registry always comes first and other special cases come later.

The reasoning is that as we mature this functionality, we can basically pre-populate the default node registry with all our built-in PortsMixin enabled composites/decorators/etc. instead of having to handle special cases.

(EDIT / getting in the weeds: We know based on isinstance / issubclass checks the rules for children: leaf nodes must have 0, decorators must have 1, composites can have >0 or >=0 depending on whether zero is allowed).

If you agree with this path, we can make a follow-up issue based on this comment.

@sea-bass
sea-bass requested a review from JenniferBuehler July 10, 2026 01:03

@JenniferBuehler JenniferBuehler left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is coming together nicely! I just have one minor open comment, and one that may have been misunderstood or overlooked in my last review. I'll make it more specific. Take this (add example to test_port_helpers.py for testing):

class RepeatWithPorts(PortsMixin, py_trees.decorators.Repeat):
    """A `py_trees.decorators.Repeat` decorator that also exposes ports.

    This is a parent node that is a decorator (it decorates a single child)
    that is *also* a ``PortsMixin`` and so is auto-registered and resolvable by tag.
    It publishes the number of successes it is configured to require via an output port.
    """

    OUTPUT_PORT = "count"

    @classmethod
    def input_ports(cls) -> dict:
        return {}

    @classmethod
    def output_ports(cls) -> dict:
        return {cls.OUTPUT_PORT: PortInformation(data_type=int, required=False)}

A port-enabled parent like this was supported before. Now, it would fail (add to test_ports_xml_parser.py):

    def test_ports_enabled_decorator_node(self) -> None:
        """A PortsMixin-derived decorator (parent node) should parse from XML.

        ``RepeatWithPorts`` is a ``Repeat`` decorator that is also a ``PortsMixin``.
        The parser should build its child and output the number of successes.

        This currently fails because the parser funnels any registered ``PortsMixin`` tag to the leaf path, which never builds the child.
        """
        xml = """<root main_tree_to_execute="MainTree">
          <BehaviorTree ID="MainTree">
            <RepeatWithPorts num_success="-1" count="{success_count}">
              <Producer name="prod" output="{out}" />
            </RepeatWithPorts>
          </BehaviorTree>
        </root>"""

        with tempfile.NamedTemporaryFile(delete=False, mode="w", suffix=".xml") as tf:
            tf.write(xml)
            path = tf.name

        try:
            root = parse_behaviour_tree_xml(path, logger=StdoutLogger())
            repeat = find_node_by_class(root, RepeatWithPorts)
            self.assertIsNotNone(repeat)
            self.assertEqual(repeat.num_success, -1)
            self.assertIsInstance(repeat.num_success, int)
            self.assertIsInstance(repeat.decorated, Producer)
        finally:
            os.unlink(path)

Perhaps it's easier to see what I mean by code snippets?

I think it was already not great before, it was already an item on the agenda as it is (these hardcoded parent tags, I mean).

Would it help if I push the suggestion for a fix to the branch, instead of trying to phrase my suggestion (it's easier to say in code)? Or you already know what I mean and want to re-arrange it so that we can support PortsMixin-parents?

Sorry to be a bum about this but PortsMixin-derived parent nodes are actually an important part in the usage of this library that I know exists today :)

Comment thread py_trees/parsers/behaviour_tree_xml.py Outdated
node = py_trees.composites.Parallel(
name=node_name,
policy=mapping.get(policy, py_trees.common.ParallelPolicy.SuccessOnAll)(), # type: ignore
policy=mapping[policy](), # type: ignore

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What I meant was when an invalid value like "my_bad_policy" is posted, before it was then falling back to mapping.get(policy, SuccessOnAll). Now mapping[policy] would raise a KeyError.

I verified by adding below unit test to test_ports_xml_parser.py. It's fine to raise on an invalid value but perhaps the KeyError isn't ideal. Try catch maybe?

    def test_parallel_invalid_policy_falls_back(self) -> None:
        """An unknown <Parallel> policy value falls back to SuccessOnAll."""
        xml = """<root main_tree_to_execute="MainTree">
          <BehaviorTree ID="MainTree">
            <Parallel policy="my_bad_policy">
              <Producer name="prod" output="{out}" />
            </Parallel>
          </BehaviorTree>
        </root>"""

        with tempfile.NamedTemporaryFile(delete=False, mode="w", suffix=".xml") as tf:
            tf.write(xml)
            path = tf.name

        try:
            root = parse_behaviour_tree_xml(path, logger=StdoutLogger())
            self.assertIsInstance(root.policy, py_trees.common.ParallelPolicy.SuccessOnAll)
        finally:
            os.unlink(path)

@JenniferBuehler

Copy link
Copy Markdown
Collaborator

The reasoning is that as we mature this functionality, we can basically pre-populate the default node registry with all our built-in PortsMixin enabled composites/decorators/etc. instead of having to handle special cases.

(EDIT / getting in the weeds: We know based on isinstance / issubclass checks the rules for children: leaf nodes must have 0, decorators must have 1, composites can have >0 or >=0 depending on whether zero is allowed).

If you agree with this path, we can make a follow-up issue based on this comment.

Yes I agree, let's make this a lot less friciton-less for new nodes! The improvement can be a future PR. Only thing I meant in my review is that it shouldn't break existing PortsMixin-derived parents, even if the solution isn't yet pretty (the prettification can be for after this PR)

@sea-bass

Copy link
Copy Markdown
Member Author

Yes! I see. If you'd like to add that test case and a possible fix here, that would be super helpful.

Perhaps my idea to check whether this registered node inherits from Decorator / Composite and therefore needs children is the way to go?

@sea-bass

Copy link
Copy Markdown
Member Author

Actually I have inspiration... give me a minute...

@sea-bass

Copy link
Copy Markdown
Member Author

Actually I have inspiration... give me a minute...

ok @JenniferBuehler I got something working with tests. Give it a look?

@sea-bass
sea-bass force-pushed the improved-decorator-xml-handling branch from 09e37b4 to 569a3b3 Compare July 11, 2026 22:39

@JenniferBuehler JenniferBuehler left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great, I ran the tests again and it all checks out now!!

Great work 👏 This is a real improvement.

@sea-bass
sea-bass merged commit 17b6e7c into devel Jul 12, 2026
5 checks passed
@sea-bass
sea-bass deleted the improved-decorator-xml-handling branch July 12, 2026 18:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants