From 242de27de814a084eb751bb9f8b2b3661422744d Mon Sep 17 00:00:00 2001 From: Tomoya Fujita Date: Mon, 20 Jul 2026 08:25:43 +0900 Subject: [PATCH 1/3] introduce ActionEndpointInfo to support "ros2 action info (-v)". Signed-off-by: Tomoya Fujita --- rclpy/rclpy/endpoint_info.py | 237 +++++++++++++++++++++++++++ rclpy/rclpy/impl/_rclpy_pybind11.pyi | 24 +++ rclpy/rclpy/node.py | 101 +++++++++++- rclpy/src/rclpy/_rclpy_pybind11.cpp | 8 + rclpy/src/rclpy/graph.cpp | 64 ++++++++ rclpy/src/rclpy/graph.hpp | 38 +++++ rclpy/src/rclpy/node.cpp | 30 ++++ rclpy/src/rclpy/node.hpp | 20 +++ rclpy/src/rclpy/utils.cpp | 47 ++++++ rclpy/src/rclpy/utils.hpp | 17 ++ rclpy/test/test_node.py | 116 +++++++++++++ 11 files changed, 701 insertions(+), 1 deletion(-) diff --git a/rclpy/rclpy/endpoint_info.py b/rclpy/rclpy/endpoint_info.py index 90ec30aeb..6b6f9b2e6 100644 --- a/rclpy/rclpy/endpoint_info.py +++ b/rclpy/rclpy/endpoint_info.py @@ -17,6 +17,7 @@ from enum import IntEnum from typing import Annotated from typing import Any +from typing import Optional from typing import Union from rclpy.impl.implementation_singleton import rclpy_implementation as _rclpy @@ -432,3 +433,239 @@ def format_qos(qos: QoSProfile, indent: str = ' ') -> str: ] return '\n'.join(info_lines) + + +class ActionEndpointInfo: + """ + Information on an action endpoint (an action client or an action server). + + An action is built on top of three services and two topics, so this class + aggregates the endpoint information of all the underlying entities of one + action client or one action server: + + * the goal service (``/_action/send_goal``) + * the cancel service (``/_action/cancel_goal``) + * the result service (``/_action/get_result``) + * the feedback topic (``/_action/feedback``) + * the status topic (``/_action/status``) + + The goal service endpoint is the canonical identity of the action + endpoint, so ``goal_service_info`` is always populated. + The remaining endpoint information is correlated to the goal service + endpoint by the node name and node namespace, and is left default + initialized (i.e. with an empty node name) if the underlying entity has + not been discovered. + """ + + __slots__ = [ + '_goal_service_info', + '_cancel_service_info', + '_result_service_info', + '_feedback_topic_info', + '_status_topic_info' + ] + + def __init__( + self, + goal_service_info: Optional[Union[ServiceEndpointInfo, + '_rclpy._ServiceEndpointInfoDict']] = None, + cancel_service_info: Optional[Union[ServiceEndpointInfo, + '_rclpy._ServiceEndpointInfoDict']] = None, + result_service_info: Optional[Union[ServiceEndpointInfo, + '_rclpy._ServiceEndpointInfoDict']] = None, + feedback_topic_info: Optional[Union[TopicEndpointInfo, + '_rclpy._TopicEndpointInfoDict']] = None, + status_topic_info: Optional[Union[TopicEndpointInfo, + '_rclpy._TopicEndpointInfoDict']] = None + ): + self.goal_service_info = goal_service_info + self.cancel_service_info = cancel_service_info + self.result_service_info = result_service_info + self.feedback_topic_info = feedback_topic_info + self.status_topic_info = status_topic_info + + @staticmethod + def _to_service_endpoint_info( + value: Optional[Union[ServiceEndpointInfo, '_rclpy._ServiceEndpointInfoDict']] + ) -> ServiceEndpointInfo: + if value is None: + return ServiceEndpointInfo() + if isinstance(value, ServiceEndpointInfo): + return value + if isinstance(value, dict): + return ServiceEndpointInfo(**value) + assert False + + @staticmethod + def _to_topic_endpoint_info( + value: Optional[Union[TopicEndpointInfo, '_rclpy._TopicEndpointInfoDict']] + ) -> TopicEndpointInfo: + if value is None: + return TopicEndpointInfo() + if isinstance(value, TopicEndpointInfo): + return value + if isinstance(value, dict): + return TopicEndpointInfo(**value) + assert False + + # Has to be marked Any due to mypy#3004. Return type is actually ServiceEndpointInfo + @property + def goal_service_info(self) -> Annotated[Any, ServiceEndpointInfo]: + """ + Get field 'goal_service_info'. + + :returns: goal_service_info attribute + """ + return self._goal_service_info + + @goal_service_info.setter + def goal_service_info( + self, + value: Optional[Union[ServiceEndpointInfo, '_rclpy._ServiceEndpointInfoDict']] + ) -> None: + self._goal_service_info = self._to_service_endpoint_info(value) + + # Has to be marked Any due to mypy#3004. Return type is actually ServiceEndpointInfo + @property + def cancel_service_info(self) -> Annotated[Any, ServiceEndpointInfo]: + """ + Get field 'cancel_service_info'. + + :returns: cancel_service_info attribute + """ + return self._cancel_service_info + + @cancel_service_info.setter + def cancel_service_info( + self, + value: Optional[Union[ServiceEndpointInfo, '_rclpy._ServiceEndpointInfoDict']] + ) -> None: + self._cancel_service_info = self._to_service_endpoint_info(value) + + # Has to be marked Any due to mypy#3004. Return type is actually ServiceEndpointInfo + @property + def result_service_info(self) -> Annotated[Any, ServiceEndpointInfo]: + """ + Get field 'result_service_info'. + + :returns: result_service_info attribute + """ + return self._result_service_info + + @result_service_info.setter + def result_service_info( + self, + value: Optional[Union[ServiceEndpointInfo, '_rclpy._ServiceEndpointInfoDict']] + ) -> None: + self._result_service_info = self._to_service_endpoint_info(value) + + # Has to be marked Any due to mypy#3004. Return type is actually TopicEndpointInfo + @property + def feedback_topic_info(self) -> Annotated[Any, TopicEndpointInfo]: + """ + Get field 'feedback_topic_info'. + + :returns: feedback_topic_info attribute + """ + return self._feedback_topic_info + + @feedback_topic_info.setter + def feedback_topic_info( + self, + value: Optional[Union[TopicEndpointInfo, '_rclpy._TopicEndpointInfoDict']] + ) -> None: + self._feedback_topic_info = self._to_topic_endpoint_info(value) + + # Has to be marked Any due to mypy#3004. Return type is actually TopicEndpointInfo + @property + def status_topic_info(self) -> Annotated[Any, TopicEndpointInfo]: + """ + Get field 'status_topic_info'. + + :returns: status_topic_info attribute + """ + return self._status_topic_info + + @status_topic_info.setter + def status_topic_info( + self, + value: Optional[Union[TopicEndpointInfo, '_rclpy._TopicEndpointInfoDict']] + ) -> None: + self._status_topic_info = self._to_topic_endpoint_info(value) + + @property + def node_name(self) -> str: + """ + Get the node name of the action endpoint, from the goal service endpoint. + + :returns: node name of the action endpoint + """ + return self._goal_service_info.node_name + + @property + def node_namespace(self) -> str: + """ + Get the node namespace of the action endpoint, from the goal service endpoint. + + :returns: node namespace of the action endpoint + """ + return self._goal_service_info.node_namespace + + @property + def action_type(self) -> str: + """ + Get the action type, derived from the goal service type. + + :returns: action type of the action endpoint + """ + goal_service_type = self._goal_service_info.service_type + suffix = '_SendGoal' + if goal_service_type.endswith(suffix): + return goal_service_type[:-len(suffix)] + return goal_service_type + + # Has to be marked Any due to mypy#3004. Return type is actually EndpointTypeEnum + @property + def endpoint_type(self) -> Annotated[Any, EndpointTypeEnum]: + """ + Get the endpoint type of the action endpoint, from the goal service endpoint. + + :returns: endpoint type of the action endpoint + """ + return self._goal_service_info.endpoint_type + + def __eq__(self, other: object) -> bool: + if not isinstance(other, ActionEndpointInfo): + return False + return all( + self.__getattribute__(slot) == other.__getattribute__(slot) + for slot in self.__slots__) + + def __str__(self) -> str: + def sub_info_lines(title: str, info: Union[ServiceEndpointInfo, + TopicEndpointInfo]) -> list[str]: + if not info.node_name: + return [f'{title}: not available'] + # The node name, node namespace and endpoint type of the + # underlying entities are implied by the action endpoint. + skipped_prefixes = ('Node name:', 'Node namespace:', 'Endpoint type:') + lines = [f'{title}:'] + for line in str(info).splitlines(): + if line.startswith(skipped_prefixes): + continue + lines.append(f' {line}') + return lines + + info_lines = [ + f'Node name: {self.node_name}', + f'Node namespace: {self.node_namespace}', + f'Action type: {self.action_type}', + f'Endpoint type: {self.endpoint_type.name}', + ] + info_lines += sub_info_lines('Goal service', self.goal_service_info) + info_lines += sub_info_lines('Cancel service', self.cancel_service_info) + info_lines += sub_info_lines('Result service', self.result_service_info) + info_lines += sub_info_lines('Feedback topic', self.feedback_topic_info) + info_lines += sub_info_lines('Status topic', self.status_topic_info) + + return '\n'.join(info_lines) diff --git a/rclpy/rclpy/impl/_rclpy_pybind11.pyi b/rclpy/rclpy/impl/_rclpy_pybind11.pyi index 6ea0a9a43..4bd8e913d 100644 --- a/rclpy/rclpy/impl/_rclpy_pybind11.pyi +++ b/rclpy/rclpy/impl/_rclpy_pybind11.pyi @@ -814,6 +814,14 @@ class _ServiceEndpointInfoDict(TypedDict): endpoint_count: int +class _ActionEndpointInfoDict(TypedDict): + goal_service_info: Optional[_ServiceEndpointInfoDict] + cancel_service_info: Optional[_ServiceEndpointInfoDict] + result_service_info: Optional[_ServiceEndpointInfoDict] + feedback_topic_info: Optional[_TopicEndpointInfoDict] + status_topic_info: Optional[_TopicEndpointInfoDict] + + def rclpy_get_publishers_info_by_topic(node: Node, topic_name: str, no_mangle: bool ) -> list[_TopicEndpointInfoDict]: """Get publishers info for a topic.""" @@ -864,6 +872,16 @@ def rclpy_get_action_names_and_types(node: Node) -> list[tuple[str, list[str]]]: """Get all action names and types in the ROS graph.""" +def rclpy_get_action_clients_info_by_action(node: Node, action_name: str + ) -> list[_ActionEndpointInfoDict]: + """Get action clients info for an action.""" + + +def rclpy_get_action_servers_info_by_action(node: Node, action_name: str + ) -> list[_ActionEndpointInfoDict]: + """Get action servers info for an action.""" + + def rclpy_serialize(pymsg: MsgT, py_msg_type: type[MsgT]) -> bytes: """Serialize a ROS message.""" @@ -906,6 +924,12 @@ class Node(Destroyable): def get_count_services(self, service_name: str) -> int: """Return the count of all the servers known for that service in the entire ROS graph.""" + def get_count_action_clients(self, action_name: str) -> int: + """Return the count of the action clients known for that action in the entire ROS graph.""" + + def get_count_action_servers(self, action_name: str) -> int: + """Return the count of the action servers known for that action in the entire ROS graph.""" + def get_node_names_and_namespaces(self) -> list[tuple[str, str]]: """Get the list of nodes discovered by the provided node.""" diff --git a/rclpy/rclpy/node.py b/rclpy/rclpy/node.py index d894e3252..bd8059b9f 100644 --- a/rclpy/rclpy/node.py +++ b/rclpy/rclpy/node.py @@ -56,7 +56,7 @@ from rclpy.clock_type import ClockType from rclpy.constants import S_TO_NS from rclpy.context import Context -from rclpy.endpoint_info import ServiceEndpointInfo, TopicEndpointInfo +from rclpy.endpoint_info import ActionEndpointInfo, ServiceEndpointInfo, TopicEndpointInfo from rclpy.event_handler import PublisherEventCallbacks from rclpy.event_handler import SubscriptionEventCallbacks from rclpy.exceptions import InvalidHandle @@ -1754,6 +1754,46 @@ def count_services(self, service_name: str) -> int: return self._count_clients_or_servers( service_name, self.handle.get_count_services) + def _count_action_clients_or_servers( + self, + action_name: str, + func: Callable[[str], int] + ) -> int: + fq_action_name = expand_topic_name(action_name, self.get_name(), self.get_namespace()) + validate_full_topic_name(fq_action_name) + with self.handle: + return func(fq_action_name) + + def count_action_clients(self, action_name: str) -> int: + """ + Return the number of action clients on a given action. + + `action_name` may be a relative, private, or fully qualified action name. + A relative or private action is expanded using this node's namespace and name. + The queried action name is not remapped. + + :param action_name: the action_name on which to count the number of action clients. + :return: the number of action clients on the action. + """ + with self.handle: + return self._count_action_clients_or_servers( + action_name, self.handle.get_count_action_clients) + + def count_action_servers(self, action_name: str) -> int: + """ + Return the number of action servers on a given action. + + `action_name` may be a relative, private, or fully qualified action name. + A relative or private action is expanded using this node's namespace and name. + The queried action name is not remapped. + + :param action_name: the action_name on which to count the number of action servers. + :return: the number of action servers on the action. + """ + with self.handle: + return self._count_action_clients_or_servers( + action_name, self.handle.get_count_action_servers) + def _get_info_by_topic( self, topic_name: str, @@ -1923,6 +1963,65 @@ def get_servers_info_by_service( no_mangle, _rclpy.rclpy_get_servers_info_by_service) + def _get_info_by_action( + self, + action_name: str, + func: Callable[['_rclpy.Node', str], list['_rclpy._ActionEndpointInfoDict']] + ) -> List[ActionEndpointInfo]: + with self.handle: + fq_action_name = expand_topic_name( + action_name, self.get_name(), self.get_namespace()) + validate_full_topic_name(fq_action_name) + info_dicts = func(self.handle, fq_action_name) + infos = [ActionEndpointInfo(**x) for x in info_dicts] + return infos + + def get_action_clients_info_by_action( + self, + action_name: str + ) -> List[ActionEndpointInfo]: + """ + Return a list of action clients on a given action. + + The returned parameter is a list of ActionEndpointInfo objects, where each aggregates + the endpoint information of all the underlying entities of one action client, i.e. the + clients of the goal, cancel, and result services and the subscriptions on the feedback + and status topics. + + ``action_name`` may be a relative, private, or fully qualified action name. + A relative or private action will be expanded using this node's namespace and name. + The queried ``action_name`` is not remapped. + + :param action_name: The action_name on which to find the action clients. + :return: A list of ActionEndpointInfo for all the action clients on this action. + """ + return self._get_info_by_action( + action_name, + _rclpy.rclpy_get_action_clients_info_by_action) + + def get_action_servers_info_by_action( + self, + action_name: str + ) -> List[ActionEndpointInfo]: + """ + Return a list of action servers on a given action. + + The returned parameter is a list of ActionEndpointInfo objects, where each aggregates + the endpoint information of all the underlying entities of one action server, i.e. the + servers of the goal, cancel, and result services and the publishers on the feedback + and status topics. + + ``action_name`` may be a relative, private, or fully qualified action name. + A relative or private action will be expanded using this node's namespace and name. + The queried ``action_name`` is not remapped. + + :param action_name: The action_name on which to find the action servers. + :return: A list of ActionEndpointInfo for all the action servers on this action. + """ + return self._get_info_by_action( + action_name, + _rclpy.rclpy_get_action_servers_info_by_action) + def _create_publisher_handle( self, msg_type: Type[MsgT], diff --git a/rclpy/src/rclpy/_rclpy_pybind11.cpp b/rclpy/src/rclpy/_rclpy_pybind11.cpp index b48ff774b..ce3dea8df 100644 --- a/rclpy/src/rclpy/_rclpy_pybind11.cpp +++ b/rclpy/src/rclpy/_rclpy_pybind11.cpp @@ -233,6 +233,14 @@ PYBIND11_MODULE(_rclpy_pybind11, m) { "rclpy_get_action_names_and_types", &rclpy::graph_get_action_names_and_types, "Get all action names and types in the ROS graph."); + m.def( + "rclpy_get_action_clients_info_by_action", + &rclpy::graph_get_action_clients_info_by_action, + "Get action clients info for an action."); + m.def( + "rclpy_get_action_servers_info_by_action", + &rclpy::graph_get_action_servers_info_by_action, + "Get action servers info for an action."); m.def( "rclpy_serialize", &rclpy::serialize, "Serialize a ROS message."); diff --git a/rclpy/src/rclpy/graph.cpp b/rclpy/src/rclpy/graph.cpp index 7dae5fa29..7329659ad 100644 --- a/rclpy/src/rclpy/graph.cpp +++ b/rclpy/src/rclpy/graph.cpp @@ -434,4 +434,68 @@ graph_get_action_names_and_types(Node & node) return convert_to_py_names_and_types(&action_names_and_types); } +typedef rcl_ret_t (* rcl_action_get_info_by_action_func_t)( + const rcl_node_t * node, + rcutils_allocator_t * allocator, + const char * action_name, + rcl_action_endpoint_info_array_t * info_array); + + +py::list +_get_info_by_action( + Node & node, + const char * action_name, + const char * type, + rcl_action_get_info_by_action_func_t rcl_action_get_info_by_action) +{ + rcutils_allocator_t allocator = rcutils_get_default_allocator(); + rcl_action_endpoint_info_array_t info_array = + rcl_action_get_zero_initialized_endpoint_info_array(); + + RCPPUTILS_SCOPE_EXIT( + { + rcl_ret_t fini_ret = rcl_action_endpoint_info_array_fini(&info_array, &allocator); + if (RCL_RET_OK != fini_ret) { + RCUTILS_SAFE_FWRITE_TO_STDERR( + "[rclpy|" RCUTILS_STRINGIFY(__FILE__) ":" RCUTILS_STRINGIFY(__LINE__) "]: " + "rcl_action_endpoint_info_array_fini failed: "); + RCUTILS_SAFE_FWRITE_TO_STDERR(rcl_get_error_string().str); + RCUTILS_SAFE_FWRITE_TO_STDERR("\n"); + rcl_reset_error(); + } + }); + + rcl_ret_t ret = rcl_action_get_info_by_action( + node.rcl_ptr(), &allocator, action_name, &info_array); + if (RCL_RET_OK != ret) { + if (RCL_RET_UNSUPPORTED == ret) { + throw NotImplementedError( + std::string("Failed to get information by action for ") + + type + ": function not supported by RMW_IMPLEMENTATION"); + } + throw RCLError( + std::string("Failed to get information by action for ") + type); + } + + return convert_to_py_action_endpoint_info_list(&info_array); +} + +py::list +graph_get_action_clients_info_by_action( + Node & node, const char * action_name) +{ + return _get_info_by_action( + node, action_name, "action clients", + rcl_action_get_clients_info_by_action); +} + +py::list +graph_get_action_servers_info_by_action( + Node & node, const char * action_name) +{ + return _get_info_by_action( + node, action_name, "action servers", + rcl_action_get_servers_info_by_action); +} + } // namespace rclpy diff --git a/rclpy/src/rclpy/graph.hpp b/rclpy/src/rclpy/graph.hpp index 0c3591904..792d30a4d 100644 --- a/rclpy/src/rclpy/graph.hpp +++ b/rclpy/src/rclpy/graph.hpp @@ -247,6 +247,44 @@ graph_get_action_server_names_and_types_by_node( py::list graph_get_action_names_and_types(Node & node); +/// Return a list of action clients on a given action. +/** + * Each entry of the returned list aggregates the endpoint information of all + * the underlying entities of one action client, i.e. the clients of the + * goal, cancel, and result services and the subscriptions on the feedback + * and status topics. + * + * Raises NotImplementedError if the call is not supported by RMW + * Raises RCLError if there is an rcl error + * + * \param[in] node node to get action clients info + * \param[in] action_name the fully qualified action name to get the action clients for. + * \return list of action clients. + * \see rcl_action_get_clients_info_by_action + */ +py::list +graph_get_action_clients_info_by_action( + Node & node, const char * action_name); + +/// Return a list of action servers on a given action. +/** + * Each entry of the returned list aggregates the endpoint information of all + * the underlying entities of one action server, i.e. the servers of the + * goal, cancel, and result services and the publishers on the feedback and + * status topics. + * + * Raises NotImplementedError if the call is not supported by RMW + * Raises RCLError if there is an rcl error + * + * \param[in] node node to get action servers info + * \param[in] action_name the fully qualified action name to get the action servers for. + * \return list of action servers. + * \see rcl_action_get_servers_info_by_action + */ +py::list +graph_get_action_servers_info_by_action( + Node & node, const char * action_name); + } // namespace rclpy #endif // RCLPY__GRAPH_HPP_ diff --git a/rclpy/src/rclpy/node.cpp b/rclpy/src/rclpy/node.cpp index 916ac4e17..6ce67cbc6 100644 --- a/rclpy/src/rclpy/node.cpp +++ b/rclpy/src/rclpy/node.cpp @@ -134,6 +134,30 @@ Node::get_count_services(const char * service_name) return count; } +size_t +Node::get_count_action_clients(const char * action_name) +{ + size_t count = 0; + rcl_ret_t ret = rcl_action_count_clients(rcl_node_.get(), action_name, &count); + if (RCL_RET_OK != ret) { + throw RCLError("Error in rcl_action_count_clients"); + } + + return count; +} + +size_t +Node::get_count_action_servers(const char * action_name) +{ + size_t count = 0; + rcl_ret_t ret = rcl_action_count_servers(rcl_node_.get(), action_name, &count); + if (RCL_RET_OK != ret) { + throw RCLError("Error in rcl_action_count_servers"); + } + + return count; +} + py::list Node::get_names_impl(bool get_enclaves) { @@ -605,6 +629,12 @@ define_node(py::object module) .def( "get_count_services", &Node::get_count_services, "Returns the count of all the servers known for that service in the entire ROS graph.") + .def( + "get_count_action_clients", &Node::get_count_action_clients, + "Returns the count of all the action clients known for that action in the entire ROS graph.") + .def( + "get_count_action_servers", &Node::get_count_action_servers, + "Returns the count of all the action servers known for that action in the entire ROS graph.") .def( "get_node_names_and_namespaces", &Node::get_node_names_and_namespaces, "Get the list of nodes discovered by the provided node") diff --git a/rclpy/src/rclpy/node.hpp b/rclpy/src/rclpy/node.hpp index 16143b994..32abcdf9d 100644 --- a/rclpy/src/rclpy/node.hpp +++ b/rclpy/src/rclpy/node.hpp @@ -132,6 +132,26 @@ class Node : public Destroyable, public std::enable_shared_from_this size_t get_count_services(const char * service_name); + /// Returns the count of all the action clients known for that action in the entire ROS graph + /** + * Raises RCLError if an error occurs in rcl + * + * \param[in] action_name Name of the action to count the number of action clients + * \return the count of all the action clients known for that action in the entire ROS graph + */ + size_t + get_count_action_clients(const char * action_name); + + /// Returns the count of all the action servers known for that action in the entire ROS graph + /** + * Raises RCLError if an error occurs in rcl + * + * \param[in] action_name Name of the action to count the number of action servers + * \return the count of all the action servers known for that action in the entire ROS graph + */ + size_t + get_count_action_servers(const char * action_name); + /// Get the list of nodes discovered by the provided node /** * Raises RCLError if the names are unavailable. diff --git a/rclpy/src/rclpy/utils.cpp b/rclpy/src/rclpy/utils.cpp index 7d603123e..3a1cd5a4f 100644 --- a/rclpy/src/rclpy/utils.cpp +++ b/rclpy/src/rclpy/utils.cpp @@ -390,6 +390,53 @@ convert_to_py_service_endpoint_info_list(const rmw_service_endpoint_info_array_t return py_info_array; } +py::object +_convert_to_py_action_endpoint_info(const rcl_action_endpoint_info_t * action_endpoint_info) +{ + // Create dictionary that represents the aggregated endpoint information of + // all the underlying entities of one action client or one action server. + // Sub-entities that have not been discovered are represented as None. + py::dict py_endpoint_info_dict; + py_endpoint_info_dict["goal_service_info"] = + action_endpoint_info->goal_service_info.node_name ? + py::object(_convert_to_py_service_endpoint_info(&action_endpoint_info->goal_service_info)) : + py::object(py::none()); + py_endpoint_info_dict["cancel_service_info"] = + action_endpoint_info->cancel_service_info.node_name ? + py::object(_convert_to_py_service_endpoint_info(&action_endpoint_info->cancel_service_info)) : + py::object(py::none()); + py_endpoint_info_dict["result_service_info"] = + action_endpoint_info->result_service_info.node_name ? + py::object(_convert_to_py_service_endpoint_info(&action_endpoint_info->result_service_info)) : + py::object(py::none()); + py_endpoint_info_dict["feedback_topic_info"] = + action_endpoint_info->feedback_topic_info.node_name ? + py::object(_convert_to_py_topic_endpoint_info(&action_endpoint_info->feedback_topic_info)) : + py::object(py::none()); + py_endpoint_info_dict["status_topic_info"] = + action_endpoint_info->status_topic_info.node_name ? + py::object(_convert_to_py_topic_endpoint_info(&action_endpoint_info->status_topic_info)) : + py::object(py::none()); + + return py_endpoint_info_dict; +} + +py::list +convert_to_py_action_endpoint_info_list(const rcl_action_endpoint_info_array_t * info_array) +{ + if (!info_array) { + throw std::runtime_error("rcl_action_endpoint_info_array_t pointer is empty"); + } + + py::list py_info_array(info_array->size); + + for (size_t i = 0; i < info_array->size; ++i) { + // add this dict to the list + py_info_array[i] = _convert_to_py_action_endpoint_info(&info_array->info_array[i]); + } + return py_info_array; +} + static py::object _convert_rmw_time_to_py_duration(const rmw_time_t * duration) diff --git a/rclpy/src/rclpy/utils.hpp b/rclpy/src/rclpy/utils.hpp index 49470fbaf..545c580c3 100644 --- a/rclpy/src/rclpy/utils.hpp +++ b/rclpy/src/rclpy/utils.hpp @@ -19,6 +19,7 @@ #include #include // rcl_names_and_types_t +#include // rcl_action_endpoint_info_array_t #include #include @@ -152,6 +153,22 @@ convert_to_py_topic_endpoint_info_list(const rmw_topic_endpoint_info_array_t * i py::list convert_to_py_service_endpoint_info_list(const rmw_service_endpoint_info_array_t * info_array); +/// Convert a C rcl_action_endpoint_info_array_t into a Python list. +/** + * Raises RuntimeError if the info_array is null. + * + * Each entry of the returned list is a dictionary aggregating the endpoint + * information of all the underlying entities of one action client or one + * action server (goal, cancel, and result services and feedback and status + * topics). + * Sub-entities that have not been discovered are represented as None. + * + * \param[in] info_array a pointer to a rcl_action_endpoint_info_array_t + * \return Python list + */ +py::list +convert_to_py_action_endpoint_info_list(const rcl_action_endpoint_info_array_t * info_array); + /// Convert a C rmw_qos_profile_t into a Python dictionary with qos profile args. /** * \param[in] qos_profile Pointer to a rmw_qos_profile_t to convert diff --git a/rclpy/test/test_node.py b/rclpy/test/test_node.py index 79c3f62b7..4cff40e88 100644 --- a/rclpy/test/test_node.py +++ b/rclpy/test/test_node.py @@ -37,6 +37,7 @@ from rcl_interfaces.msg import SetParametersResult from rcl_interfaces.srv import GetParameters import rclpy +from rclpy.action import ActionClient, ActionServer from rclpy.clock_type import ClockType import rclpy.context from rclpy.duration import Duration @@ -62,6 +63,7 @@ from rclpy.time_source import USE_SIM_TIME_NAME from rclpy.type_description_service import START_TYPE_DESCRIPTION_SERVICE_PARAM from rclpy.utilities import get_rmw_implementation_identifier +from test_msgs.action import Fibonacci from test_msgs.msg import BasicTypes from test_msgs.srv import Empty @@ -421,6 +423,120 @@ def test_get_clients_servers_info_by_service(self) -> None: self.node.get_clients_info_by_service('13') self.node.get_servers_info_by_service('13') + def test_get_action_clients_servers_info_by_action(self) -> None: + action_name = 'test_action_endpoint_info' + fq_action_name = '{namespace}/{name}'.format(namespace=TEST_NAMESPACE, name=action_name) + # Lists should be empty + self.assertFalse(self.node.get_action_clients_info_by_action(fq_action_name)) + self.assertFalse(self.node.get_action_servers_info_by_action(fq_action_name)) + + # Add an action client + action_client = ActionClient(self.node, Fibonacci, action_name) + # Client list should have at least one item + client_list = self.node.get_action_clients_info_by_action(fq_action_name) + self.assertGreaterEqual(len(client_list), 1) + # Server list should be empty + self.assertFalse(self.node.get_action_servers_info_by_action(fq_action_name)) + + # Verify client list has the right data + self.assertEqual(self.node.get_name(), client_list[0].node_name) + self.assertEqual(self.node.get_namespace(), client_list[0].node_namespace) + self.assertEqual('test_msgs/action/Fibonacci', client_list[0].action_type) + self.assertEqual(client_list[0].endpoint_type, EndpointTypeEnum.CLIENT) + # Verify the underlying entities of the action client + goal_info = client_list[0].goal_service_info + self.assertEqual('test_msgs/action/Fibonacci_SendGoal', goal_info.service_type) + self.assertEqual(EndpointTypeEnum.CLIENT, goal_info.endpoint_type) + self.assertTrue(goal_info.endpoint_count == 1 or goal_info.endpoint_count == 2) + cancel_info = client_list[0].cancel_service_info + self.assertEqual('action_msgs/srv/CancelGoal', cancel_info.service_type) + self.assertEqual(EndpointTypeEnum.CLIENT, cancel_info.endpoint_type) + result_info = client_list[0].result_service_info + self.assertEqual('test_msgs/action/Fibonacci_GetResult', result_info.service_type) + self.assertEqual(EndpointTypeEnum.CLIENT, result_info.endpoint_type) + feedback_info = client_list[0].feedback_topic_info + self.assertEqual('test_msgs/action/Fibonacci_FeedbackMessage', feedback_info.topic_type) + self.assertEqual(EndpointTypeEnum.SUBSCRIPTION, feedback_info.endpoint_type) + status_info = client_list[0].status_topic_info + self.assertEqual('action_msgs/msg/GoalStatusArray', status_info.topic_type) + self.assertEqual(EndpointTypeEnum.SUBSCRIPTION, status_info.endpoint_type) + + # Add an action server + action_server = ActionServer( + self.node, Fibonacci, action_name, lambda goal_handle: Fibonacci.Result()) + # Both lists should have at least one item + client_list = self.node.get_action_clients_info_by_action(fq_action_name) + server_list = self.node.get_action_servers_info_by_action(fq_action_name) + self.assertGreaterEqual(len(client_list), 1) + self.assertGreaterEqual(len(server_list), 1) + + # Verify server list has the right data + self.assertEqual(self.node.get_name(), server_list[0].node_name) + self.assertEqual(self.node.get_namespace(), server_list[0].node_namespace) + self.assertEqual('test_msgs/action/Fibonacci', server_list[0].action_type) + self.assertEqual(server_list[0].endpoint_type, EndpointTypeEnum.SERVER) + # Verify the underlying entities of the action server + goal_info = server_list[0].goal_service_info + self.assertEqual('test_msgs/action/Fibonacci_SendGoal', goal_info.service_type) + self.assertEqual(EndpointTypeEnum.SERVER, goal_info.endpoint_type) + self.assertTrue(goal_info.endpoint_count == 1 or goal_info.endpoint_count == 2) + cancel_info = server_list[0].cancel_service_info + self.assertEqual('action_msgs/srv/CancelGoal', cancel_info.service_type) + self.assertEqual(EndpointTypeEnum.SERVER, cancel_info.endpoint_type) + result_info = server_list[0].result_service_info + self.assertEqual('test_msgs/action/Fibonacci_GetResult', result_info.service_type) + self.assertEqual(EndpointTypeEnum.SERVER, result_info.endpoint_type) + feedback_info = server_list[0].feedback_topic_info + self.assertEqual('test_msgs/action/Fibonacci_FeedbackMessage', feedback_info.topic_type) + self.assertEqual(EndpointTypeEnum.PUBLISHER, feedback_info.endpoint_type) + status_info = server_list[0].status_topic_info + self.assertEqual('action_msgs/msg/GoalStatusArray', status_info.topic_type) + self.assertEqual(EndpointTypeEnum.PUBLISHER, status_info.endpoint_type) + + action_client.destroy() + action_server.destroy() + + # Error cases + with self.assertRaises(TypeError): + self.node.get_action_clients_info_by_action(1) # type: ignore[arg-type] + self.node.get_action_servers_info_by_action(1) # type: ignore[arg-type] + with self.assertRaisesRegex(ValueError, 'is invalid'): + self.node.get_action_clients_info_by_action('13') + self.node.get_action_servers_info_by_action('13') + + def test_count_action_clients_servers(self) -> None: + short_action_name = 'fibonacci' + fq_action_name = '%s/%s' % (TEST_NAMESPACE, short_action_name) + + self.assertEqual(0, self.node.count_action_clients(fq_action_name)) + self.assertEqual(0, self.node.count_action_servers(fq_action_name)) + + action_client = ActionClient(self.node, Fibonacci, short_action_name) + self.assertEqual(1, self.node.count_action_clients(short_action_name)) + self.assertEqual(1, self.node.count_action_clients(fq_action_name)) + self.assertEqual(0, self.node.count_action_servers(short_action_name)) + self.assertEqual(0, self.node.count_action_servers(fq_action_name)) + + action_server = ActionServer( + self.node, Fibonacci, short_action_name, lambda goal_handle: Fibonacci.Result()) + self.assertEqual(1, self.node.count_action_clients(short_action_name)) + self.assertEqual(1, self.node.count_action_clients(fq_action_name)) + self.assertEqual(1, self.node.count_action_servers(short_action_name)) + self.assertEqual(1, self.node.count_action_servers(fq_action_name)) + + action_client.destroy() + action_server.destroy() + + # error cases + with self.assertRaises(TypeError): + self.node.count_action_clients(1) # type: ignore[arg-type] + with self.assertRaises(TypeError): + self.node.count_action_servers(1) # type: ignore[arg-type] + with self.assertRaisesRegex(ValueError, 'is invalid'): + self.node.count_action_clients('42') + with self.assertRaisesRegex(ValueError, 'is invalid'): + self.node.count_action_servers('42') + def test_count_publishers_subscribers(self) -> None: short_topic_name = 'chatter' fq_topic_name = '%s/%s' % (TEST_NAMESPACE, short_topic_name) From 81f5b4cf00d7b96801bcf941bdba1381cad516fc Mon Sep 17 00:00:00 2001 From: Tomoya Fujita Date: Mon, 20 Jul 2026 08:49:43 +0900 Subject: [PATCH 2/3] address Copilot review comments. Signed-off-by: Tomoya Fujita --- rclpy/rclpy/endpoint_info.py | 8 ++++++-- rclpy/test/test_node.py | 2 ++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/rclpy/rclpy/endpoint_info.py b/rclpy/rclpy/endpoint_info.py index 6b6f9b2e6..9560e1c3a 100644 --- a/rclpy/rclpy/endpoint_info.py +++ b/rclpy/rclpy/endpoint_info.py @@ -494,7 +494,9 @@ def _to_service_endpoint_info( return value if isinstance(value, dict): return ServiceEndpointInfo(**value) - assert False + raise TypeError( + 'expected ServiceEndpointInfo, dict, or None, ' + f'got {type(value).__name__}') @staticmethod def _to_topic_endpoint_info( @@ -506,7 +508,9 @@ def _to_topic_endpoint_info( return value if isinstance(value, dict): return TopicEndpointInfo(**value) - assert False + raise TypeError( + 'expected TopicEndpointInfo, dict, or None, ' + f'got {type(value).__name__}') # Has to be marked Any due to mypy#3004. Return type is actually ServiceEndpointInfo @property diff --git a/rclpy/test/test_node.py b/rclpy/test/test_node.py index 4cff40e88..22ddff58d 100644 --- a/rclpy/test/test_node.py +++ b/rclpy/test/test_node.py @@ -499,9 +499,11 @@ def test_get_action_clients_servers_info_by_action(self) -> None: # Error cases with self.assertRaises(TypeError): self.node.get_action_clients_info_by_action(1) # type: ignore[arg-type] + with self.assertRaises(TypeError): self.node.get_action_servers_info_by_action(1) # type: ignore[arg-type] with self.assertRaisesRegex(ValueError, 'is invalid'): self.node.get_action_clients_info_by_action('13') + with self.assertRaisesRegex(ValueError, 'is invalid'): self.node.get_action_servers_info_by_action('13') def test_count_action_clients_servers(self) -> None: From 3ba3157831b1536e2aa6d144233ef1c9b0ef1b47 Mon Sep 17 00:00:00 2001 From: Tomoya Fujita Date: Fri, 24 Jul 2026 16:54:10 +0900 Subject: [PATCH 3/3] harden the test wait loop more reliable. Signed-off-by: Tomoya Fujita --- rclpy/test/test_node.py | 47 ++++++++++++++++++++++++++++++++--------- 1 file changed, 37 insertions(+), 10 deletions(-) diff --git a/rclpy/test/test_node.py b/rclpy/test/test_node.py index 22ddff58d..95f3b93d4 100644 --- a/rclpy/test/test_node.py +++ b/rclpy/test/test_node.py @@ -16,6 +16,7 @@ import platform import time from typing import Any +from typing import Callable from typing import cast from typing import List from typing import Optional @@ -506,25 +507,51 @@ def test_get_action_clients_servers_info_by_action(self) -> None: with self.assertRaisesRegex(ValueError, 'is invalid'): self.node.get_action_servers_info_by_action('13') + def assert_count_eventually_equal( + self, + expected: int, + count_func: Callable[[str], int], + name: str, + timeout: float = 5.0 + ) -> None: + # Graph counts are discovery-based and updated asynchronously, so + # poll until the expected value is observed instead of asserting once. + deadline = time.monotonic() + timeout + actual = count_func(name) + while actual != expected and time.monotonic() < deadline: + time.sleep(0.1) + actual = count_func(name) + self.assertEqual(expected, actual) + def test_count_action_clients_servers(self) -> None: short_action_name = 'fibonacci' fq_action_name = '%s/%s' % (TEST_NAMESPACE, short_action_name) - self.assertEqual(0, self.node.count_action_clients(fq_action_name)) - self.assertEqual(0, self.node.count_action_servers(fq_action_name)) + self.assert_count_eventually_equal( + 0, self.node.count_action_clients, fq_action_name) + self.assert_count_eventually_equal( + 0, self.node.count_action_servers, fq_action_name) action_client = ActionClient(self.node, Fibonacci, short_action_name) - self.assertEqual(1, self.node.count_action_clients(short_action_name)) - self.assertEqual(1, self.node.count_action_clients(fq_action_name)) - self.assertEqual(0, self.node.count_action_servers(short_action_name)) - self.assertEqual(0, self.node.count_action_servers(fq_action_name)) + self.assert_count_eventually_equal( + 1, self.node.count_action_clients, short_action_name) + self.assert_count_eventually_equal( + 1, self.node.count_action_clients, fq_action_name) + self.assert_count_eventually_equal( + 0, self.node.count_action_servers, short_action_name) + self.assert_count_eventually_equal( + 0, self.node.count_action_servers, fq_action_name) action_server = ActionServer( self.node, Fibonacci, short_action_name, lambda goal_handle: Fibonacci.Result()) - self.assertEqual(1, self.node.count_action_clients(short_action_name)) - self.assertEqual(1, self.node.count_action_clients(fq_action_name)) - self.assertEqual(1, self.node.count_action_servers(short_action_name)) - self.assertEqual(1, self.node.count_action_servers(fq_action_name)) + self.assert_count_eventually_equal( + 1, self.node.count_action_clients, short_action_name) + self.assert_count_eventually_equal( + 1, self.node.count_action_clients, fq_action_name) + self.assert_count_eventually_equal( + 1, self.node.count_action_servers, short_action_name) + self.assert_count_eventually_equal( + 1, self.node.count_action_servers, fq_action_name) action_client.destroy() action_server.destroy()