diff --git a/ros2pkg/ros2pkg/api/create.py b/ros2pkg/ros2pkg/api/create.py index 7b152ce5d..dc4a5ba3b 100644 --- a/ros2pkg/ros2pkg/api/create.py +++ b/ros2pkg/ros2pkg/api/create.py @@ -106,6 +106,8 @@ def create_package_environment(package, destination_directory): 'package_license': package.licenses[0], 'buildtool_dependencies': package.buildtool_depends, 'dependencies': package.build_depends, + 'exec_dependencies': package.exec_depends, + 'member_of_group': package.member_of_groups, 'test_dependencies': package.test_depends, 'exports': package.exports, } @@ -259,12 +261,14 @@ def populate_cmake(package, package_directory, cpp_node_name, cpp_library_name): version_config) -def populate_ament_cmake(package, package_directory, cpp_node_name, cpp_library_name): +def populate_ament_cmake(package, package_directory, cpp_node_name, cpp_library_name, + message_names=None): cmakelists_config = { 'project_name': package.name, 'dependencies': [str(dep) for dep in package.build_depends], 'cpp_node_name': cpp_node_name, 'cpp_library_name': cpp_library_name, + 'message_names': message_names or [], } _create_template_file( 'ament_cmake', @@ -355,3 +359,20 @@ def populate_rust_node(package, source_directory, node_name): source_directory, 'main.rs', cargo_node_config) + + +def populate_messages(package_directory: str, message_names: list) -> None: + """ + Create stub message files in the msg/ subdirectory of the package. + + :param package_directory: path to the package directory. + :param message_names: list of message names to create. + """ + msg_directory = _create_folder('msg', package_directory) + for msg_name in message_names: + _create_template_file( + 'msg', + 'message.msg.em', + msg_directory, + msg_name + '.msg', + {}) diff --git a/ros2pkg/ros2pkg/resource/ament_cmake/CMakeLists.txt.em b/ros2pkg/ros2pkg/resource/ament_cmake/CMakeLists.txt.em index 663c2f81a..7fff33ac9 100644 --- a/ros2pkg/ros2pkg/resource/ament_cmake/CMakeLists.txt.em +++ b/ros2pkg/ros2pkg/resource/ament_cmake/CMakeLists.txt.em @@ -10,6 +10,9 @@ find_package(ament_cmake REQUIRED) @[if cpp_library_name]@ find_package(ament_cmake_ros REQUIRED) @[end if]@ +@[if message_names]@ +find_package(rosidl_default_generators REQUIRED) +@[end if]@ @[if dependencies]@ @[ for dep in dependencies]@ find_package(@dep REQUIRED) @@ -19,6 +22,19 @@ find_package(@dep REQUIRED) # further dependencies manually. # find_package( REQUIRED) @[end if]@ +@[if message_names]@ + +# do not forget to find_package all dependencies of your custom messages +# (if they were not already found earlier) +# find_package( REQUIRED) + +rosidl_generate_interfaces(${PROJECT_NAME} +@[ for msg in message_names]@ + "msg/@(msg).msg" +@[ end for]@ + DEPENDENCIES # list all package dependencies of your messages +) +@[end if]@ @[if cpp_library_name]@ add_library(@(cpp_library_name) src/@(cpp_library_name).cpp) diff --git a/ros2pkg/ros2pkg/resource/msg/__init__.py b/ros2pkg/ros2pkg/resource/msg/__init__.py new file mode 100644 index 000000000..e50fad00b --- /dev/null +++ b/ros2pkg/ros2pkg/resource/msg/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2026 Leander Stephen Desouza +# +# 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. diff --git a/ros2pkg/ros2pkg/resource/msg/message.msg.em b/ros2pkg/ros2pkg/resource/msg/message.msg.em new file mode 100644 index 000000000..e5daa63ba --- /dev/null +++ b/ros2pkg/ros2pkg/resource/msg/message.msg.em @@ -0,0 +1,6 @@ +# TODO: Define the message fields here. +# For example: +# bool my_flag +# string my_string +# int32 my_number +# if you use a non-trivial message type, do not forget to add its package to package.xml and CMakeLists.txt diff --git a/ros2pkg/ros2pkg/resource/package_environment/package.xml.em b/ros2pkg/ros2pkg/resource/package_environment/package.xml.em index 93002aa97..0dceda250 100644 --- a/ros2pkg/ros2pkg/resource/package_environment/package.xml.em +++ b/ros2pkg/ros2pkg/resource/package_environment/package.xml.em @@ -18,12 +18,24 @@ @dep @[ end for]@ +@[end if]@ +@[if exec_dependencies]@ +@[ for dep in exec_dependencies]@ + @dep +@[ end for]@ + @[end if]@ @[if test_dependencies]@ @[ for dep in test_dependencies]@ @dep @[ end for]@ +@[end if]@ +@[if member_of_group]@ +@[ for group in member_of_group]@ + @group +@[ end for]@ + @[end if]@ @[if exports]@ diff --git a/ros2pkg/ros2pkg/verb/create.py b/ros2pkg/ros2pkg/verb/create.py index 8832af5e9..54be1872c 100644 --- a/ros2pkg/ros2pkg/verb/create.py +++ b/ros2pkg/ros2pkg/verb/create.py @@ -14,6 +14,7 @@ import getpass import os +import re import shutil import subprocess import sys @@ -33,6 +34,7 @@ from ros2pkg.api.create import populate_cmake from ros2pkg.api.create import populate_cpp_library from ros2pkg.api.create import populate_cpp_node +from ros2pkg.api.create import populate_messages from ros2pkg.api.create import populate_python_libary from ros2pkg.api.create import populate_python_node from ros2pkg.api.create import populate_rust_node @@ -90,6 +92,12 @@ def add_arguments(self, parser, cli_name): parser.add_argument( '--library-name', help='name of the empty library') + parser.add_argument( + '--message', + nargs='+', + default=[], + metavar='MESSAGE_NAME', + help='name(s) of message files to create in msg/') def main(self, *, args): @@ -136,12 +144,34 @@ def get_git_config(key: str) -> Optional[str]: print('[WARNING] node name can not be equal to the library name', file=sys.stderr) print('[WARNING] renaming node to %s' % node_name, file=sys.stderr) + if args.message: + if args.build_type != 'ament_cmake': + return "Aborted: --message is only supported with 'ament_cmake' build type." + + invalid = [ + name for name in args.message + if not re.match(r'^[A-Z][A-Za-z0-9]+$', name) + ] + if invalid: + return 'Aborted: invalid message name(s): ' + ', '.join(invalid) + \ + '. Message names must be CamelCase and alphanumeric (e.g. MyMsg).' + + if len(set(args.message)) != len(args.message): + duplicates = { + name for name in args.message + if args.message.count(name) > 1 + } + return 'Aborted: duplicate message name(s): ' + \ + ', '.join(sorted(duplicates)) + buildtool_depends = [] if args.build_type == 'ament_cmake': if args.library_name: buildtool_depends = ['ament_cmake_ros'] else: buildtool_depends = ['ament_cmake'] + if args.message: + buildtool_depends.append('rosidl_default_generators') if args.build_type == 'ament_cargo': buildtool_depends = ['ament_cargo'] @@ -153,6 +183,12 @@ def get_git_config(key: str) -> Optional[str]: test_dependencies = ['ament_copyright', 'ament_flake8', 'ament_mypy', 'ament_pep257', 'ament_xmllint', 'python3-pytest'] + member_of_group_depends = [] + exec_depends = [] + if args.message: + member_of_group_depends.append('rosidl_interface_packages') + exec_depends.append('rosidl_default_runtime') + if args.build_type == 'ament_python' and args.package_name == 'test': # If the package name is 'test', there will be a conflict between # the directory the source code for the package goes in and the @@ -169,6 +205,8 @@ def get_git_config(key: str) -> Optional[str]: licenses=[args.license], buildtool_depends=[Dependency(dep) for dep in buildtool_depends], build_depends=[Dependency(dep) for dep in args.dependencies], + exec_depends=[Dependency(dep) for dep in exec_depends], + member_of_groups=member_of_group_depends, test_depends=[Dependency(dep) for dep in test_dependencies], exports=[Export('build_type', content=args.build_type)] ) @@ -192,6 +230,8 @@ def get_git_config(key: str) -> Optional[str]: print('node_name:', node_name) if library_name: print('library_name:', library_name) + if args.message: + print('messages:', args.message) package_directory, source_directory, include_directory = \ create_package_environment(package, args.destination_directory) @@ -202,7 +242,8 @@ def get_git_config(key: str) -> Optional[str]: populate_cmake(package, package_directory, node_name, library_name) if args.build_type == 'ament_cmake': - populate_ament_cmake(package, package_directory, node_name, library_name) + populate_ament_cmake(package, package_directory, node_name, library_name, + message_names=args.message) if args.build_type == 'ament_cargo': populate_ament_cargo(package, package_directory, library_name) @@ -239,6 +280,9 @@ def get_git_config(key: str) -> Optional[str]: node_name ) + if args.message: + populate_messages(package_directory, args.message) + if args.license in available_licenses: with open(os.path.join(package_directory, 'LICENSE'), 'w') as outfp: for lic in available_licenses[args.license]: diff --git a/ros2pkg/test/test_cli.py b/ros2pkg/test/test_cli.py index bdb063bce..56cc2ae67 100644 --- a/ros2pkg/test/test_cli.py +++ b/ros2pkg/test/test_cli.py @@ -132,7 +132,8 @@ def test_create_package(self): '--maintainer-email', 'nobody@nowhere.com', '--maintainer-name', 'Nobody', '--node-name', 'test_node', - '--library-name', 'test_library' + '--library-name', 'test_library', + '--message', 'MyMsg', 'OtherMsg', ], cwd=tmpdir ) as pkg_command: assert pkg_command.wait_for_shutdown(timeout=5) @@ -151,6 +152,7 @@ def test_create_package(self): "dependencies: ['ros2pkg']", 'node_name: test_node', 'library_name: test_library', + "messages: ['MyMsg', 'OtherMsg']", 'creating folder ' + os.path.join('.', 'a_test_package'), 'creating ' + os.path.join('.', 'a_test_package', 'package.xml'), 'creating source and include folder', @@ -171,29 +173,31 @@ def test_create_package(self): 'creating ' + os.path.join( '.', 'a_test_package', 'include', 'a_test_package', 'visibility_control.h' ), + 'creating folder ' + os.path.join('.', 'a_test_package', 'msg'), + 'creating ' + os.path.join('.', 'a_test_package', 'msg', 'MyMsg.msg'), + 'creating ' + os.path.join('.', 'a_test_package', 'msg', 'OtherMsg.msg'), ], text=pkg_command.output, strict=True ) # Check layout - assert os.path.isdir(os.path.join(tmpdir, 'a_test_package')) - assert os.path.isfile(os.path.join(tmpdir, 'a_test_package', 'package.xml')) - assert os.path.isfile(os.path.join(tmpdir, 'a_test_package', 'CMakeLists.txt')) - assert os.path.isfile(os.path.join(tmpdir, 'a_test_package', 'LICENSE')) - assert os.path.isfile( - os.path.join(tmpdir, 'a_test_package', 'src', 'test_node.cpp') - ) - assert os.path.isfile( - os.path.join(tmpdir, 'a_test_package', 'src', 'test_library.cpp') - ) + pkg_dir = os.path.join(tmpdir, 'a_test_package') + assert os.path.isdir(pkg_dir) + assert os.path.isfile(os.path.join(pkg_dir, 'package.xml')) + assert os.path.isfile(os.path.join(pkg_dir, 'CMakeLists.txt')) + assert os.path.isfile(os.path.join(pkg_dir, 'LICENSE')) + assert os.path.isfile(os.path.join(pkg_dir, 'src', 'test_node.cpp')) + assert os.path.isfile(os.path.join(pkg_dir, 'src', 'test_library.cpp')) assert os.path.isfile(os.path.join( - tmpdir, 'a_test_package', 'include', 'a_test_package', 'test_library.hpp' + pkg_dir, 'include', 'a_test_package', 'test_library.hpp' )) assert os.path.isfile(os.path.join( - tmpdir, 'a_test_package', 'include', 'a_test_package', 'visibility_control.h' + pkg_dir, 'include', 'a_test_package', 'visibility_control.h' )) + assert os.path.isfile(os.path.join(pkg_dir, 'msg', 'MyMsg.msg')) + assert os.path.isfile(os.path.join(pkg_dir, 'msg', 'OtherMsg.msg')) # Check package.xml - tree = ET.parse(os.path.join(tmpdir, 'a_test_package', 'package.xml')) + tree = ET.parse(os.path.join(pkg_dir, 'package.xml')) root = tree.getroot() assert root.tag == 'package' assert root.attrib['format'] == '3' @@ -204,3 +208,18 @@ def test_create_package(self): assert root.find('license').text == 'Apache-2.0' assert root.find('depend').text == 'ros2pkg' assert root.find('.//build_type').text == 'ament_cmake' + + # Check rosidl message dependencies + buildtool_deps = [e.text for e in root.findall('buildtool_depend')] + assert 'rosidl_default_generators' in buildtool_deps + exec_deps = [e.text for e in root.findall('exec_depend')] + assert 'rosidl_default_runtime' in exec_deps + assert root.find('member_of_group').text == 'rosidl_interface_packages' + + # Check CMakeLists.txt + with open(os.path.join(pkg_dir, 'CMakeLists.txt'), 'r', encoding='utf-8') as f: + cmake_content = f.read() + assert 'find_package(rosidl_default_generators REQUIRED)' in cmake_content + assert 'rosidl_generate_interfaces(${PROJECT_NAME}' in cmake_content + assert '"msg/MyMsg.msg"' in cmake_content + assert '"msg/OtherMsg.msg"' in cmake_content