diff --git a/.github/ci/after_make.sh b/.github/ci/after_make.sh new file mode 100644 index 000000000..d094d8799 --- /dev/null +++ b/.github/ci/after_make.sh @@ -0,0 +1,2 @@ +# It's necessary to install the python modules for the test. +make install \ No newline at end of file diff --git a/.github/ci/packages.apt b/.github/ci/packages.apt index 6e2c82feb..359b64268 100644 --- a/.github/ci/packages.apt +++ b/.github/ci/packages.apt @@ -9,4 +9,10 @@ libsqlite3-dev libzmq3-dev pkg-config protobuf-compiler +python3-dev +python3-distutils +python3-psutil +python3-pybind11 +python3-pytest +python3-gz-msgs10 uuid-dev diff --git a/.gitignore b/.gitignore index 62b9898cc..f8a1669f2 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ build_* # OS generated files .DS_Store *.swp + +# Python generated files +*.pyc diff --git a/CMakeLists.txt b/CMakeLists.txt index 4c813a596..8a5cb5efc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -15,6 +15,22 @@ set(GZ_CMAKE_VER ${gz-cmake3_VERSION_MAJOR}) #============================================================================ # Configure the project #============================================================================ +set(skip_pybind11_default_value OFF) +option(SKIP_PYBIND11 + "Skip generating Python bindings via pybind11" + ${skip_pybind11_default_value}) + +# Python interfaces vars +include(CMakeDependentOption) +include(GzPython) +cmake_dependent_option(USE_SYSTEM_PATHS_FOR_PYTHON_INSTALLATION + "Install python modules in standard system paths in the system" + OFF "NOT SKIP_PYBIND11" OFF) + +cmake_dependent_option(USE_DIST_PACKAGES_FOR_PYTHON + "Use dist-packages instead of site-package to install python modules" + OFF "NOT SKIP_PYBIND11" OFF) + set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) @@ -72,6 +88,7 @@ gz_find_package(CPPZMQ REQUIRED PRIVATE #-------------------------------------- # Find uuid if (MSVC) + set(skip_pybind11_default_value ON) message (STATUS "UUID: Using Windows RPC UuidCreate function\n") else() gz_find_package(UUID REQUIRED) @@ -115,6 +132,31 @@ gz_find_package(SQLite3 #============================================================================ # Configure the build #============================================================================ + +######################################## +# Python interfaces +if (NOT PYTHON3_FOUND) + GZ_BUILD_WARNING("Python is missing: Python interfaces are disabled.") + message (STATUS "Searching for Python - not found.") +else() + message (STATUS "Searching for Python - found version ${Python3_VERSION}.") + + if (SKIP_PYBIND11) + message(STATUS "SKIP_PYBIND11 set - disabling python bindings") + else() + set(PYBIND11_PYTHON_VERSION 3) + find_package(pybind11 2.4 QUIET) + + if (${pybind11_FOUND}) + find_package(Python3 ${GZ_PYTHON_VERSION} REQUIRED COMPONENTS Development) + message (STATUS "Searching for pybind11 - found version ${pybind11_VERSION}.") + else() + GZ_BUILD_WARNING("pybind11 is missing: Python interfaces are disabled.") + message (STATUS "Searching for pybind11 - not found.") + endif() + endif() +endif() + gz_configure_build(QUIT_IF_BUILD_ERRORS COMPONENTS log parameters) @@ -123,6 +165,13 @@ gz_configure_build(QUIT_IF_BUILD_ERRORS #============================================================================ add_subdirectory(conf) +#============================================================================ +# gz transport python bindings +#============================================================================ +if (pybind11_FOUND AND NOT SKIP_PYBIND11) + add_subdirectory(python) +endif() + #============================================================================ # Create package information #============================================================================ diff --git a/python/CMakeLists.txt b/python/CMakeLists.txt new file mode 100644 index 000000000..3577928ce --- /dev/null +++ b/python/CMakeLists.txt @@ -0,0 +1,96 @@ +if(WIN32 AND CMAKE_BUILD_TYPE STREQUAL "Debug") + # pybind11 logic for setting up a debug build when both a debug and release + # python interpreter are present in the system seems to be pretty much broken. + # This works around the issue. + set(PYTHON_LIBRARIES "${PYTHON_DEBUG_LIBRARIES}") +endif() + +if(USE_SYSTEM_PATHS_FOR_PYTHON_INSTALLATION) + if(${CMAKE_VERSION} VERSION_LESS "3.12.0") + execute_process( + COMMAND "${PYTHON_EXECUTABLE}" -c "if True: + from distutils import sysconfig as sc + print(sc.get_python_lib(plat_specific=True))" + OUTPUT_VARIABLE Python3_SITEARCH + OUTPUT_STRIP_TRAILING_WHITESPACE) + else() + # Get install variable from Python3 module + # Python3_SITEARCH is available from 3.12 on, workaround if needed: + find_package(Python3 COMPONENTS Interpreter) + endif() + + if(USE_DIST_PACKAGES_FOR_PYTHON) + string(REPLACE "site-packages" "dist-packages" GZ_PYTHON_INSTALL_PATH ${Python3_SITEARCH}) + else() + # custom cmake command is returning dist-packages + string(REPLACE "dist-packages" "site-packages" GZ_PYTHON_INSTALL_PATH ${Python3_SITEARCH}) + endif() +else() + # If not a system installation, respect local paths + set(GZ_PYTHON_INSTALL_PATH ${GZ_LIB_INSTALL_DIR}/python/gz) +endif() + +# Set the build location and install location for a CPython extension +function(configure_build_install_location _library_name) + # Install library for actual use + install(TARGETS ${_library_name} + DESTINATION "${GZ_PYTHON_INSTALL_PATH}/transport${PROJECT_VERSION_MAJOR}" + ) +endfunction() + +message(STATUS "Building pybind11 interfaces") +# We are creating the bindings name as the following because we +# created a python wrapper that does some modifications in order +# to be able to provide an API similar to the one used in C++. +set(BINDINGS_MODULE_NAME "_transport") +# Split from main extension and converted to pybind11 +pybind11_add_module(${BINDINGS_MODULE_NAME} MODULE + src/transport/_gz_transport_pybind11.cc +) + +target_link_libraries(${BINDINGS_MODULE_NAME} PRIVATE + ${PROJECT_LIBRARY_TARGET_NAME} +) + +target_compile_definitions(${BINDINGS_MODULE_NAME} PRIVATE + BINDINGS_MODULE_NAME=${BINDINGS_MODULE_NAME}) + +configure_build_install_location(${BINDINGS_MODULE_NAME}) + +install(FILES + src/__init__.py + DESTINATION "${GZ_PYTHON_INSTALL_PATH}/transport${PROJECT_VERSION_MAJOR}" +) + +if (BUILD_TESTING AND NOT WIN32) + set(python_tests + pubSub_TEST + requester_TEST + options_TEST + ) + execute_process(COMMAND "${Python3_EXECUTABLE}" -m pytest --version + OUTPUT_VARIABLE PYTEST_output + ERROR_VARIABLE PYTEST_error + RESULT_VARIABLE PYTEST_result) + if(${PYTEST_result} EQUAL 0) + set(pytest_FOUND TRUE) + else() + message(WARNING "Pytest package not available: ${PYTEST_error}") + endif() + + foreach (test ${python_tests}) + if (pytest_FOUND) + add_test(NAME ${test}.py COMMAND + "${Python3_EXECUTABLE}" -m pytest "${CMAKE_SOURCE_DIR}/python/test/${test}.py" --junitxml "${CMAKE_BINARY_DIR}/test_results/${test}.xml") + else() + add_test(NAME ${test}.py COMMAND + "${Python3_EXECUTABLE}" "${CMAKE_SOURCE_DIR}/python/test/${test}.py") + endif() + set(_env_vars) + list(APPEND _env_vars "CMAKE_BINARY_DIR=${CMAKE_BINARY_DIR}/bin") + list(APPEND _env_vars "PYTHONPATH=${CMAKE_INSTALL_PREFIX}/${CMAKE_INSTALL_LIBDIR}/python/:${CMAKE_BINARY_DIR}/lib:$ENV{PYTHONPATH}") + list(APPEND _env_vars "LD_LIBRARY_PATH=${CMAKE_INSTALL_PREFIX}/${CMAKE_INSTALL_LIBDIR}:$ENV{LD_LIBRARY_PATH}") + set_tests_properties(${test}.py PROPERTIES + ENVIRONMENT "${_env_vars}") + endforeach() +endif() diff --git a/python/examples/data_race_with_mutex.py b/python/examples/data_race_with_mutex.py new file mode 100644 index 000000000..caebaa0fa --- /dev/null +++ b/python/examples/data_race_with_mutex.py @@ -0,0 +1,74 @@ +# Copyright (C) 2023 Open Source Robotics Foundation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from gz.msgs10.vector3d_pb2 import Vector3d +from gz.transport13 import Node + +from threading import Lock +import time + +x = 1 +y = 2 +z = 3 + +mutex = Lock() + +def vector3_cb(msg: Vector3d): + global x, y, z, mutex + with mutex: + x = msg.x + y = msg.y + z = msg.z + +def main(): + global x, y, z, mutex + # create a transport node + node = Node() + topic_vector3d = "/vector3d_topic" + + pub_vector3d = node.advertise(topic_vector3d, Vector3d) + + vector3d_msg = Vector3d() + vector3d_msg.x = 1 + vector3d_msg.y = 2 + vector3d_msg.z = 3 + + # subscribe to a topic by registering a callback + if node.subscribe(Vector3d, topic_vector3d, vector3_cb): + print("Subscribing to type {} on topic [{}]".format( + Vector3d, topic_vector3d)) + else: + print("Error subscribing to topic [{}]".format(topic_vector3d)) + return + + # wait for shutdown + try: + count = 1 + while True: + with mutex: + count += 1 + vector3d_msg.x = vector3d_msg.x*count + vector3d_msg.y = vector3d_msg.y*count + vector3d_msg.z = vector3d_msg.z*count + pub_vector3d.publish(vector3d_msg) + if ((vector3d_msg.x - x) != 0) or ((vector3d_msg.y - y) != 0) or ((vector3d_msg.z - z) != 0): + print("Race Condition happened") + time.sleep(0.01) + except KeyboardInterrupt: + pass + print("Done") + +if __name__ == "__main__": + main() diff --git a/python/examples/data_race_without_mutex.py b/python/examples/data_race_without_mutex.py new file mode 100644 index 000000000..d77e5ea92 --- /dev/null +++ b/python/examples/data_race_without_mutex.py @@ -0,0 +1,69 @@ +# Copyright (C) 2023 Open Source Robotics Foundation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from gz.msgs10.vector3d_pb2 import Vector3d +from gz.transport13 import Node + +import time + +x = 1 +y = 2 +z = 3 + +def vector3_cb(msg: Vector3d): + global x, y, z + x = msg.x + y = msg.y + z = msg.z + +def main(): + global x, y, z + # create a transport node + node = Node() + topic_vector3d = "/vector3d_topic" + + pub_vector3d = node.advertise(topic_vector3d, Vector3d) + + vector3d_msg = Vector3d() + vector3d_msg.x = 1 + vector3d_msg.y = 2 + vector3d_msg.z = 3 + + # subscribe to a topic by registering a callback + if node.subscribe(Vector3d, topic_vector3d, vector3_cb): + print("Subscribing to type {} on topic [{}]".format( + Vector3d, topic_vector3d)) + else: + print("Error subscribing to topic [{}]".format(topic_vector3d)) + return + + # wait for shutdown + try: + count = 1 + while True: + count += 1 + vector3d_msg.x = vector3d_msg.x*count + vector3d_msg.y = vector3d_msg.y*count + vector3d_msg.z = vector3d_msg.z*count + pub_vector3d.publish(vector3d_msg) + if ((vector3d_msg.x - x) != 0) or ((vector3d_msg.y - y) != 0) or ((vector3d_msg.z - z) != 0): + print("Race Condition happened") + time.sleep(0.01) + except KeyboardInterrupt: + pass + print("Done") + +if __name__ == "__main__": + main() diff --git a/python/examples/publisher.py b/python/examples/publisher.py new file mode 100644 index 000000000..0fdfa89eb --- /dev/null +++ b/python/examples/publisher.py @@ -0,0 +1,54 @@ +# Copyright (C) 2023 Open Source Robotics Foundation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from gz.msgs10.stringmsg_pb2 import StringMsg +from gz.msgs10.vector3d_pb2 import Vector3d +from gz.transport13 import AdvertiseMessageOptions +from gz.transport13 import Node + +import time + +def main(): + node = Node() + stringmsg_topic = "/example_stringmsg_topic" + vector3d_topic = "/example_vector3d_topic" + pub_stringmsg = node.advertise(stringmsg_topic, StringMsg) + pub_vector3d = node.advertise(vector3d_topic, Vector3d) + + vector3d_msg = Vector3d() + vector3d_msg.x = 10 + vector3d_msg.y = 15 + vector3d_msg.z = 20 + + stringmsg_msg = StringMsg() + stringmsg_msg.data = "Hello" + try: + count = 0 + while True: + count += 1 + vector3d_msg.x = count + if not (pub_stringmsg.publish(stringmsg_msg) or pub_vector3d.publish(vector3d_msg)): + break + + print("Publishing 'Hello' on topic [{}]".format(stringmsg_topic)) + print("Publishing a Vector3d on topic [{}]".format(vector3d_topic)) + time.sleep(0.1) + + except KeyboardInterrupt: + pass + + +if __name__ == "__main__": + main() diff --git a/python/examples/requester.py b/python/examples/requester.py new file mode 100644 index 000000000..9da72b394 --- /dev/null +++ b/python/examples/requester.py @@ -0,0 +1,31 @@ +# Copyright (C) 2023 Open Source Robotics Foundation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from gz.msgs10.stringmsg_pb2 import StringMsg +from gz.transport13 import Node + +def main(): + node = Node() + service_name = "/echo" + request = StringMsg() + request.data = "Hello world" + response = StringMsg() + timeout = 5000 + + result, response = node.request(service_name, request, StringMsg, StringMsg, timeout) + print("Result:", result, "\nResponse:", response.data) + +if __name__ == "__main__": + main() diff --git a/python/examples/subscriber.py b/python/examples/subscriber.py new file mode 100644 index 000000000..f5f26b142 --- /dev/null +++ b/python/examples/subscriber.py @@ -0,0 +1,60 @@ +# Copyright (C) 2023 Open Source Robotics Foundation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from gz.msgs10.stringmsg_pb2 import StringMsg +from gz.msgs10.vector3d_pb2 import Vector3d +from gz.transport13 import SubscribeOptions +from gz.transport13 import Node + +import time + +def stringmsg_cb(msg: StringMsg): + print("Received StringMsg: [{}]".format(msg.data)) + +def vector3_cb(msg: Vector3d): + print("Received Vector3: [x: {}, y: {}, z: {}]".format(msg.x, msg.y, msg.z)) + +def main(): + # create a transport node + node = Node() + topic_stringmsg = "/example_stringmsg_topic" + topic_vector3d = "/example_vector3d_topic" + + # subscribe to a topic by registering a callback + if node.subscribe(StringMsg, topic_stringmsg, stringmsg_cb): + print("Subscribing to type {} on topic [{}]".format( + StringMsg, topic_stringmsg)) + else: + print("Error subscribing to topic [{}]".format(topic_stringmsg)) + return + + # subscribe to a topic by registering a callback + if node.subscribe(Vector3d, topic_vector3d, vector3_cb): + print("Subscribing to type {} on topic [{}]".format( + Vector3d, topic_vector3d)) + else: + print("Error subscribing to topic [{}]".format(topic_vector3d)) + return + + # wait for shutdown + try: + while True: + time.sleep(0.001) + except KeyboardInterrupt: + pass + print("Done") + +if __name__ == "__main__": + main() diff --git a/python/src/__init__.py b/python/src/__init__.py new file mode 100644 index 000000000..4d007567d --- /dev/null +++ b/python/src/__init__.py @@ -0,0 +1,147 @@ +# Copyright (C) 2023 Open Source Robotics Foundation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from ._transport import Node as _Node +from ._transport import * +from typing import TypeVar, Callable + +# The "ProtoMsg" TypeVar represents an actual msg of a protobuf type. +# On the other hand, the "ProtoMsgType" TypeVar represents the protobuf type. +# For example, let's take the following code: +# from gz.msgs10.stringmsg_pb2 import StringMsg +# proto_msg = StringMsg() +# The variable `proto_msg` would be the expected input for a "ProtoMsg" +# type argument and `StringMsg` would be the expected input for a +# "ProtoMsgType" type argument. +ProtoMsg = TypeVar("ProtoMsg") +ProtoMsgType = TypeVar("ProtoMsgType") + + +class Publisher(_Node.Publisher): + """ + A wrapper class representing extends the _Node.Publisher class for + in order to publish serialized protocol buffer messages. + + """ + + def publish(self, proto_msg: ProtoMsg): + """ + Publishes a serialized protocol buffer message. + + Args: + proto_msg (ProtoMsg): The protocol buffer message to be published. + + Returns: + None + + """ + msg_string = proto_msg.SerializeToString() + msg_type = proto_msg.DESCRIPTOR.full_name + return self.publish_raw(msg_string, msg_type) + + +class Node(_Node): + """ + A wrapper class that extends the _Node class for managing + publishers, subscribers and service request. + """ + + def advertise( + self, topic: str, msg_type: ProtoMsg, options=_transport.AdvertiseMessageOptions() + ): + """ + Advertises a topic for publishing messages of a specific type. + + Args: + topic (str): The name of the topic to advertise. + msg_type (ProtoMsg): The type of the messages to be published. + options (AdvertiseMessageOptions): Options for advertising + the topic. Defaults to AdvertiseMessageOptions(). + + Returns: + Publisher: A publisher object associated with the advertised topic. + + """ + return Publisher( + _Node.advertise(self, topic, msg_type.DESCRIPTOR.full_name, + options) + ) + + def subscribe( + self, + msg_type: ProtoMsg, + topic: str, + callback: Callable, + options=_transport.SubscribeOptions(), + ): + """ + Subscribes to a topic to receive messages of a specific type and + invokes a callback function for each received message. + + Args: + msg_type (ProtoMsg): The type of the messages to subscribe to. + topic (str): The name of the topic to subscribe to. + callback (Callable): The callback function to be invoked + options (SubscribeOptions): Options for subscribing to + the topic. Defaults to SubscribeOptions(). + + Returns: + None. + + """ + + def cb_deserialize(proto_msg, msg_info): + deserialized_msg = msg_type() + deserialized_msg.ParseFromString(proto_msg) + callback(deserialized_msg) + + return self.subscribe_raw( + topic, cb_deserialize, msg_type.DESCRIPTOR.full_name, options + ) + + def request( + self, + service: str, + request: ProtoMsg, + request_type: ProtoMsgType, + response_type: ProtoMsgType, + timeout: int, + ): + """ + Sends a request of a specific type to a service and waits for a + response of a specific type. + + Args: + service (str): The name of the service to send the request to. + request (ProtoMsg): The request message to be sent. + request_type (ProtoMsgType): The type of the request. + response_type (ProtoMsgType): The expected type of the response. + timeout (int): The maximum time (in ms) to wait for a response. + + Returns: + tuple: A tuple containing the result of the request (bool) and the + deserialized response message. + + """ + result, serialized_response = self.request_raw( + service, + request.SerializeToString(), + request_type.DESCRIPTOR.full_name, + response_type.DESCRIPTOR.full_name, + timeout, + ) + deserialized_response = response_type() + deserialized_response.ParseFromString(serialized_response) + return result, deserialized_response diff --git a/python/src/transport/_gz_transport_pybind11.cc b/python/src/transport/_gz_transport_pybind11.cc new file mode 100644 index 000000000..dc582c71c --- /dev/null +++ b/python/src/transport/_gz_transport_pybind11.cc @@ -0,0 +1,329 @@ +/* + * Copyright (C) 2023 Open Source Robotics Foundation + * Copyright (C) 2022 Rhys Mainwaring + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * +*/ + +#include +#include + +#include +#include +#include + +#include +#include +#include + +namespace py = pybind11; + +namespace gz +{ +namespace transport +{ +namespace python +{ + +PYBIND11_MODULE(BINDINGS_MODULE_NAME, m) { + py::class_( + m, "AdvertiseOptions", + "A class for customizing the publication options for" + " a topic or service advertised") + .def(py::init<>()) + .def(py::init()) + .def_property("scope", + &AdvertiseOptions::Scope, + &AdvertiseOptions::SetScope, + "The scope used in this topic/service") + .def("__copy__", [](const AdvertiseOptions &self) + { + return AdvertiseOptions(self); + }) + .def("__deepcopy__",[](const AdvertiseOptions &self, pybind11::dict) + { + return AdvertiseOptions(self); + }); + + py::class_( + m, "AdvertiseMessageOptions", + "A class for customizing the publication options for a topic") + .def(py::init<>()) + .def(py::init()) + .def_property_readonly("throttled", + &AdvertiseMessageOptions::Throttled, + "Whether the publication has been throttled") + .def_property("msgs_per_sec", + &AdvertiseMessageOptions::MsgsPerSec, + &AdvertiseMessageOptions::SetMsgsPerSec, + "The maximum number of messages per second to be published") + .def("__copy__", + [](const AdvertiseMessageOptions &self) + { + return AdvertiseMessageOptions(self); + }) + .def("__deepcopy__", + [](const AdvertiseMessageOptions &self, pybind11::dict) + { + return AdvertiseMessageOptions(self); + }); + + py::class_( + m, "NodeOptions", + "A class to provide different options for the node") + .def(py::init<>()) + .def(py::init()) + .def_property("namespace", + &NodeOptions::NameSpace, + &NodeOptions::SetNameSpace, + "Set the node's namespace.") + .def_property("partition", + &NodeOptions::Partition, + &NodeOptions::SetPartition, + "Set the node's partition name.") + .def("add_topic_remap", + &NodeOptions::AddTopicRemap, + py::arg("from_topic"), + py::arg("to_topic"), + "Add a new topic remapping. If a topic is remapped, the 'from_topic'" + "topic will be renamed to 'to_topic. Is not possible to add two remaps" + "over the same 'from_topic'.") + .def("topic_remap",[]( + NodeOptions &self, + const std::string &fromTopic) + { + std::string toTopic; + bool result{false}; + result = self.TopicRemap(fromTopic, toTopic); + return py::make_tuple(result, toTopic); + }, + py::arg("from_topic"), + "Get a topic remapping. Returns a pair with the result of the method" + "and the remapped name of the topic. The topic name remains empty if" + "there isn't any remap for the topic") + .def("__copy__", + [](const NodeOptions &self) + { + return NodeOptions(self); + }) + .def("__deepcopy__", + [](const NodeOptions &self, pybind11::dict) + { + return NodeOptions(self); + }); + + py::class_( + m, "SubscribeOptions", + "A class to provide different options for a subscription") + .def(py::init<>()) + .def(py::init()) + .def_property_readonly("throttled", + &SubscribeOptions::Throttled, + "Whether the subscription has been throttled") + .def_property("msgs_per_sec", + &SubscribeOptions::MsgsPerSec, + &SubscribeOptions::SetMsgsPerSec, + "Set the maximum number of messages per second received per topic") + .def("__copy__", + [](const SubscribeOptions &self) + { + return SubscribeOptions(self); + }) + .def("__deepcopy__", + [](const SubscribeOptions &self, pybind11::dict) + { + return SubscribeOptions(self); + }); + + // We are leaving this as an opaque class in order to be able to add bindings + // to it later without breaking ABI. + py::class_( + m, "MessageInfo", + "A class that provides information about the message received.") + .def(py::init<>()); + + py::class_( + m, "MessagePublisher", + "This class stores all the information about a message publisher.") + .def(py::init<>()) + .def_property_readonly("ctrl", + &MessagePublisher::Ctrl, + "Get the ZeroMQ control address of the publisher.") + .def_property_readonly("msg_type_name", + &MessagePublisher::MsgTypeName, + "Get the message type advertised by this publisher.") + .def_property_readonly("options", + &MessagePublisher::Options, + "Get the advertised options."); + + py::class_( + m, "ServicePublisher", + "This class stores all the information about a service publisher.") + .def(py::init<>()) + .def_property_readonly("socket_id", + &ServicePublisher::SocketId, + "Get the ZeroMQ socket ID for this publisher.") + .def_property_readonly("req_type_name", + &ServicePublisher::ReqTypeName, + "Get the name of the request's protobuf message advertised.") + .def_property_readonly("rep_type_name", + &ServicePublisher::RepTypeName, + "Get the advertised options.") + .def_property_readonly("options", + &ServicePublisher::Options, + "Get the advertised options."); + + py::class_( + m, "TopicStatistics", + "This class encapsulates statistics for a single topic..") + .def(py::init<>()); + + auto node = py::class_(m, "Node", + "A class that allows a client to communicate with other peers." + " There are two main communication modes: pub/sub messages" + " and service calls") + .def(py::init<>()) + .def(py::init()) + .def("advertise", static_cast< + Node::Publisher (Node::*) ( + const std::string &, + const std::string &, + const AdvertiseMessageOptions & + )>(&Node::Advertise), + py::arg("topic"), + py::arg("msg_type_name"), + py::arg("options"), + "Advertise a new topic. If a topic is currently advertised," + " you cannot advertise it a second time (regardless of its type)") + .def("advertised_topics", &Node::AdvertisedTopics, + "Get the list of topics advertised by this node") + .def("subscribed_topics", &Node::SubscribedTopics, + "Get the list of topics subscribed by this node") + .def("unsubscribe", &Node::Unsubscribe, + py::arg("topic"), + "Unsubscribe from a topic") + // Send a service request using the blocking interface + .def("request_raw", []( + Node &_node, + const std::string &_service, + const std::string &_request, + const std::string &_reqType, + const std::string &_repType, + const unsigned int &_timeout) + { + bool result{false}; + std::string _response; + result = _node.RequestRaw(_service, _request, _reqType, + _repType, _timeout, _response, result); + return py::make_tuple(result, py::bytes(_response.c_str(), _response.size())); + }, + py::arg("topic"), + py::arg("request"), + py::arg("request_type"), + py::arg("response_type"), + py::arg("timeout"), + "Request a new service without input parameter using" + " a blocking call") + .def("topic_list", []( + Node &_node) + { + std::vector topics; + _node.TopicList(topics); + return topics; + }, + "Get the list of topics currently advertised in the network") + .def("topic_info", []( + Node &_node, + const std::string &_topic) + { + std::vector publishers; + std::vector subscribers; + _node.TopicInfo(_topic, publishers, subscribers); + return py::make_tuple(publishers, subscribers); + }, + py::arg("topic"), + "Get the information about a topic") + .def("service_list", []( + Node &_node) + { + std::vector services; + _node.ServiceList(services); + return services; + }, + "Get the list of topics currently advertised in the network") + .def("service_info", []( + Node &_node, + const std::string &_service) + { + std::vector publishers; + _node.ServiceInfo(_service, publishers); + return publishers; + }, + py::arg("service"), + "Get the information about a service") + .def("subscribe_raw", []( + Node &_node, + const std::string &_topic, + std::function &_callback, + const std::string &_msgType, + const SubscribeOptions &_opts) + { + auto _cb = [_callback](const char *_msgData, const size_t _size, + const MessageInfo &_info){ + return _callback(py::bytes(_msgData, _size), _info); + }; + return _node.SubscribeRaw(_topic, _cb, _msgType, _opts); + }, + py::arg("topic"), + py::arg("callback"), + py::arg("msg_type"), + py::arg("options")) + .def_property_readonly("options", &Node::Options, + "Get the reference to the current node options.") + .def("enable_stats", &Node::EnableStats, + py::arg("topic"), + py::arg("enable"), + py::arg("publication_topic") = "/statistics", + py::arg("publication_rate") = 1, + "Turn topic statistics on or off.") + .def("topic_stats", &Node::TopicStats, + py::arg("topic"), + "Get the current statistics for a topic. Statistics must" + "have been enabled using the EnableStats function, otherwise" + "the return value will be null."); + + // Register Node::Publisher as a subclass of Node + py::class_(node, "Publisher", + "A class that is used to store information about an" + " advertised publisher.") + .def(py::init<>()) + .def(py::init()) + .def("valid", &gz::transport::Node::Publisher::Valid, + "Return true if valid information, such as a non-empty" + " topic name, is present.") + .def("publish_raw", &gz::transport::Node::Publisher::PublishRaw, + py::arg("msg_data"), + py::arg("msg_type")) + .def("throttled_update_ready", + &gz::transport::Node::Publisher::ThrottledUpdateReady, + "") + .def("has_connections", + &gz::transport::Node::Publisher::HasConnections, + "Return true if this publisher has subscribers"); +} // gz-transport13 module + +} // python +} // transport +} // gz diff --git a/python/test/options_TEST.py b/python/test/options_TEST.py new file mode 100644 index 000000000..9b2530a90 --- /dev/null +++ b/python/test/options_TEST.py @@ -0,0 +1,69 @@ +from gz.msgs10.stringmsg_pb2 import StringMsg +from gz.transport13 import Node, AdvertiseMessageOptions, SubscribeOptions, NodeOptions + +import unittest + + +class OptionsTEST(unittest.TestCase): + def stringmsg_cb(self, msg: StringMsg): + _ = msg.data + + def test_advertise_message_options(self): + opts = AdvertiseMessageOptions() + msgs_per_sec = 10 + self.assertFalse(opts.throttled) + self.assertNotEqual(opts.msgs_per_sec, msgs_per_sec) + opts.msgs_per_sec = msgs_per_sec + self.assertEqual(opts.msgs_per_sec, msgs_per_sec) + self.assertTrue(opts.throttled) + + node = Node() + pub = node.advertise("/test_topic", StringMsg, opts) + self.assertTrue(pub) + + def test_subscribe_options(self): + opts = SubscribeOptions() + msgs_per_sec = 10 + self.assertFalse(opts.throttled) + self.assertNotEqual(opts.msgs_per_sec, msgs_per_sec) + opts.msgs_per_sec = msgs_per_sec + self.assertEqual(opts.msgs_per_sec, msgs_per_sec) + self.assertTrue(opts.throttled) + + node = Node() + self.assertTrue( + node.subscribe(StringMsg, "/test_topic", self.stringmsg_cb, opts) + ) + + def test_node_options(self): + opts = NodeOptions() + + namespace = "test_namespace" + self.assertNotEqual(opts.namespace, namespace) + opts.namespace = namespace + self.assertEqual(opts.namespace, namespace) + + partition = "test_partition" + self.assertNotEqual(opts.partition, partition) + opts.partition = partition + self.assertEqual(opts.partition, partition) + + from_topic = "test_topic" + to_topic = "test_topic_remapped" + + result, topic_remapped = opts.topic_remap(from_topic) + self.assertFalse(result) + self.assertNotEqual(topic_remapped, to_topic) + self.assertEqual(topic_remapped, "") + self.assertTrue(opts.add_topic_remap(from_topic, to_topic)) + result, topic_remapped = opts.topic_remap(from_topic) + self.assertTrue(result) + self.assertEqual(topic_remapped, to_topic) + + node = Node(opts) + opts_from_node = node.options + self.assertEqual(opts.namespace, opts_from_node.namespace) + self.assertEqual(opts.partition, opts_from_node.partition) + result, topic_remapped = opts_from_node.topic_remap(from_topic) + self.assertTrue(result) + self.assertEqual(topic_remapped, to_topic) diff --git a/python/test/pubSub_TEST.py b/python/test/pubSub_TEST.py new file mode 100644 index 000000000..a44278327 --- /dev/null +++ b/python/test/pubSub_TEST.py @@ -0,0 +1,214 @@ +# Copyright (C) 2023 Open Source Robotics Foundation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from gz.msgs10.stringmsg_pb2 import StringMsg +from gz.msgs10.vector3d_pb2 import Vector3d +from gz.transport13 import Node, AdvertiseMessageOptions, SubscribeOptions, TopicStatistics + +from threading import Lock + +import time +import unittest + +mutex = Lock() + + +class PubSubTEST(unittest.TestCase): + def vector3_cb(self, msg: Vector3d): + with mutex: + self.received_msg = msg.x + + def stringmsg_cb(self, msg: StringMsg): + with mutex: + self.msg_counter += 1 + self.received_msg = msg.data + + def setUp(self): + # Publisher set up + self.pub_node = Node() + self.vector3d_topic = "/test_vector3d" + self.pub = self.pub_node.advertise(self.vector3d_topic, Vector3d) + self.assertTrue(self.pub) + self.assertFalse(self.pub.has_connections()) + + self.vector3d_msg = Vector3d() + self.vector3d_msg.x = 10 + + def tearDown(self): + del self.pub, self.pub_node + + # Check that the publisher publishes a message of the appropiate type + # but doesn't publish when the message is not the appropiate type. + def test_publish_msg(self): + string_msg = StringMsg() + string_msg.data = "Hello" + self.assertTrue(self.pub.publish(self.vector3d_msg)) + self.assertFalse(self.pub.publish(string_msg)) + + # Checks the `advertised_topic` method. + def test_advertised_topics(self): + advertised_topics = self.pub_node.advertised_topics() + self.assertEqual(len(advertised_topics), 1) + self.assertEqual(advertised_topics[0], self.vector3d_topic) + + # Checks the `subscribed_topics` method + def test_subscribed_topics(self): + # Subscriber set up + sub_node = Node() + subscribed_topics = sub_node.subscribed_topics() + self.assertEqual(len(subscribed_topics), 0) + self.assertTrue( + sub_node.subscribe(Vector3d, self.vector3d_topic, self.vector3_cb) + ) + subscribed_topics = sub_node.subscribed_topics() + self.assertEqual(len(subscribed_topics), 1) + self.assertEqual(subscribed_topics[0], self.vector3d_topic) + + # Check that a message is received if the callback does not use the + # advertised types. + def test_msg_callback(self): + # Subscriber set up + sub_node = Node() + self.assertTrue( + sub_node.subscribe(Vector3d, self.vector3d_topic, self.vector3_cb) + ) + self.assertTrue(self.pub.has_connections()) + + # Publish and expect callback + self.received_msg = 0 + self.assertEqual(self.received_msg, 0) + self.assertTrue(self.pub.publish(self.vector3d_msg)) + time.sleep(0.5) + with mutex: + self.assertEqual(self.received_msg, self.vector3d_msg.x) + self.assertTrue(sub_node.unsubscribe(self.vector3d_topic)) + self.assertFalse(self.pub.has_connections()) + + # Check that a message is not received if the callback does not use + # the advertised types. + def test_wrong_msg_type_callback(self): + # Subscriber set up + sub_node = Node() + self.assertTrue( + sub_node.subscribe(StringMsg, self.vector3d_topic, self.stringmsg_cb) + ) + self.received_msg = 0 + self.assertFalse(self.pub.has_connections()) + self.assertEqual(self.received_msg, 0) + self.assertTrue(self.pub.publish(self.vector3d_msg)) + time.sleep(0.5) + with mutex: + self.assertNotEqual(self.received_msg, self.vector3d_msg.x) + self.assertTrue(sub_node.unsubscribe(self.vector3d_topic)) + self.assertFalse(self.pub.has_connections()) + + # Checks the functioning of a publisher that is throttled + def test_pub_throttle(self): + # Throttle Publisher set up + pub_node = Node() + throttle_topic = "/test_throttle_topic" + opts = AdvertiseMessageOptions() + opts.msgs_per_sec = 1 + pub_throttle = pub_node.advertise(throttle_topic, StringMsg, opts) + self.assertTrue(pub_throttle) + self.assertFalse(pub_throttle.has_connections()) + msg = StringMsg() + msg.data = "Hello" + + # Subscriber set up + sub_node = Node() + self.assertTrue( + sub_node.subscribe(StringMsg, throttle_topic, self.stringmsg_cb) + ) + self.msg_counter = 0 + self.assertTrue(pub_throttle.has_connections()) + self.assertEqual(self.msg_counter, 0) + # Publish 25 messages in 2.5s + for _ in range(25): + self.assertTrue(pub_throttle.publish(msg)) + time.sleep(0.1) + with mutex: + self.assertEqual(self.msg_counter, 3) + self.assertTrue(sub_node.unsubscribe(throttle_topic)) + self.assertFalse(pub_throttle.has_connections()) + + # Checks the functioning of a subscriber that is throttled. + def test_sub_throttle(self): + # Publisher set up + pub_node = Node() + throttle_topic = "/test_throttle_topic" + pub = pub_node.advertise(throttle_topic, StringMsg) + self.assertTrue(pub) + self.assertFalse(pub.has_connections()) + msg = StringMsg() + msg.data = "Hello" + + # Subscriber set up + sub_node = Node() + opts = SubscribeOptions() + opts.msgs_per_sec = 1 + self.assertTrue( + sub_node.subscribe(StringMsg, throttle_topic, self.stringmsg_cb, opts) + ) + self.msg_counter = 0 + self.assertTrue(pub.has_connections()) + self.assertEqual(self.msg_counter, 0) + # Publish 25 messages in 2.5s + for _ in range(25): + self.assertTrue(pub.publish(msg)) + time.sleep(0.1) + with mutex: + self.assertEqual(self.msg_counter, 3) + self.assertTrue(sub_node.unsubscribe(throttle_topic)) + self.assertFalse(pub.has_connections()) + + # Checks that the node is able to retrieve the list of topics. + def test_topic_list(self): + # Second Publisher set up + pub_node = Node() + string_msg_topic = "/test_stringmsg_topic" + pub_2 = pub_node.advertise(string_msg_topic, StringMsg) + self.assertTrue(pub_2) + self.assertTrue(pub_2.valid()) + self.assertFalse(pub_2.has_connections()) + + # Node set up + node = Node() + topics = node.topic_list() + self.assertTrue(topics) + self.assertEqual(len(topics), 2) + # Check alphabetical order of the list of topics + self.assertEqual(topics[0], string_msg_topic) + + # Checks that the node is able to retrieve the information of a topic. + def test_topic_info(self): + # Node set up + node = Node() + topic_info = node.topic_info('/topic_no_publisher') + self.assertEqual(len(topic_info[0]), 0) + topic_info = node.topic_info(self.vector3d_topic) + self.assertEqual(len(topic_info[0]), 1) + self.assertEqual(topic_info[0][0].msg_type_name, 'gz.msgs.Vector3d') + + # Checks that the methods to enable a topic statistics and get those stats + # are working as expected. + def test_topic_stats(self): + self.assertEqual(len(self.pub_node.topic_info('/statistics')[0]), 0) + self.assertTrue(self.pub_node.enable_stats(self.vector3d_topic, True, '/statistics', 1)) + self.assertEqual(len(self.pub_node.topic_info('/statistics')[0]), 1) + # Currently, within process subscribers and publishers won't have statistics. + topic_stats = self.pub_node.topic_stats(self.vector3d_topic) + self.assertEqual(topic_stats, None) + self.assertTrue(self.pub_node.enable_stats(self.vector3d_topic, False)) diff --git a/python/test/requester_TEST.py b/python/test/requester_TEST.py new file mode 100644 index 000000000..5523dcfa1 --- /dev/null +++ b/python/test/requester_TEST.py @@ -0,0 +1,86 @@ +# Copyright (C) 2023 Open Source Robotics Foundation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from gz.msgs10.int32_pb2 import Int32 +from gz.msgs10.stringmsg_pb2 import StringMsg +from gz.transport13 import Node + +import os +import subprocess +import unittest + + +class RequesterTEST(unittest.TestCase): + def setUp(self): + # Environment Setup + gz_partition = "python_requester_test" + os.environ["GZ_PARTITION"] = gz_partition + + # Subprocess Setup + cmd = f"{os.getenv('CMAKE_BINARY_DIR')}/INTEGRATION_twoProcsSrvCallReplier_aux {gz_partition}" + self.service_process = subprocess.Popen(cmd, shell=True) + + # Requester Setup + self.node = Node() + self.service_name = "/foo" + self.request = Int32() + self.request.data = 100 + self.timeout = 2000 + + def tearDown(self): + self.service_process.kill() + + # Checks that the node is able to create a service request. + def test_request(self): + result, response = self.node.request( + self.service_name, self.request, Int32, Int32, self.timeout + ) + self.assertTrue(result) + self.assertEqual(response.data, self.request.data) + + # Checks that the node is unable to create a service request if the types + # of the request and response are wrong. + def test_wrong_type(self): + result, response = self.node.request( + self.service_name, self.request, StringMsg, Int32, self.timeout + ) + self.assertFalse(result) + self.assertNotEqual(response.data, self.request.data) + + result, response = self.node.request( + self.service_name, self.request, Int32, StringMsg, self.timeout + ) + self.assertFalse(result) + self.assertNotEqual(response.data, self.request.data) + + result, response = self.node.request( + self.service_name, self.request, StringMsg, StringMsg, self.timeout + ) + self.assertFalse(result) + self.assertNotEqual(response.data, self.request.data) + + # Checks that the node is able to retrieve the list of services. + def test_service_list(self): + services = self.node.service_list() + self.assertTrue(services) + self.assertEqual(len(services), 1) + + # Checks that the node is able to retrieve the information of a service. + def test_service_info(self): + service_info_list = self.node.service_info('/service_no_responser') + self.assertEqual(len(service_info_list), 0) + service_info_list = self.node.service_info(self.service_name) + self.assertEqual(service_info_list[0].req_type_name, 'gz.msgs.Int32') + self.assertEqual(service_info_list[0].rep_type_name, 'gz.msgs.Int32')