├── .gitignore ├── .gitattributes ├── doc ├── logo.png ├── rosmsg0.png └── rosmsg1.png ├── src └── ros_command │ ├── __init__.py │ ├── commands │ ├── get_ros_command_bash.py │ ├── rosdep_install.py │ ├── get_current_setup_bash.py │ ├── roslaunch.py │ ├── rosrun.py │ ├── get_ros_directory.py │ ├── rosexecute.py │ ├── rosbuild.py │ ├── rosclean.py │ ├── rostopic.py │ └── rosinterface.py │ ├── util.py │ ├── ros_command_setup.bash │ ├── command_lib.py │ ├── packages.py │ ├── build_status_display.py │ ├── completion.py │ ├── terminal_display.py │ └── build_tool.py ├── setup.cfg ├── .git_archival.txt ├── .github └── workflows │ └── publish_pip.yaml ├── .pre-commit-config.yaml ├── LICENSE ├── pyproject.toml └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | src/ros_command/_version.py 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | .git_archival.txt export-subst 2 | -------------------------------------------------------------------------------- /doc/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MetroRobots/ros_command/HEAD/doc/logo.png -------------------------------------------------------------------------------- /src/ros_command/__init__.py: -------------------------------------------------------------------------------- 1 | """ros_command: Unifying the ROS command line tools""" 2 | -------------------------------------------------------------------------------- /doc/rosmsg0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MetroRobots/ros_command/HEAD/doc/rosmsg0.png -------------------------------------------------------------------------------- /doc/rosmsg1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MetroRobots/ros_command/HEAD/doc/rosmsg1.png -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [pep8] 2 | max_line_length = 120 3 | 4 | [flake8] 5 | max_line_length = 120 6 | -------------------------------------------------------------------------------- /.git_archival.txt: -------------------------------------------------------------------------------- 1 | node: 26dee917396ebccf6f6902d830bb17d7af41a5b8 2 | node-date: 2025-10-07T10:50:08-04:00 3 | describe-name: v0.1.0-9-g26dee91 4 | ref-names: HEAD -> main 5 | -------------------------------------------------------------------------------- /src/ros_command/commands/get_ros_command_bash.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | 3 | 4 | def main(): 5 | this_file = pathlib.Path(__file__) 6 | expected_dir = this_file.parent.parent 7 | bash = expected_dir / 'ros_command_setup.bash' 8 | print(bash) 9 | -------------------------------------------------------------------------------- /src/ros_command/commands/rosdep_install.py: -------------------------------------------------------------------------------- 1 | import argcomplete 2 | import argparse 3 | import asyncio 4 | import pathlib 5 | 6 | from ros_command.command_lib import run 7 | 8 | 9 | async def main(debug=False): 10 | parser = argparse.ArgumentParser() 11 | parser.add_argument('path', type=pathlib.Path, default='.', nargs='?') 12 | 13 | argcomplete.autocomplete(parser) 14 | 15 | args, unknown_args = parser.parse_known_args() 16 | 17 | command = ['rosdep', 'install', '--ignore-src', '-y', '-r', '--from-paths', str(args.path)] 18 | 19 | code = await run(command + unknown_args) 20 | exit(code) 21 | 22 | 23 | def main_rosdep(): 24 | loop = asyncio.new_event_loop() 25 | asyncio.set_event_loop(loop) 26 | loop.run_until_complete(main()) 27 | -------------------------------------------------------------------------------- /src/ros_command/commands/get_current_setup_bash.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from betsy_ros import BuildType, get_workspace_root 4 | 5 | 6 | def main(): 7 | build_type, workspace_root = get_workspace_root() 8 | if workspace_root is None: 9 | print('Cannot find your ROS workspace!', file=sys.stderr) 10 | exit(-1) 11 | 12 | # Check for a setup.bash in the root, which can be used to override additional variables 13 | # (i.e. set annoying things like IGN_GAZEBO_RESOURCE_PATH) 14 | root_setup = workspace_root / 'setup.bash' 15 | if root_setup.exists(): 16 | print(root_setup) 17 | return 18 | 19 | if build_type == BuildType.COLCON: 20 | print(workspace_root / 'install/setup.bash') 21 | else: 22 | print(workspace_root / 'devel/setup.bash') 23 | -------------------------------------------------------------------------------- /.github/workflows/publish_pip.yaml: -------------------------------------------------------------------------------- 1 | name: Publish package on pip 2 | 3 | on: 4 | workflow_dispatch: 5 | release: 6 | types: 7 | - published 8 | 9 | jobs: 10 | dist: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - name: Build SDist and wheel 16 | run: pipx run build 17 | 18 | - uses: actions/upload-artifact@v4 19 | with: 20 | path: dist/* 21 | 22 | - name: Check metadata 23 | run: pipx run twine check dist/* 24 | 25 | publish: 26 | needs: [dist] 27 | environment: pypi 28 | permissions: 29 | id-token: write 30 | runs-on: ubuntu-latest 31 | if: github.event_name == 'release' && github.event.action == 'published' 32 | 33 | steps: 34 | - uses: actions/download-artifact@v4 35 | with: 36 | name: artifact 37 | path: dist 38 | 39 | - uses: pypa/gh-action-pypi-publish@release/v1 40 | -------------------------------------------------------------------------------- /src/ros_command/util.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | 3 | import yaml 4 | 5 | CONFIG_PATH = pathlib.Path('~/.ros/ros_command.yaml').expanduser() 6 | CONFIG = None 7 | 8 | 9 | def get_config(key, default_value=None, workspace_root=None): 10 | if workspace_root: 11 | local_config_path = workspace_root / 'ros_command.yaml' 12 | if local_config_path.exists(): 13 | local_config = yaml.safe_load(open(local_config_path)) 14 | if key in local_config: 15 | return local_config[key] 16 | 17 | global CONFIG 18 | if CONFIG is None: 19 | if CONFIG_PATH.exists(): 20 | CONFIG = yaml.safe_load(open(CONFIG_PATH)) 21 | else: 22 | CONFIG = {} 23 | 24 | return CONFIG.get(key, default_value) 25 | 26 | 27 | def sizeof_fmt(num, suffix='B'): 28 | # https://stackoverflow.com/questions/1094841/get-human-readable-version-of-file-size 29 | BASE = 1024.0 30 | for unit in ['', 'Ki', 'Mi', 'Gi', 'Ti', 'Pi', 'Ei', 'Zi']: 31 | if abs(num) < BASE: 32 | return f'{num:3.1f} {unit}{suffix}' 33 | num /= BASE 34 | return '{num:.1f} Yi{suffix}' 35 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v6.0.0 4 | hooks: 5 | - id: check-added-large-files 6 | - id: check-case-conflict 7 | - id: check-merge-conflict 8 | - id: check-symlinks 9 | - id: debug-statements 10 | - id: destroyed-symlinks 11 | - id: detect-private-key 12 | - id: end-of-file-fixer 13 | - id: mixed-line-ending 14 | - id: trailing-whitespace 15 | - id: check-ast 16 | - id: check-executables-have-shebangs 17 | - id: double-quote-string-fixer 18 | - id: requirements-txt-fixer 19 | - id: check-shebang-scripts-are-executable 20 | - id: check-yaml 21 | - repo: https://github.com/hhatto/autopep8 22 | rev: v2.3.2 23 | hooks: 24 | - id: autopep8 25 | - repo: https://github.com/PyCQA/flake8 26 | rev: 7.3.0 27 | hooks: 28 | - id: flake8 29 | # Broken: https://github.com/lovesegfault/beautysh/pull/251 30 | # - repo: https://github.com/lovesegfault/beautysh 31 | # rev: v6.2.1 32 | # hooks: 33 | # - id: beautysh 34 | - repo: https://github.com/codespell-project/codespell 35 | rev: v2.4.1 36 | hooks: 37 | - id: codespell 38 | args: 39 | - --write-changes 40 | ci: 41 | autoupdate_schedule: quarterly 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) 2022, Metro Robots 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 17 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 18 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 21 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 22 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 24 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /src/ros_command/commands/roslaunch.py: -------------------------------------------------------------------------------- 1 | import argcomplete 2 | import argparse 3 | import asyncio 4 | 5 | from betsy_ros import get_ros_version, get_workspace_root 6 | 7 | from ros_command.command_lib import run, get_overlayed_command 8 | from ros_command.completion import PackageCompleter, LaunchArgCompleter, LaunchFileCompleter 9 | 10 | 11 | async def main(): 12 | build_type, workspace_root = get_workspace_root() 13 | version, distro = get_ros_version() 14 | 15 | parser = argparse.ArgumentParser() 16 | parser.add_argument('package_name').completer = PackageCompleter(workspace_root, version) 17 | parser.add_argument('launch_file_name').completer = LaunchFileCompleter(workspace_root, version) 18 | parser.add_argument('argv', nargs=argparse.REMAINDER).completer = LaunchArgCompleter(workspace_root, version) 19 | 20 | argcomplete.autocomplete(parser, always_complete_options=False) 21 | args = parser.parse_args() 22 | 23 | command = [] 24 | if version == 1: 25 | command.append(await get_overlayed_command('roslaunch')) 26 | else: 27 | command.append(f'/opt/ros/{distro}/bin/ros2') 28 | command.append('launch') 29 | 30 | command += [args.package_name, args.launch_file_name] 31 | command += args.argv 32 | 33 | code = await run(command) 34 | exit(code) 35 | 36 | 37 | def main_launch(): 38 | loop = asyncio.new_event_loop() 39 | asyncio.set_event_loop(loop) 40 | loop.run_until_complete(main()) 41 | -------------------------------------------------------------------------------- /src/ros_command/commands/rosrun.py: -------------------------------------------------------------------------------- 1 | import argcomplete 2 | import argparse 3 | import asyncio 4 | 5 | from betsy_ros import get_ros_version, get_workspace_root 6 | 7 | from ros_command.command_lib import run, get_overlayed_command 8 | from ros_command.completion import PackageCompleter, ExecutableNameCompleter 9 | 10 | 11 | async def main(debug=False): 12 | build_type, workspace_root = get_workspace_root() 13 | version, distro = get_ros_version() 14 | 15 | parser = argparse.ArgumentParser() 16 | parser.add_argument('-d', '--debug', action='store_true') 17 | parser.add_argument('package_name').completer = PackageCompleter(workspace_root, version) 18 | parser.add_argument('executable_name').completer = ExecutableNameCompleter(workspace_root, version) 19 | parser.add_argument('argv', nargs=argparse.REMAINDER) 20 | 21 | argcomplete.autocomplete(parser, always_complete_options=False) 22 | args = parser.parse_args() 23 | 24 | command = [] 25 | if version == 1: 26 | command.append(await get_overlayed_command('rosrun')) 27 | else: 28 | command.append(f'/opt/ros/{distro}/bin/ros2') 29 | command.append('run') 30 | 31 | command += [args.package_name, args.executable_name] 32 | 33 | if debug or args.debug: 34 | command += ['--prefix', 'gdb -ex run --args'] 35 | 36 | command += args.argv 37 | 38 | code = await run(command) 39 | exit(code) 40 | 41 | 42 | def main_rosrun(): 43 | loop = asyncio.new_event_loop() 44 | asyncio.set_event_loop(loop) 45 | loop.run_until_complete(main()) 46 | 47 | 48 | def main_rosdebug(): 49 | loop = asyncio.new_event_loop() 50 | asyncio.set_event_loop(loop) 51 | loop.run_until_complete(main(debug=True)) 52 | -------------------------------------------------------------------------------- /src/ros_command/commands/get_ros_directory.py: -------------------------------------------------------------------------------- 1 | import argcomplete 2 | import argparse 3 | import asyncio 4 | import sys 5 | 6 | from betsy_ros import BuildType, get_workspace_root 7 | 8 | from ros_command.command_lib import get_output 9 | from ros_command.completion import PackageCompleter 10 | 11 | 12 | async def main(): 13 | build_type, workspace_root = get_workspace_root() 14 | 15 | parser = argparse.ArgumentParser() 16 | parser.add_argument('package', nargs='?').completer = PackageCompleter(workspace_root) 17 | 18 | argcomplete.autocomplete(parser) 19 | 20 | args = parser.parse_args() 21 | 22 | if workspace_root is None: 23 | print('Cannot find your ROS workspace!', file=sys.stderr) 24 | exit(-1) 25 | 26 | if args.package is None: 27 | print(workspace_root.resolve()) 28 | exit(0) 29 | 30 | if build_type == BuildType.COLCON: 31 | # ROS 2 32 | ret, out, err = await get_output(['ros2', 'pkg', 'prefix', args.package]) 33 | if ret: 34 | print(err.strip()) 35 | elif '/opt/ros' in out: 36 | out = out.strip() 37 | print(f'{out}/share/{args.package}') 38 | else: 39 | _, out, _ = await get_output(['colcon', 'list', '--packages-select', args.package, '--paths-only'], 40 | cwd=workspace_root) 41 | src_path = workspace_root / out.strip() 42 | print(src_path) 43 | else: 44 | # ROS 1 45 | _, out, _ = await get_output(['rospack', 'find', args.package]) 46 | print(out.strip()) 47 | 48 | 49 | def main_get_dir(): 50 | loop = asyncio.new_event_loop() 51 | asyncio.set_event_loop(loop) 52 | loop.run_until_complete(main()) 53 | -------------------------------------------------------------------------------- /src/ros_command/ros_command_setup.bash: -------------------------------------------------------------------------------- 1 | # This file is intended to be source-d 2 | 3 | # roscd is not a Python script because Python cannot change the directory 4 | # https://stackoverflow.com/questions/18166977/cd-to-dir-after-exiting-script-system-independent-way-purely-in-python 5 | # Attempt to implement roscd upstream: https://github.com/ros2/ros2cli/pull/75 6 | 7 | # Only define roscd when it is not defined, i.e. don't overwrite the ROS 1 version 8 | if ! type -t roscd >/dev/null 2>&1; then 9 | roscd() 10 | { 11 | if [[ -z "${ROS_VERSION}" ]]; then 12 | echo "Unable to determine ROS version" 13 | return 14 | fi 15 | 16 | cd $(get_ros_directory $1) 17 | return 18 | } 19 | 20 | # TODO: Reimplement completions 21 | _roscd_completions() 22 | { 23 | case $COMP_CWORD in 24 | 1) 25 | COMPREPLY=($(compgen -W "$(ros2 pkg list | sed 's/\t//')" -- "${COMP_WORDS[1]}")) ;; 26 | esac 27 | } 28 | 29 | complete -F _roscd_completions roscd 30 | fi 31 | 32 | source_ros() 33 | { 34 | LOCATION="$(get_current_setup_bash)" 35 | if [[ $LOCATION ]] 36 | then 37 | echo "Sourcing $LOCATION" 38 | source $LOCATION 39 | fi 40 | } 41 | 42 | argcomplete=$(which register-python-argcomplete{,3}) 43 | 44 | eval "$($argcomplete get_ros_directory)" 45 | eval "$($argcomplete rosaction)" 46 | eval "$($argcomplete rosbuild)" 47 | eval "$($argcomplete rosclean)" 48 | eval "$($argcomplete rosdebug)" 49 | eval "$($argcomplete rosdep_install)" 50 | eval "$($argcomplete rosexecute)" 51 | eval "$($argcomplete roslaunch)" 52 | eval "$($argcomplete rosmsg)" 53 | eval "$($argcomplete rosrun)" 54 | eval "$($argcomplete rossrv)" 55 | eval "$($argcomplete rostopic)" 56 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling", "hatch-vcs"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "ros_command" 7 | description = "Unifying the ROS command line tools" 8 | readme = "README.md" 9 | authors = [ 10 | { name = "David V. Lu!!", email = "davidvlu@gmail.com" }, 11 | ] 12 | maintainers = [ 13 | { name = "David V. Lu!!", email = "davidvlu@gmail.com" }, 14 | ] 15 | 16 | dependencies = [ 17 | "argcomplete", 18 | "argparse", 19 | "betsy-ros>=0.2.0", 20 | "blessed", 21 | "click", 22 | "pyyaml", 23 | ] 24 | 25 | requires-python = ">=3.8" 26 | 27 | dynamic = ["version"] 28 | 29 | [project.scripts] 30 | get_current_setup_bash = "ros_command.commands.get_current_setup_bash:main" 31 | get_ros_command_bash = "ros_command.commands.get_ros_command_bash:main" 32 | get_ros_directory = "ros_command.commands.get_ros_directory:main_get_dir" 33 | rosaction = "ros_command.commands.rosinterface:main_action" 34 | rosbuild = "ros_command.commands.rosbuild:main_rosbuild" 35 | rosclean = "ros_command.commands.rosclean:main" 36 | rosdebug = "ros_command.commands.rosrun:main_rosdebug" 37 | rosdep_install = "ros_command.commands.rosdep_install:main_rosdep" 38 | rosexecute = "ros_command.commands.rosexecute:main_execute" 39 | roslaunch = "ros_command.commands.roslaunch:main_launch" 40 | rosmsg = "ros_command.commands.rosinterface:main_msg" 41 | rosrun = "ros_command.commands.rosrun:main_rosrun" 42 | rossrv = "ros_command.commands.rosinterface:main_srv" 43 | rostopic = "ros_command.commands.rostopic:main_rostopic" 44 | 45 | [project.urls] 46 | Homepage = "https://github.com/MetroRobots/ros_command" 47 | "Bug Tracker" = "https://github.com/MetroRobots/ros_command/issues" 48 | 49 | [tool.hatch] 50 | version.source = "vcs" 51 | build.hooks.vcs.version-file = "src/ros_command/_version.py" 52 | -------------------------------------------------------------------------------- /src/ros_command/commands/rosexecute.py: -------------------------------------------------------------------------------- 1 | import argcomplete 2 | import argparse 3 | import asyncio 4 | 5 | from betsy_ros import get_ros_version, get_workspace_root 6 | 7 | from ros_command.command_lib import run, get_overlayed_command 8 | from ros_command.completion import PackageCompleter, ExecutableNameCompleter, LaunchFileCompleter 9 | from ros_command.packages import find_executables_in_package 10 | 11 | 12 | class StartCompleter: 13 | def __init__(self, workspace_root=None, version=None): 14 | self.workspace_root = workspace_root 15 | self.version = version 16 | self.enc = ExecutableNameCompleter(workspace_root, version) 17 | self.lfc = LaunchFileCompleter(workspace_root, version) 18 | 19 | def __call__(self, **kwargs): 20 | return self.enc(**kwargs) + self.lfc(**kwargs) 21 | 22 | 23 | async def main(): 24 | build_type, workspace_root = get_workspace_root() 25 | version, distro = get_ros_version() 26 | 27 | parser = argparse.ArgumentParser() 28 | parser.add_argument('package_name').completer = PackageCompleter(workspace_root, version) 29 | parser.add_argument('executable_or_launchfile').completer = StartCompleter(workspace_root, version) 30 | parser.add_argument('argv', nargs=argparse.REMAINDER) 31 | 32 | argcomplete.autocomplete(parser, always_complete_options=False) 33 | args = parser.parse_args() 34 | 35 | if args.executable_or_launchfile in find_executables_in_package(args.package_name, version): 36 | verb = 'run' 37 | else: 38 | verb = 'launch' 39 | 40 | command = [] 41 | if version == 1: 42 | command.append(await get_overlayed_command(f'ros{verb}')) 43 | else: 44 | command.append(f'/opt/ros/{distro}/bin/ros2') 45 | command.append(verb) 46 | 47 | command += [args.package_name, args.executable_or_launchfile] 48 | 49 | command += args.argv 50 | 51 | code = await run(command) 52 | exit(code) 53 | 54 | 55 | def main_execute(): 56 | loop = asyncio.new_event_loop() 57 | asyncio.set_event_loop(loop) 58 | loop.run_until_complete(main()) 59 | -------------------------------------------------------------------------------- /src/ros_command/commands/rosbuild.py: -------------------------------------------------------------------------------- 1 | import argcomplete 2 | import argparse 3 | import asyncio 4 | import os 5 | 6 | from betsy_ros import BuildType, get_package_name_from_path, get_workspace_root 7 | 8 | from ros_command.build_tool import add_package_selection_args, generate_build_command, get_package_selection_args 9 | from ros_command.build_tool import run_build_command 10 | from ros_command.command_lib import run 11 | from ros_command.util import get_config 12 | 13 | 14 | async def main(): 15 | build_type, workspace_root = get_workspace_root() 16 | 17 | parser = argparse.ArgumentParser() 18 | parser.add_argument('-c', '--continue-on-failure', action='store_true') 19 | parser.add_argument('-j', '--jobs', type=int) 20 | parser.add_argument('-b', '--cmake-build-type', choices=['Debug', 'Release', 'RelWithDebInfo']) 21 | parser.add_argument('-t', '--test', action='store_true') 22 | parser.add_argument('-g', '--toggle-graphics', action='store_true') 23 | add_package_selection_args(parser, workspace_root) 24 | 25 | argcomplete.autocomplete(parser, always_complete_options=False) 26 | 27 | args, unknown_args = parser.parse_known_args() 28 | 29 | if build_type is None: 30 | ros_version = int(os.environ.get('ROS_VERSION', 1)) 31 | if ros_version == 2: 32 | build_type = BuildType.COLCON 33 | else: 34 | build_type = BuildType.CATKIN_TOOLS # Defaults to catkin tools 35 | 36 | pkg_name = get_package_name_from_path() 37 | 38 | package_selection_args = get_package_selection_args(args, build_type, pkg_name) 39 | 40 | if args.test: 41 | command = generate_build_command(build_type, unknown_args, package_selection_args, 42 | args.continue_on_failure, args.jobs, args.cmake_build_type, workspace_root) 43 | print(' '.join(command)) 44 | exit(0) 45 | 46 | code = await run_build_command(build_type, workspace_root, unknown_args, package_selection_args, 47 | args.continue_on_failure, args.jobs, 48 | args.cmake_build_type, args.toggle_graphics) 49 | 50 | # Sound Notification 51 | sound_path = None 52 | if code == 0: 53 | sound_path = get_config('success_sound') 54 | else: 55 | sound_path = get_config('fail_sound') 56 | if sound_path: 57 | await run(['aplay', '-q', sound_path]) 58 | exit(code) 59 | 60 | 61 | def main_rosbuild(): 62 | loop = asyncio.new_event_loop() 63 | asyncio.set_event_loop(loop) 64 | loop.run_until_complete(main()) 65 | -------------------------------------------------------------------------------- /src/ros_command/command_lib.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from asyncio import create_subprocess_exec 3 | from asyncio.subprocess import DEVNULL, PIPE 4 | import pathlib 5 | import sys 6 | 7 | import click 8 | 9 | # Brought to you by https://stackoverflow.com/questions/803265/getting-realtime-output-using-subprocess 10 | 11 | 12 | async def _read_stream(stream, callback, encoding='UTF8'): 13 | while True: 14 | line = await stream.readline() 15 | if line: 16 | callback(line.decode(encoding)) 17 | else: 18 | break 19 | 20 | 21 | def _default_stdout_callback(line): 22 | """Default callback for stdout that just prints.""" 23 | click.echo(line, nl=False) 24 | 25 | 26 | def _default_stderr_callback(line): 27 | """Default callback for stderr that prints to stdout in red.""" 28 | click.secho(line, fg='red', nl=False) 29 | 30 | 31 | async def run(command, stdout_callback=None, stderr_callback=None, cwd=None): 32 | """Run a command (array of strings) and process its output with callbacks.""" 33 | process = await create_subprocess_exec( 34 | *command, stdout=PIPE, stderr=PIPE, cwd=cwd 35 | ) 36 | 37 | if stdout_callback is None: 38 | stdout_callback = _default_stdout_callback 39 | if stderr_callback is None: 40 | stderr_callback = _default_stderr_callback 41 | 42 | await asyncio.wait([asyncio.create_task(_read_stream(process.stdout, stdout_callback)), 43 | asyncio.create_task(_read_stream(process.stderr, stderr_callback))]) 44 | 45 | return await process.wait() 46 | 47 | 48 | async def run_silently(command, cwd=None): 49 | """Run a command (array of strings) and hide all output.""" 50 | process = await create_subprocess_exec( 51 | *command, stdout=DEVNULL, stderr=DEVNULL, cwd=cwd 52 | ) 53 | 54 | return await process.wait() 55 | 56 | 57 | async def get_output(command, cwd=None): 58 | """Run a command (array of strings) and return its standard output and error.""" 59 | out = [] 60 | err = [] 61 | 62 | def gather_callback(lines, line): 63 | lines.append(line) 64 | 65 | ret = await run(command, cwd=cwd, 66 | stdout_callback=lambda line: gather_callback(out, line), 67 | stderr_callback=lambda line: gather_callback(err, line)) 68 | 69 | return ret, ''.join(out), ''.join(err) 70 | 71 | 72 | async def get_overlayed_command(command): 73 | _, which_output, _ = await get_output(['which', '-a', command]) 74 | cmds = which_output.splitlines() 75 | executing_folder_s = str(pathlib.Path(sys.argv[0]).parent) 76 | return next(r for r in cmds if not r.startswith(executing_folder_s)) 77 | -------------------------------------------------------------------------------- /src/ros_command/commands/rosclean.py: -------------------------------------------------------------------------------- 1 | import argcomplete 2 | import argparse 3 | import pathlib 4 | import shutil 5 | 6 | import click 7 | 8 | from betsy_ros import BuildType, get_workspace_root 9 | 10 | from ros_command.completion import LocalPackageCompleter 11 | from ros_command.util import sizeof_fmt 12 | 13 | 14 | def main(): 15 | build_type, workspace_root = get_workspace_root() 16 | 17 | parser = argparse.ArgumentParser() 18 | parser.add_argument('-y', '--yes-to-all', '--no-confirm', action='store_true') 19 | parser.add_argument('-c', '--just-checking', action='store_true') 20 | parser.add_argument('-n', '--no-sizes', action='store_true') 21 | pack_arg = parser.add_argument('packages', metavar='package', nargs='*') 22 | pack_arg.completer = LocalPackageCompleter(workspace_root) 23 | 24 | argcomplete.autocomplete(parser) 25 | 26 | args = parser.parse_args() 27 | 28 | if args.packages: 29 | if args.packages[0] == 'check': 30 | args.just_checking = True 31 | args.packages.pop(0) 32 | elif args.packages[0] == 'purge': 33 | args.packages.pop(0) 34 | 35 | directories = [] 36 | 37 | if not args.packages: 38 | directories.append(workspace_root / 'build') 39 | directories.append(workspace_root / 'log') 40 | if build_type == BuildType.COLCON: 41 | directories.append(workspace_root / 'install') 42 | else: 43 | directories.append(workspace_root / 'devel') 44 | directories.append(pathlib.Path('~/.ros/log').expanduser()) 45 | else: 46 | if build_type != BuildType.COLCON: 47 | raise NotImplementedError() 48 | for package in args.packages: 49 | directories.append(workspace_root / 'build' / package) 50 | directories.append(workspace_root / 'install' / package) 51 | 52 | max_len = max(len(str(p)) for p in directories) 53 | 54 | try: 55 | for directory in directories: 56 | if not directory.exists(): 57 | click.secho(f'{directory} does not exist!', fg='yellow') 58 | continue 59 | 60 | click.secho(str(directory).ljust(max_len + 2), nl=False) 61 | if not args.no_sizes: 62 | dir_size = sizeof_fmt(sum(f.stat().st_size for f in directory.glob('**/*') if f.is_file())) 63 | click.secho(dir_size, fg='bright_blue') 64 | else: 65 | click.secho('') 66 | if args.just_checking: 67 | continue 68 | if args.yes_to_all or click.confirm('Delete?'): 69 | shutil.rmtree(directory) 70 | except click.exceptions.Abort: 71 | click.echo() 72 | -------------------------------------------------------------------------------- /src/ros_command/commands/rostopic.py: -------------------------------------------------------------------------------- 1 | import argcomplete 2 | import argparse 3 | import asyncio 4 | 5 | from betsy_ros import get_ros_version, get_workspace_root 6 | 7 | from ros_command.command_lib import run, get_overlayed_command 8 | from ros_command.completion import TopicCompleter 9 | from ros_command.commands.rosinterface import InterfaceInterface, InterfaceCompleter 10 | 11 | 12 | async def main(debug=False): 13 | build_type, workspace_root = get_workspace_root() 14 | version, distro = get_ros_version() 15 | 16 | ii = InterfaceInterface(version, distro, 'msg') 17 | 18 | parser = argparse.ArgumentParser() 19 | subparsers = parser.add_subparsers(dest='verb') 20 | 21 | bw_parser = subparsers.add_parser('bw') 22 | bw_parser.add_argument('topic').completer = TopicCompleter(version=version) 23 | 24 | delay_parser = subparsers.add_parser('delay') 25 | delay_parser.add_argument('topic').completer = TopicCompleter(version=version) 26 | 27 | echo_parser = subparsers.add_parser('echo') 28 | echo_parser.add_argument('topic').completer = TopicCompleter(version=version) 29 | 30 | find_parser = subparsers.add_parser('find') 31 | find_parser.add_argument('msg_type').completer = InterfaceCompleter(workspace_root, ii) 32 | 33 | hz_parser = subparsers.add_parser('hz') 34 | hz_parser.add_argument('topic').completer = TopicCompleter(version=version) 35 | 36 | info_parser = subparsers.add_parser('info') 37 | info_parser.add_argument('topic').completer = TopicCompleter(version=version) 38 | 39 | subparsers.add_parser('list') 40 | 41 | pub_parser = subparsers.add_parser('pub') 42 | pub_parser.add_argument('topic').completer = TopicCompleter(version=version) 43 | 44 | type_parser = subparsers.add_parser('type') 45 | type_parser.add_argument('topic').completer = TopicCompleter(version=version) 46 | 47 | parser.add_argument('argv', nargs=argparse.REMAINDER) 48 | 49 | argcomplete.autocomplete(parser, always_complete_options=False) 50 | args = parser.parse_args() 51 | 52 | command = [] 53 | if version == 1: 54 | command.append(await get_overlayed_command('rostopic')) 55 | else: 56 | command.append(f'/opt/ros/{distro}/bin/ros2') 57 | command.append('topic') 58 | 59 | command.append(args.verb) 60 | 61 | if hasattr(args, 'topic'): 62 | command.append(args.topic) 63 | elif hasattr(args, 'msg_type'): 64 | full_names = ii.translate_to_full_names(args.msg_type) 65 | if len(full_names) == 1: 66 | interface = full_names[0].to_string(False) 67 | else: 68 | interface = args.msg_type 69 | command.append(interface) 70 | 71 | command += args.argv 72 | 73 | code = await run(command) 74 | exit(code) 75 | 76 | 77 | def main_rostopic(): 78 | loop = asyncio.new_event_loop() 79 | asyncio.set_event_loop(loop) 80 | loop.run_until_complete(main()) 81 | -------------------------------------------------------------------------------- /src/ros_command/packages.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from rosdep2.ament_packages import get_packages_with_prefixes, AMENT_PREFIX_PATH_ENV_VAR 4 | from rosdep2.catkin_packages import find_catkin_packages_in 5 | 6 | ROS_PACKAGE_PATH = 'ROS_PACKAGE_PATH' 7 | 8 | 9 | def get_packages_in_folder(folder, verbose=False): 10 | """Wrapper for find_catkin_packages_in that returns a set. 11 | 12 | * Ignores folders with IGNORE_MARKERS 13 | * Returns paths with package.xml 14 | 15 | https://github.com/ros-infrastructure/rosdep/blob/master/src/rosdep2/catkin_packages.py#L19 16 | """ 17 | return set(find_catkin_packages_in(folder, verbose=verbose)) 18 | 19 | 20 | def get_all_packages(folder=None, verbose=False): 21 | """Return the set of all packages in the environment.""" 22 | universe = set() 23 | if folder: 24 | universe.update(get_packages_in_folder(folder, verbose)) 25 | 26 | # Get Packages From Path 27 | if AMENT_PREFIX_PATH_ENV_VAR in os.environ: 28 | universe.update(get_packages_with_prefixes().keys()) 29 | elif ROS_PACKAGE_PATH in os.environ: 30 | for path in os.environ[ROS_PACKAGE_PATH].split(os.pathsep): 31 | if not os.path.exists(path): 32 | continue 33 | universe.update(find_catkin_packages_in(path, verbose)) 34 | return universe 35 | 36 | 37 | def find_executables_in_package(package_name, version): 38 | if version == 1: 39 | from catkin.find_in_workspaces import find_in_workspaces 40 | execs = [] 41 | for exec_folder in find_in_workspaces(['libexec'], package_name): 42 | for path in sorted(os.listdir(exec_folder)): 43 | full_path = os.path.join(exec_folder, path) 44 | if os.access(full_path, os.X_OK): 45 | execs.append(path) 46 | return execs 47 | else: 48 | from ros2pkg.api import get_executable_paths, PackageNotFound 49 | try: 50 | paths = get_executable_paths(package_name=package_name) 51 | except PackageNotFound: 52 | return [] 53 | return [os.path.basename(p) for p in paths] 54 | 55 | 56 | def find_launch_files_in_package(package_name, version): 57 | if version == 1: 58 | from catkin.find_in_workspaces import find_in_workspaces 59 | launches = [] 60 | for folder in find_in_workspaces(['share'], package_name): 61 | for name, subdirs, files in os.walk(folder): 62 | for filepath in files: 63 | if filepath.endswith('.launch'): 64 | launches.append(filepath) 65 | return launches 66 | else: 67 | from ament_index_python.packages import get_package_share_directory 68 | from ament_index_python.packages import PackageNotFoundError 69 | from ros2launch.api.api import get_launch_file_paths 70 | try: 71 | package_share_directory = get_package_share_directory(package_name) 72 | paths = get_launch_file_paths(path=package_share_directory) 73 | except PackageNotFoundError: 74 | return [] 75 | return [os.path.basename(p) for p in paths] 76 | 77 | 78 | def get_launch_file_arguments(package_name, launch_file_name, version): 79 | try: 80 | if version == 1: 81 | import roslaunch.arg_dump as roslaunch_arg_dump 82 | from roslaunch import rlutil 83 | 84 | args = rlutil.resolve_launch_arguments([package_name, launch_file_name]) 85 | return sorted(roslaunch_arg_dump.get_args(args).keys()) 86 | else: 87 | from ros2launch.api import get_share_file_path_from_package 88 | from launch.launch_description_sources import get_launch_description_from_any_launch_file 89 | 90 | path = get_share_file_path_from_package(package_name=package_name, file_name=launch_file_name) 91 | launch_description = get_launch_description_from_any_launch_file(path) 92 | return sorted(arg.name for arg in launch_description.get_launch_arguments()) 93 | except Exception: 94 | return [] 95 | -------------------------------------------------------------------------------- /src/ros_command/build_status_display.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | import re 4 | 5 | from ros_command.terminal_display import DynamicLayoutTerminalDisplay, Gauge, HSplit, Log, Marquee, TerminalComponent 6 | from ros_command.terminal_display import Text, VSplit 7 | 8 | STATUS_COLORS = { 9 | 'blocked': 'magenta', 10 | 'queued': 'yellow', 11 | 'active': 'blue', 12 | 'finished': 'green', 13 | 'failed': 'red', 14 | 'skipped': 'cyan' 15 | } 16 | 17 | EMOJIS = { 18 | 'melodic': '🎶', 19 | 'noetic': '🤔', 20 | 'debian': '🍥', 21 | 'eloquent': '🗣', 22 | 'foxy': '🦊', 23 | 'galactic': '🌌', 24 | 'humble': '🤭', 25 | 'iron': '🔧', 26 | 'jazzy': '🎷', 27 | 'rolling': '🎢', 28 | } 29 | 30 | 31 | class BuildStatusHeader(TerminalComponent): 32 | def __init__(self, status, display): 33 | self.status = status 34 | self.distro = os.environ.get('ROS_DISTRO', '') 35 | self.emoji = EMOJIS.get(self.distro, '') 36 | self.marquee = Marquee('rosbuild', display.get_colors(re.compile(r'[^_]*green'))) 37 | 38 | def draw(self, display): 39 | marquee_s = str(self.marquee) 40 | elapsed_s = self.status.get_elapsed_time() 41 | if self.h == 1: 42 | display(self.emoji, xy=(self.x0, self.y0)) 43 | display(marquee_s, xy=(self.x0 + 2, self.y0)) 44 | display(display.white + elapsed_s, xy=(self.x0 + self.w - len(elapsed_s), self.y0)) 45 | else: 46 | x1 = self.x0 + (self.w - self.marquee.n) // 2 47 | y1 = self.y0 + 1 48 | display(marquee_s, xy=(x1, y1)) 49 | 50 | display(display.white + elapsed_s.center(self.w), xy=(self.x0, y1 + 1)) 51 | 52 | if self.emoji: 53 | for dx in [0, 1]: 54 | for dy in [0, 1]: 55 | display(self.emoji, xy=(self.x0 + dx * (self.w - 2), self.y0 + dy * (self.h - 1))) 56 | 57 | def finish(self): 58 | self.marquee.finish() 59 | 60 | 61 | class CombinedStatusDisplay(TerminalComponent): 62 | def __init__(self): 63 | self.active = [] 64 | self.error = [] 65 | 66 | def draw(self, display): 67 | self.clear(display) 68 | s = display.bright_blue + 'Active: ' + ', '.join(self.active) 69 | if self.error: 70 | s += '\n' + display.orangered + 'Failed: ' + ', '.join(self.error) 71 | display(s, (self.x0, self.y0)) 72 | 73 | 74 | class BuildStatusDisplay: 75 | def __init__(self, status, update_period=0.1): 76 | self.status = status 77 | self.term = DynamicLayoutTerminalDisplay() 78 | self.header = BuildStatusHeader(status, self.term) 79 | self.active_gui = Text(title='Active', border_color=self.term.bright_blue) 80 | self.error_gui = Text(title='Failed', border_color=self.term.orangered) 81 | self.complete_gui = Gauge(title='Complete', border_color=self.term.green) 82 | self.queued_gui = Gauge(title='Queued', border_color=self.term.goldenrod) 83 | self.blocked_gui = Gauge(title='Blocked', border_color=self.term.purple) 84 | self.log_gui = Log(title='Errors', border_color=self.term.firebrick) 85 | self.combined_gui = CombinedStatusDisplay() 86 | 87 | progress = HSplit(self.complete_gui, self.queued_gui, self.blocked_gui) 88 | self.term.large_layout = VSplit( 89 | HSplit( 90 | VSplit(self.header, self.active_gui, self.error_gui), 91 | self.log_gui 92 | ), 93 | progress, 94 | weights=[6, 1]) 95 | self.term.small_layout = VSplit( 96 | self.header, 97 | self.combined_gui, 98 | progress, 99 | weights=[1, 60, 1]) 100 | 101 | self.term.draw() 102 | 103 | self.update_period = 0.1 104 | self.task = asyncio.ensure_future(self.timer_cb()) 105 | 106 | async def timer_cb(self): 107 | await asyncio.sleep(self.update_period) 108 | self.show() 109 | self.task = asyncio.ensure_future(self.timer_cb()) 110 | 111 | def get_elapsed_time(self): 112 | return self.status.get_elapsed_time() 113 | 114 | def show(self): 115 | self.header.marquee.advance() 116 | self.active_gui.update(self.status.pkg_lists['active']) 117 | self.error_gui.update(self.status.pkg_lists['failed']) 118 | self.combined_gui.active = self.status.pkg_lists['active'] 119 | self.combined_gui.error = self.status.pkg_lists['failed'] 120 | self.complete_gui.update(len(self.status.pkg_lists['finished']), self.status.n) 121 | self.queued_gui.update(len(self.status.pkg_lists['queued']), self.status.n) 122 | self.blocked_gui.update(len(self.status.pkg_lists['blocked']), self.status.n) 123 | self.log_gui.update(self.status.error_buffer) 124 | self.term.draw() 125 | 126 | def finish(self): 127 | self.task.cancel() 128 | self.header.finish() 129 | self.term.finish() 130 | self.show() 131 | -------------------------------------------------------------------------------- /src/ros_command/completion.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import pathlib 3 | import re 4 | import yaml 5 | 6 | from betsy_ros.environment import get_topics 7 | from ros_command.packages import get_all_packages, get_packages_in_folder, get_launch_file_arguments 8 | from ros_command.packages import find_executables_in_package, find_launch_files_in_package 9 | from ros_command.util import get_config 10 | 11 | CACHE_PATH = pathlib.Path('~/.ros/ros_command_cache.yaml').expanduser() 12 | THE_CACHE = None 13 | 14 | # https://stackoverflow.com/a/51916936 15 | DELTA_PATTERN = re.compile(r'^((?P[\.\d]+?)h)?((?P[\.\d]+?)m)?((?P[\.\d]+?)s)?$') 16 | 17 | 18 | def get_tab_timeout(): 19 | timeout_s = get_config('tab_complete_timeout', '4h') 20 | m = DELTA_PATTERN.match(timeout_s) 21 | if m: 22 | time_params = {name: float(param) for name, param in m.groupdict().items() if param} 23 | return datetime.timedelta(**time_params) 24 | return datetime.timedelta(hours=4) 25 | 26 | 27 | class Completer: 28 | def __init__(self, workspace_root=None, version=None): 29 | self.workspace_root = workspace_root 30 | self.version = version 31 | 32 | def get_cache_keys(self, **kwargs): 33 | raise NotImplementedError('Please implement this method') 34 | 35 | def get_completions(self, **kwargs): 36 | raise NotImplementedError('Please implement this method') 37 | 38 | def filter_values(self, values, **kwargs): 39 | # Overridable method 40 | return values 41 | 42 | def get_cached_completions(self, cache_keys): 43 | global THE_CACHE 44 | 45 | if cache_keys is None: 46 | return 47 | 48 | # Load cache 49 | if THE_CACHE is None: 50 | if CACHE_PATH.exists(): 51 | THE_CACHE = yaml.safe_load(open(CACHE_PATH)) 52 | else: 53 | THE_CACHE = {} 54 | 55 | # Get relevant part 56 | d = THE_CACHE 57 | for key in cache_keys: 58 | if key not in d: 59 | d[key] = {} 60 | d = d[key] 61 | 62 | # Check timing 63 | if 'stamp' not in d: 64 | return 65 | delta = datetime.datetime.now() - d['stamp'] 66 | if delta < get_tab_timeout(): 67 | return d['data'] 68 | 69 | def write_to_cache(self, cache_keys, results): 70 | if cache_keys is None: 71 | return 72 | 73 | d = THE_CACHE 74 | for key in cache_keys: 75 | d = d[key] 76 | 77 | d['data'] = list(results) 78 | d['stamp'] = datetime.datetime.now() 79 | yaml.safe_dump(THE_CACHE, open(CACHE_PATH, 'w')) 80 | 81 | def __call__(self, **kwargs): 82 | cache_keys = self.get_cache_keys(**kwargs) 83 | 84 | results = self.get_cached_completions(cache_keys) 85 | if not results: 86 | results = self.get_completions(**kwargs) 87 | self.write_to_cache(cache_keys, results) 88 | 89 | return self.filter_values(results, **kwargs) 90 | 91 | 92 | class PackageCompleter(Completer): 93 | def get_cache_keys(self, **kwargs): 94 | return [str(self.workspace_root), 'packages'] 95 | 96 | def get_completions(self, **kwargs): 97 | return get_all_packages(self.workspace_root) 98 | 99 | 100 | class LocalPackageCompleter(Completer): 101 | def get_cache_keys(self, **kwargs): 102 | return [str(self.workspace_root), 'local_packages'] 103 | 104 | def get_completions(self, **kwargs): 105 | return get_packages_in_folder(self.workspace_root) 106 | 107 | 108 | class ExecutableNameCompleter(Completer): 109 | def get_cache_keys(self, parsed_args, **kwargs): 110 | return [str(self.workspace_root), parsed_args.package_name, 'executables'] 111 | 112 | def get_completions(self, parsed_args, **kwargs): 113 | return find_executables_in_package(parsed_args.package_name, self.version) 114 | 115 | 116 | class LaunchFileCompleter(Completer): 117 | def get_cache_keys(self, parsed_args, **kwargs): 118 | return [str(self.workspace_root), parsed_args.package_name, 'launches'] 119 | 120 | def get_completions(self, parsed_args, **kwargs): 121 | return find_launch_files_in_package(parsed_args.package_name, self.version) 122 | 123 | 124 | class LaunchArgCompleter(Completer): 125 | def get_cache_keys(self, parsed_args, **kwargs): 126 | return [str(self.workspace_root), parsed_args.package_name, parsed_args.launch_file_name, 'arg'] 127 | 128 | def get_completions(self, parsed_args, **kwargs): 129 | args = get_launch_file_arguments(parsed_args.package_name, 130 | parsed_args.launch_file_name, 131 | self.version) 132 | return [f'{a}:=' for a in args] 133 | 134 | def filter_values(self, values, parsed_args, **kwargs): 135 | existing_args = set() 136 | for arg_s in parsed_args.argv: 137 | if ':=' in arg_s: 138 | i = arg_s.index(':=') 139 | existing_args.add(arg_s[:i+2]) 140 | return [a for a in values if a not in existing_args] 141 | 142 | 143 | class TopicCompleter(Completer): 144 | def get_cache_keys(self, **kwargs): 145 | return [str(self.version), 'topics'] 146 | 147 | def get_completions(self, prefix, parsed_args, **kwargs): 148 | matches = [] 149 | for topic in get_topics(self.version): 150 | if topic.startswith(prefix): 151 | matches.append(topic) 152 | return matches 153 | 154 | 155 | class TopicsCompleter(TopicCompleter): 156 | def get_completions(self, prefix, parsed_args, **kwargs): 157 | matches = [] 158 | for topic in TopicCompleter.get_completions(self, prefix, parsed_args, **kwargs): 159 | if topic not in parsed_args.topics: 160 | matches.append(topic) 161 | return matches 162 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![ros_command](doc/logo.png) 2 | ## Unifying the ROS command line tools 3 | 4 | One impairment to ROS 2 adoption is that all of the commands that have worked their way into muscle memory for ROS 1 developers no longer work. Also, all of the commands in ROS 2 tend to be at least two characters longer. To get information about a topic in ROS 1, one could type `rosto` (5 characters before tab), but in ROS 2 the equivalent is `ros2 to` (7 characters before tab). 5 | 6 | On top of the differences between the ROS 1 and ROS 2 command line tools, there is also a wide gulf between the different build tools that are available. For example, if you want to build a specific package (e.g. `pr2_moveit_config`) and all its dependencies, there are three possible commands, depending on the build tool. 7 | 8 | | build tool | command | 9 | |----------------|--------------------------------------------------------| 10 | | `catkin_make` | `catkin_make --only-pkg-with-deps pr2_moveit_config` | 11 | | `catkin_tools` | `catkin build pr2_moveit_config` | 12 | | `colcon` | `colcon build --packages-up-to pr2_moveit_config` | 13 | 14 | The `ros_command` package provides a set of command line interfaces for common actions with syntax similar to ROS 1 and `catkin_tools`, since those are often simpler, shorter and more familiar to a majority of ROS developers [[citation needed]](https://xkcd.com/285/). 15 | 16 | # Setup 17 | This tool uses Python 3. 18 | 19 | sudo pip3 install ros-command 20 | 21 | [![PyPI version](https://badge.fury.io/py/ros-command.svg)](https://badge.fury.io/py/ros-command) 22 | 23 | It also uses some BASH scripts. It is recommended that you add `source $(get_ros_command_bash)` to your `.bashrc`. This will enable the bash-only commands (`roscd` and `source_ros`) and enable tab completion of the other commands. 24 | 25 | echo "source \$(get_ros_command_bash)" >> ~/.bashrc 26 | 27 | Note that if you are using ROS 1, by default the native ROS commands will take precedence over the `ros_command` versions. You can test this by running `which rosmsg`. If it returns `/opt/ros/$ROS_DISTRO/bin/rosmsg` then it is the native version. If it returns `/usr/local/bin/rosmsg` then it is the `ros_command` version. This is likely because `/opt/ros/$ROS_DISTRO/bin` comes earlier in the `$PATH` than `/usr/local/bin`. You can fix this by changing your `$PATH`, i.e. 28 | 29 | export PATH=/usr/local/bin:$PATH 30 | 31 | However, this may have additional consequences and should be done carefully. 32 | 33 | (If you don't do this for ROS 1, then the commands that do not share a name with native ROS commands (i.e. `rosbuild`) will still work fine.) 34 | 35 | # Commands 36 | 37 | ## roscd 38 | This command was not implemented in ROS 2. There is the somewhat similar [`colcon_cd`](https://colcon.readthedocs.io/en/released/user/installation.html#quick-directory-changes) command, but it requires additional installation. Instead, this package has implemented a version of `roscd` that works with ROS 2. Because you cannot change the shell's working directory from within a Python script, `roscd` is implemented in `bash`, depending on the Python script `get_ros_directory`. 39 | 40 | ## rosbuild 41 | `rosbuild` functions as a convenient wrapper for `catkin_make`, `catkin_tools` and `colcon`. (Apologies to all the people still using `rosbuild` in its [original form](http://wiki.ros.org/rosbuild), but its been deprecated since 2013.) Running `rosbuild` will automatically determine your current workspace root folder and which build tool it uses, and then running the equivalent native build command. For example running `rosbuild pr2_moveit_config` will run the three commands shown in the table in the introduction. 42 | 43 | * Like `catkin build`, it can be run from anywhere within the workspace directory structure. 44 | * Can play notification sounds when complete (see Configuration section below) 45 | * Displays the build status in a fancy [blessed](https://github.com/jquast/blessed)-based terminal-focused graphical user interface (although not for `catkin_make`). 46 | 47 | https://user-images.githubusercontent.com/1016143/148160202-03f6a5e5-f914-459b-8157-b50d2ed3d3c1.mp4 48 | 49 | 50 | * Other arguments not specified in the table below are passed into the raw build command. 51 | 52 | | Category | rosbuild | colcon | catkin_tools | catkin_make | 53 | |-----------------------|-----------------------------|-----------------------------------|-----------------------------------|----------------------------------------| 54 | | **General** | -c, --continue_on_failure | --continue-on-error | --continue-on-failure | ❌ | 55 | | | -j N --jobs N | --parallel-workers N | --jobs N | --jobs N | 56 | | | -b, --cmake_build_type X | --cmake_args -DCMAKE_BUILD_TYPE=X | --cmake_args -DCMAKE_BUILD_TYPE=X | -DCMAKE_BUILD_TYPE=X | 57 | | **Package Selection** | --this | --packages-up-to pkg_name | --this | --pkg pkg_name | 58 | | | --this --no-deps | --packages-select pkg_name | --this --no-deps | --only-pkg-with-deps | 59 | | | -s --skip_packages pkg_name | --packages-skip pkg_name | 🔲 | -DCATKIN_BLACKLIST_PACKAGES="pkg_name" | 60 | | | pkg_name | --packages-up-to pkg_name | pkg_name | --pkg pkg_name | 61 | 62 | * ❌ There is no equivalent to `--continue-on-failure` with `catkin_make` (and it is probably not possible) 63 | * 🔲 There is no equivalent to `--skip_packages` in `catkin_tools`, although you could theoretically do it by parsing the dependency tree 64 | * If `cmake_build_type` is NOT specified, then it defaults to the value in the Configuration. The command line argument does overwrite the configured one. 65 | 66 | 67 | ## rosdep_install 68 | One useful arcane command that pops up in many places/aliases (in both ROS 1 and ROS 2) is 69 | 70 | rosdep install --ignore-src -y -r --from-paths . 71 | 72 | 73 | According to the manual , `rosdep install` will "download and install the dependencies of a given package or packages". 74 | * `--from-paths .` specifies that dependencies should be installed for all packages in the current directory 75 | * `--ignore-src` will ignore packages that you have the source code checked out 76 | * `-y` makes it non-interactive (so it defaults to installing everything without prompting) 77 | * `-r` continues when you get errors 78 | 79 | Essentially, this is a command for installing all of the upstream dependencies for the packages in your workspace. 80 | 81 | Now there's the new simple command `rosdep_install` which will do the same thing. A version with a terminal GUI similar to `rosbuild` is in development. 82 | 83 | 84 | ## rosmsg / rossrv / rosaction 85 | In ROS 2, `rosmsg` and `rossrv` were replaced by `ros2 interface`, which can also handle actions. For most commands, calling the `ros_command` version of `rosmsg` and `rossrv` will just call either the ROS 1 `rosmsg/rossrv` command or the equivalent `ros2 interface` command. 86 | 87 | #### Brand New Functionality 88 | * The basic `show` command now has syntax highlighting. 89 | ![screenshot showing syntax highlighting](doc/rosmsg0.png) 90 | * The `show` command now has a `-r` option to recurse through the interface definitions. 91 | ![screenshot showing recursive message definitions](doc/rosmsg1.png) 92 | 93 | #### Added ROS 1 Functionality 94 | * `rosaction ` - `rosaction` does not exist in ROS 1, so it is implemented here, often calling `rosmsg ` on the constituent parts (i.e. Goal/Result/Feedback). 95 | 96 | #### Added ROS 2 Functionality 97 | * `ros show` without namespaces - The equivalent command to ROS 1's `rosmsg show Point` is `ros2 interface show geometry_msgs/msg/Point`. This is cumbersome for a number of reasons. First, ROS 1 is nearly half has short (17 chars vs 43 chars). It also requires you remember what package the message you are looking for is. The version implemented here will search for matching fully qualified names, and then print the fully qualified name and the contents of the interface definition. 98 | * `rosmsg proto` also works without namespaces IF there's only one type that matches. 99 | 100 | ## source_ros 101 | If you use a single ROS workspace, then you probably source the appropriate `setup.bash` from the `.bashrc` file. However, if you use multiple, you can source the appropriate `setup.bash` with one simple command: `source_ros`. This will find the appropriate `setup.bash` by determining the current ROS Workspace based on the folder the script is executed in. Typically, this will either source the `devel/setup.bash` or `install/setup.bash` depending on whether it is ROS 1 or 2. (You can also have a setup.bash in the workspace root if you need custom logic to source additional environment variables.) 102 | 103 | (Under the hood, this runs the `get_current_setup_bash` script to print the appropriate filename) 104 | 105 | ## rosrun and rosdebug 106 | In ROS 1, `rosrun` works the same way as the standard ROS 1 version. In ROS 2, it runs `ros2 run`. 107 | 108 | `rosdebug` does the same things, except it will insert `--prefix 'gdb -ex run --args'` into the appropriate place to run your node using `gdb`. 109 | 110 | ## roslaunch 111 | Similar to `rosrun`, `roslaunch` here either just runs ROS 1's `roslaunch` or ROS 2's `ros2 launch`. 112 | 113 | ## rosexecute 114 | `rosexecute` merges the `rosrun` and `roslaunch` command (convenient if you can't remember if a particular functionality was a launch file or executable). When you run a command of the form `rosexecute PKG_NAME ABC`, if there is an executable with the name `ABC`, that is `rosrun`, otherwise it is `roslaunch`ed. 115 | 116 | 117 | ## rosclean 118 | The `rosclean` command works as a hybrid of `rosclean` and `catkin clean`. 119 | * With no arguments (`rosclean`) the script will ask whether you want to delete the workspace's `devel/install/build/log` directories as well as the global `~/.ros/log` directory while also printing their sizes. 120 | * With the `-y` flag (`rosclean -y`) it will not prompt you and just delete things! 121 | * To just print the sizes without deleting anything, you can run `rosclean check` or `rosclean -c`. 122 | * You can also avoid the computation of folder sizes with the `-n` flag. 123 | * You can also provide a list of packages (`rosclean std_msgs nav2_core`) and it will attempt to delete just those portions of the workspace. 124 | 125 | You can also throw the word `purge` at the beginning just to mirror the ROS 1 `rosclean` more closely. 126 | 127 | ## rostopic 128 | The commands for `rostopic` are strikingly similar for ROS 1 and ROS 2, but are provided here as a convenience. 129 | 130 | 131 | # Configuration 132 | Users may change the default behavior of `ros_command` by putting settings in yaml files in two places. 133 | * `ros_command.yaml` in the workspace root (highest precedence) 134 | * `~/.ros/ros_command.yaml` 135 | 136 | The current settings you may change are summarized in this table. 137 | 138 | | key | type | default | note | 139 | |----------------------|------------------------|---------|----------------------------------------------------------------| 140 | | cmake_build_type | string | Release | [CMAKE_BUILD_TYPE](https://cmake.org/cmake/help/latest/variable/CMAKE_BUILD_TYPE.html) | 141 | | graphic_build | boolean | True | By default, `rosbuild` shows a fancy graphical interface | 142 | | success_sound | string / absolute path | None | Sound file path to play after successful builds | 143 | | fail_sound | string / absolute path | None | Sound file path to play after **un**successful builds | 144 | | extra_cmake_args | list of strings | [] | Args added to `--cmake-args` in build command | 145 | | extra_build_args | list of strings | [] | List of tokens added to the end of the build command | 146 | | tab_complete_timeout | string | 4h | Time to cache the tab completions. Examples: [`1h`, `2m30s`](https://stackoverflow.com/a/51916936) | 147 | 148 | 149 | # Power Usage 150 | If you like really short, convenient commands, try adding these to your `~/.bashrc` 151 | 152 | alias sros='source_ros' # Easier tab completion than source_ros 153 | alias asdf='rosbuild --this -c' # Builds the package in the current directory (and its dependencies) 154 | alias zxcv='rosbuild --this --no-deps' # Builds just the package in the current directory 155 | 156 | # Acknowledgements 157 | * `ros_command`'s core functionality was implemented at [PickNik Robotics](https://github.com/PickNikRobotics). 158 | * The implementation of `roscd` was originally developed by [Vatan Aksoy Tezer](https://github.com/vatanaksoytezer) 159 | -------------------------------------------------------------------------------- /src/ros_command/terminal_display.py: -------------------------------------------------------------------------------- 1 | from math import ceil 2 | import random 3 | 4 | from blessed import Terminal 5 | from blessed.formatters import COLORS, resolve_color 6 | 7 | 8 | # Some elements borrowed from https://github.com/FedericoCeratto/dashing/blob/master/dashing/dashing.py 9 | BOTTOM_LEFT = '└' 10 | BOTTOM_RIGHT = '┘' 11 | TOP_LEFT = '┌' 12 | TOP_RIGHT = '┐' 13 | HORIZONTAL = '─' 14 | VERTICAL = '│' 15 | HBAR_CHARS = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉'] 16 | LETTER_OFFSETS = [0x00061, 0x1D41A, 0x1D44E, 0x1D482, 0x1D5BA, 0x1D5EE, 0x1D622, 0x1D656, 17 | 0x1D4EA, 0x1D51E, 0x1D586, 0x1D68A, 0x1D552, 0x00041, 0x1D400, 0x1D434, 18 | 0x1D468, 0x1D5A0, 0x1D5D4, 0x1D608, 0x1D63C, 0x1D4D0, 0x1D56C, 0x1D670] 19 | SPINNER = '🕐🕑🕒🕓🕔🕕🕖🕗🕘🕙🕚🕛' 20 | 21 | 22 | class TerminalDisplay: 23 | """Wrapper around blessed.Terminal that allows for easy xy printing.""" 24 | 25 | def __init__(self): 26 | self.term = Terminal() 27 | print(self.home + self.clear) 28 | self.saved_size = None 29 | self.root = None 30 | 31 | def __call__(self, s, xy=None): 32 | """Mild hack to use the display object as a functor to call display('text', (5, 5)).""" 33 | if xy: 34 | print(self.term.move_xy(*xy), end='') 35 | print(s, end='') 36 | 37 | def __getattr__(self, k): 38 | """Get colors and other properties from the terminal object.""" 39 | return self.term.__getattr__(k) 40 | 41 | def draw(self): 42 | """Draw all the elements.""" 43 | if self.root: 44 | size = (self.term.width, self.term.height - 1) 45 | if not self.saved_size or self.saved_size != size: 46 | print(self.clear) 47 | self.root.set_geometry((0, 0), size) 48 | self.saved_size = size 49 | self.root.draw(self) 50 | print(self.term.normal) 51 | 52 | def get_colors(self, pattern): 53 | """Get all the colors where the color name matches a given pattern.""" 54 | for color_s in COLORS: 55 | m = pattern.match(color_s) 56 | if m: 57 | yield resolve_color(self.term, color_s) 58 | 59 | def finish(self): 60 | if self.root: 61 | self.root.finish() 62 | self.term.move_xy(0, self.term.height) 63 | 64 | 65 | class DynamicLayoutTerminalDisplay(TerminalDisplay): 66 | def __init__(self): 67 | TerminalDisplay.__init__(self) 68 | self.small_layout = None 69 | self.large_layout = None 70 | self.cutoff = 12 71 | 72 | def draw(self): 73 | size = (self.term.width, self.term.height - 1) 74 | if self.root and self.saved_size and self.saved_size == size: 75 | self.root.draw(self) 76 | print(self.term.normal) 77 | return 78 | 79 | # Dynanmic Layout 80 | print(self.clear) 81 | if size[1] < self.cutoff: 82 | self.root = self.small_layout 83 | else: 84 | self.root = self.large_layout 85 | self.root.set_geometry((0, 0), size) 86 | self.root.draw(self) 87 | self.saved_size = size 88 | 89 | 90 | class TerminalComponent: 91 | def __init__(self): 92 | self.set_geometry((0, 0), (0, 0)) 93 | 94 | def set_geometry(self, xy, size): 95 | self.x0, self.y0 = xy 96 | self.w, self.h = size 97 | 98 | def draw(self, display): 99 | raise NotImplementedError() 100 | 101 | def clear(self, display): 102 | s = ' ' * self.w 103 | for dy in range(self.h): 104 | display(s, (self.x0, self.y0 + dy)) 105 | 106 | def finish(self): 107 | pass 108 | 109 | 110 | class Box(TerminalComponent): 111 | def __init__(self, title=None, border_color=None): 112 | TerminalComponent.__init__(self) 113 | self.base_title = title 114 | self.title = title 115 | self.border_color = border_color 116 | 117 | def draw(self, display): 118 | # top border 119 | s = TOP_LEFT + (HORIZONTAL * (self.w - 2)) + TOP_RIGHT 120 | if self.title: 121 | s = s[:2] + ' ' + self.title + ' ' + s[len(self.title) + 4:] 122 | display(self.border_color + s, xy=(self.x0, self.y0)) 123 | 124 | # left and right 125 | for dy in range(1, self.h - 1): 126 | display(VERTICAL, xy=(self.x0, self.y0 + dy)) 127 | display(VERTICAL, xy=(self.x0 + self.w - 1, self.y0 + dy)) 128 | 129 | display(BOTTOM_LEFT + (HORIZONTAL * (self.w - 2)) + BOTTOM_RIGHT, xy=(self.x0, self.y0 + self.h - 1)) 130 | 131 | 132 | class Text(Box): 133 | def __init__(self, min_column_width=10, *args, **kwargs): 134 | Box.__init__(self, *args, **kwargs) 135 | self.min_column_width = min_column_width 136 | self.values = [] 137 | 138 | def update(self, values): 139 | self.title = f'{self.base_title} {len(values)}' 140 | self.values = values 141 | 142 | def draw(self, display): 143 | Box.draw(self, display) 144 | 145 | columns = 1 146 | while len(self.values) // columns > self.h - 2 and (self.w - 2) // (columns + 1) > self.min_column_width: 147 | columns += 1 148 | 149 | index = 0 150 | cwidth = (self.w - 2) // columns 151 | for column in range(columns): 152 | for row in range(1, self.h - 1): 153 | if index < len(self.values): 154 | s = self.values[index] 155 | if len(s) > cwidth - 1: 156 | s = s[:cwidth - 2] + '…' 157 | index += 1 158 | else: 159 | s = '' 160 | s += ' ' * (cwidth - len(s)) 161 | display(s, xy=(self.x0 + 1 + cwidth * column, self.y0 + row)) 162 | 163 | 164 | class Log(Box): 165 | def __init__(self, text_color=None, *args, **kwargs): 166 | Box.__init__(self, *args, **kwargs) 167 | self.text_color = text_color 168 | self.lines = [] 169 | 170 | def update(self, lines): 171 | max_lines = self.h - 2 172 | self.lines = [] 173 | # Only save useful number of lines 174 | for line in lines[-max_lines:]: 175 | # Replace tabs with spaces for proper length computations 176 | fixed_line = line.replace('\t', ' ') 177 | self.lines.append(fixed_line) 178 | 179 | def draw(self, display): 180 | Box.draw(self, display) 181 | if self.text_color: 182 | display(self.text_color) 183 | for h in range(1, self.h - 1): 184 | if h - 1 < len(self.lines): 185 | s = self.lines[h - 1][:self.w - 2] 186 | else: 187 | s = '' 188 | s += ' ' * (self.w - 2 - len(s)) 189 | display(s, xy=(self.x0 + 1, self.y0 + h)) 190 | 191 | 192 | class Gauge(Box): 193 | def __init__(self, *args, **kwargs): 194 | Box.__init__(self, *args, **kwargs) 195 | self.base_title = self.title 196 | self.level = 0.0 197 | 198 | def update(self, numerator, denominator): 199 | self.title = f'{self.base_title} {numerator}/{denominator}' 200 | if denominator: 201 | self.level = numerator / denominator 202 | 203 | def draw(self, display): 204 | if self.h >= 3: 205 | Box.draw(self, display) 206 | w = self.w - 2 207 | ys = range(1, self.h - 1) 208 | x1 = self.x0 + 1 209 | elif self.title: 210 | print(self.border_color) 211 | if self.h >= 1: 212 | w = self.w - len(self.title) 213 | x1 = self.x0 + len(self.title) 214 | else: 215 | w = self.w 216 | x1 = self.x0 217 | ys = range(self.h) 218 | else: 219 | print(self.border_color) 220 | w = self.w 221 | ys = range(self.h) 222 | x1 = self.x0 223 | 224 | filled = w * self.level 225 | i_filled = int(filled) 226 | s = i_filled * HBAR_CHARS[-1] 227 | remainder = filled - i_filled 228 | if len(s) < w: 229 | s += HBAR_CHARS[int(remainder * (len(HBAR_CHARS) - 1))] 230 | s += ' ' * (w - len(s)) 231 | for dy in ys: 232 | display(s, xy=(x1, self.y0 + dy)) 233 | if self.h < 3 and self.title: 234 | display(self.title, (self.x0, self.y0)) 235 | 236 | 237 | class StatusBoard(Box): 238 | def __init__(self, keys, status_colors, *args, **kwargs): 239 | Box.__init__(self, *args, **kwargs) 240 | self.keys = keys 241 | self.status_colors = status_colors 242 | self.statuses = {} 243 | self.spinner_index = 0 244 | 245 | self.column_width = 4 + max(len(s) for s in self.keys) if self.keys else 1 246 | 247 | def advance(self): 248 | self.spinner_index = (self.spinner_index + 1) % len(SPINNER) 249 | 250 | def draw(self, display): 251 | self.clear(display) 252 | Box.draw(self, display) 253 | 254 | row_index = list(range(self.y0 + 1, self.y0 + self.h - 1)) 255 | num_rows = len(row_index) 256 | displayable_columns = ceil(self.w / self.column_width) 257 | skippable_columns = 0 258 | column_values = set() 259 | table = [[]] 260 | for key in self.keys: 261 | status = self.statuses.get(key) 262 | column_values.add(status) 263 | status_color, status_char = self.get_status_formatting(status) 264 | table[-1].append(f'{status_color}{status_char} {key}') 265 | if len(table[-1]) >= num_rows: 266 | if None not in column_values and 'active' not in column_values: 267 | skippable_columns += 1 268 | column_values = set() 269 | table.append([]) 270 | 271 | start_column_index = max(skippable_columns - displayable_columns + 2, 0) 272 | for col_i, column in enumerate(table[start_column_index:]): 273 | for row_i, s in enumerate(column): 274 | x = self.x0 + col_i * self.column_width + 2 275 | if x + self.column_width > self.w: 276 | break 277 | display(s, xy=(x, row_index[row_i])) 278 | 279 | def get_status_formatting(self, status): 280 | color = self.status_colors.get(status, self.status_colors[None]) 281 | 282 | if status == 'failure': 283 | status_char = '❌' 284 | elif status == 'success': 285 | status_char = '✅' 286 | elif status == 'active': 287 | status_char = SPINNER[self.spinner_index] 288 | else: 289 | status_char = '🔲' 290 | return color, status_char 291 | 292 | 293 | class Splitter(TerminalComponent): 294 | def __init__(self, *elements, weights=None): 295 | self.elements = elements 296 | self.weights = weights 297 | 298 | def get_sizes(self, total_size): 299 | N = len(self.elements) 300 | if N > total_size: 301 | return [1] * total_size + [0] * (N - total_size) 302 | if self.weights is None: 303 | ratios = [1 / N] * N 304 | else: 305 | denominator = sum(self.weights) 306 | ratios = [weight / denominator for weight in self.weights] 307 | 308 | factor = 1.0 309 | sizes = [max(1, int(ratio * total_size * factor)) for ratio in ratios] 310 | while sum(sizes) >= total_size: 311 | factor *= 0.95 312 | sizes = [max(1, int(ratio * total_size * factor)) for ratio in ratios] 313 | 314 | # If leftover, add to last section 315 | remainder = total_size - sum(sizes) 316 | if remainder: 317 | sizes[-1] += remainder 318 | return sizes 319 | 320 | def draw(self, display): 321 | for element in self.elements: 322 | element.draw(display) 323 | 324 | def finish(self): 325 | for element in self.elements: 326 | element.finish() 327 | 328 | 329 | class HSplit(Splitter): 330 | def set_geometry(self, xy, size): 331 | x0, y0 = xy 332 | w, h = size 333 | 334 | for element, w1 in zip(self.elements, self.get_sizes(w)): 335 | element.set_geometry((x0, y0), (w1, h)) 336 | x0 += w1 337 | 338 | 339 | class VSplit(Splitter): 340 | def set_geometry(self, xy, size): 341 | x0, y0 = xy 342 | w, h = size 343 | 344 | for element, h1 in zip(self.elements, self.get_sizes(h)): 345 | element.set_geometry((x0, y0), (w, h1)) 346 | y0 += h1 347 | 348 | 349 | class Marquee: 350 | def __init__(self, text, colors): 351 | self.char_codes = [ord(c) - ord('a') for c in text.lower()] 352 | self.colors = list(colors) 353 | self.n = len(text) 354 | self.index = 0 355 | self.start_offset, self.start_color = self.get_random_offset_and_color() 356 | self.end_offset, self.end_color = self.get_random_offset_and_color() 357 | 358 | def get_random_offset_and_color(self): 359 | return random.choice(LETTER_OFFSETS), random.choice(self.colors) 360 | 361 | def advance(self): 362 | self.index += 1 363 | if self.index <= self.n: 364 | return 365 | 366 | # Reset 367 | self.end_offset = self.start_offset 368 | self.end_color = self.start_color 369 | self.start_offset, self.start_color = self.get_random_offset_and_color() 370 | self.index = 0 371 | 372 | def finish(self): 373 | self.index = self.n 374 | 375 | def __repr__(self): 376 | s = self.start_color 377 | for i, cc in enumerate(self.char_codes): 378 | if i == self.index: 379 | s += self.end_color 380 | 381 | if i < self.index: 382 | offset = self.start_offset 383 | else: 384 | offset = self.end_offset 385 | 386 | s += chr(cc + offset) 387 | return s 388 | -------------------------------------------------------------------------------- /src/ros_command/build_tool.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import re 3 | import sys 4 | import time 5 | 6 | import click 7 | 8 | from betsy_ros import BuildType 9 | 10 | from ros_command.build_status_display import BuildStatusDisplay, STATUS_COLORS 11 | from ros_command.command_lib import get_output, run 12 | from ros_command.completion import LocalPackageCompleter 13 | from ros_command.util import get_config 14 | 15 | 16 | PKG_PATTERN = r'\s+([\w\-]+)\s+' 17 | BRACKET_PATTERN = r'\[\s*(.*)\s*\]\s*' 18 | STATUS_PATTERNS = [ 19 | ('start', re.compile(r'Starting *>>>' + PKG_PATTERN)), 20 | ('stop', re.compile(r'Finished *<<<' + PKG_PATTERN + BRACKET_PATTERN)), 21 | ('fail', re.compile(r'Failed *<<<' + PKG_PATTERN + BRACKET_PATTERN)), 22 | ('abort', re.compile(r'Aborted *<<<' + PKG_PATTERN + BRACKET_PATTERN)), 23 | ('abort', re.compile(r'Abandoned *<<<' + PKG_PATTERN + BRACKET_PATTERN)) 24 | ] 25 | SKIPPABLE_PATTERNS = [ 26 | re.compile(r'^Summary:.*'), 27 | re.compile(r'^\[build( [\d\.:]+ s)?\].*'), 28 | re.compile(r'^\[Processing: .*\]'), 29 | re.compile(r'^\s*\d+ packages? ([^\:]+):.*'), 30 | re.compile(r'^\s+\d+ packages? not processed.*') 31 | ] 32 | 33 | 34 | class BuildStatus: 35 | def __init__(self): 36 | self.start_time = time.time() 37 | self.upstream_deps = {} 38 | self.pkg_lists = collections.defaultdict(list) 39 | self.error_buffer = [] 40 | self.n = 0 41 | 42 | def out_callback(self, line): 43 | self.output_callback(line, False) 44 | 45 | def err_callback(self, line): 46 | self.output_callback(line, True) 47 | 48 | def output_callback(self, line, is_err): 49 | # Split by \r if needed 50 | if '\r' in line: 51 | for bit in line.split('\r'): 52 | if bit: 53 | self.output_callback(bit, is_err) 54 | return 55 | 56 | for pattern in SKIPPABLE_PATTERNS: 57 | if pattern.match(line): 58 | return 59 | for name, pattern in STATUS_PATTERNS: 60 | m = pattern.search(line) 61 | if m: 62 | getattr(self, name)(m.group(1)) 63 | return 64 | 65 | self.add_error_line(line.rstrip()) 66 | 67 | def set_dependencies(self, upstream): 68 | self.upstream_deps = {} 69 | self.n = len(upstream) 70 | for pkg, deps in upstream.items(): 71 | if deps: 72 | self.pkg_lists['blocked'].append(pkg) 73 | self.upstream_deps[pkg] = deps 74 | else: 75 | self.pkg_lists['queued'].append(pkg) 76 | 77 | def start(self, pkg): 78 | if pkg in self.pkg_lists['queued']: 79 | self.pkg_lists['queued'].remove(pkg) 80 | self.pkg_lists['active'].append(pkg) 81 | 82 | def stop(self, pkg): 83 | if pkg not in self.pkg_lists['active']: 84 | return 85 | self.pkg_lists['active'].remove(pkg) 86 | self.pkg_lists['finished'].append(pkg) 87 | for pkg2, deps in list(self.upstream_deps.items()): 88 | if pkg in deps: 89 | deps.remove(pkg) 90 | if not deps: 91 | del self.upstream_deps[pkg2] 92 | self.pkg_lists['blocked'].remove(pkg2) 93 | self.pkg_lists['queued'].append(pkg2) 94 | 95 | def fail(self, pkg): 96 | self.pkg_lists['active'].remove(pkg) 97 | self.pkg_lists['failed'].append(pkg) 98 | for pkg2, deps in list(self.upstream_deps.items()): 99 | if pkg in deps: 100 | self.pkg_lists['blocked'].remove(pkg2) 101 | self.pkg_lists['skipped'].append(pkg2) 102 | if not deps: 103 | del self.upstream_deps[pkg2] 104 | 105 | def abort(self, pkg): 106 | if pkg not in self.pkg_lists['blocked']: 107 | return 108 | self.pkg_lists['blocked'].remove(pkg) 109 | self.pkg_lists['skipped'].append(pkg) 110 | 111 | def add_error_line(self, line): 112 | self.error_buffer.append(line) 113 | 114 | def get_elapsed_time(self): 115 | dt = time.time() - self.start_time 116 | hours, remainder = divmod(dt, 3600) 117 | minutes, seconds = divmod(remainder, 60) 118 | if hours or minutes: 119 | hours = int(hours) 120 | minutes = int(minutes) 121 | seconds = int(seconds) 122 | s = f'{minutes:02d}:{seconds:02d}' 123 | if hours: 124 | return f'{hours}:' + s 125 | else: 126 | return s 127 | else: 128 | return f'{seconds:.1f} s' 129 | 130 | def get_all_packages(self): 131 | pkgs = [] 132 | for pkg_list in self.pkg_lists.values(): 133 | pkgs += pkg_list 134 | return pkgs 135 | 136 | def print_status(self): 137 | n_fin = len(self.pkg_lists['finished']) 138 | dt = self.get_elapsed_time() 139 | click.secho('Summary: ', fg='white', bold=True, nl=False) 140 | click.secho(f'{n_fin} packages finished ', nl=False, fg='blue', bold=False) 141 | click.secho(f'[{dt}]', fg='white', bold=False) 142 | for category, pkgs in self.pkg_lists.items(): 143 | if not pkgs or category == 'finished': 144 | continue 145 | n = len(pkgs) 146 | suffix = '' if n == 1 else 's' 147 | pkgs_s = ' '.join(sorted(pkgs)) 148 | click.secho(f' {n:4} package{suffix} {category}', fg=STATUS_COLORS.get(category, 'white'), nl=False) 149 | click.secho(f': {pkgs_s}') 150 | 151 | 152 | def parse_colcon_graph(s): 153 | lines = [line for line in s.split('\n') if line] 154 | if not lines: 155 | return {} 156 | start_index = lines[0].index('+') 157 | pkgs = [line[:start_index].strip() for line in lines] 158 | upstream = {pkg: set() for pkg in pkgs} 159 | for i, line in enumerate(lines): 160 | dep = pkgs[i] 161 | for j in range(start_index + i, len(line)): 162 | c = line[j] 163 | pkg = pkgs[j - start_index] 164 | if c in '+ ': 165 | continue 166 | upstream[pkg].add(dep) 167 | return upstream 168 | 169 | 170 | async def get_colcon_graph(workspace_root, package_selection_args): 171 | _, output, _ = await get_output(['colcon', 'graph'] + package_selection_args, cwd=workspace_root) 172 | return parse_colcon_graph(output) 173 | 174 | 175 | async def get_catkin_tools_graph(workspace_root, package_selection_args): 176 | upstream = {} 177 | pkg_name = None 178 | build_depends = set() 179 | run_section = False 180 | error_text = '' 181 | 182 | def stdout_callback(line): 183 | nonlocal pkg_name, build_depends, run_section 184 | if line[0] != ' ': 185 | if pkg_name: 186 | upstream[pkg_name] = build_depends 187 | pkg_name = line.strip() 188 | if pkg_name[-1] == ':': 189 | pkg_name = pkg_name[:-1] 190 | build_depends = set() 191 | run_section = False 192 | elif line == ' build_depend:\n': 193 | return 194 | elif line == ' run_depend:\n': 195 | run_section = True 196 | elif run_section: 197 | return 198 | else: 199 | dep_name = line.strip()[2:] 200 | build_depends.add(dep_name) 201 | 202 | def stderr_callback(line): 203 | nonlocal error_text 204 | error_text += line 205 | 206 | cmd = ['catkin', 'list', '--rdeps', '--unformatted'] 207 | if package_selection_args: 208 | cmd += [arg for arg in package_selection_args if arg != '--no-deps'] 209 | 210 | ret = await run(cmd, cwd=workspace_root, 211 | stdout_callback=lambda line: stdout_callback(line), stderr_callback=stderr_callback) 212 | 213 | if ret != 0 or error_text: 214 | click.secho(error_text, fg='red') 215 | raise RuntimeError('Error retrieving dependencies!') 216 | 217 | if pkg_name: 218 | upstream[pkg_name] = build_depends 219 | 220 | # Check package_selection_args, limit upstream as needed 221 | return upstream 222 | 223 | 224 | def add_package_selection_args(parser, workspace_root=None): 225 | completer = LocalPackageCompleter(workspace_root) 226 | 227 | parser.add_argument('--this', action='store_true') 228 | parser.add_argument('-n', '--no-deps', action='store_true') 229 | parser.add_argument('-s', '--skip-packages', nargs='+').completer = completer 230 | parser.add_argument('include_packages', metavar='include_package', nargs='*').completer = completer 231 | return parser 232 | 233 | 234 | def get_package_selection_args(args, build_type, pkg_name): 235 | package_selection_args = [] 236 | 237 | if args.this and not pkg_name: 238 | raise RuntimeError('Not currently in a package') 239 | elif args.no_deps and not (args.this or args.include_packages): 240 | raise RuntimeError('With --no-deps, you must specify packages to build.') 241 | 242 | if build_type == BuildType.COLCON: 243 | if args.no_deps: 244 | package_selection_args.append('--packages-select') 245 | elif args.this or args.include_packages: 246 | package_selection_args.append('--packages-up-to') 247 | if args.this: 248 | package_selection_args.append(pkg_name) 249 | if args.include_packages: 250 | package_selection_args += args.include_packages 251 | if args.skip_packages: 252 | package_selection_args.append('--packages-skip') 253 | package_selection_args += args.skip_packages 254 | elif build_type == BuildType.CATKIN_TOOLS: 255 | if args.no_deps: 256 | package_selection_args.append('--no-deps') 257 | if args.this: 258 | package_selection_args.append(pkg_name) 259 | if args.include_packages: 260 | package_selection_args += args.include_packages 261 | if args.skip_packages: 262 | raise NotImplementedError 263 | elif build_type == BuildType.CATKIN_MAKE: 264 | pkg_list = [] 265 | if args.this: 266 | pkg_list.append(pkg_name) 267 | if args.include_packages: 268 | pkg_list += args.include_packages 269 | if pkg_list: 270 | if args.no_deps: 271 | package_selection_args.append('--pkg') 272 | else: 273 | package_selection_args.append('--only-pkg-with-deps') 274 | package_selection_args += pkg_list 275 | if args.skip_packages: 276 | pkg_s = ';'.join(args.skip_packages) 277 | package_selection_args.append(f'-DCATKIN_BLACKLIST_PACKAGES="{pkg_s}"') 278 | 279 | return package_selection_args 280 | 281 | 282 | def generate_build_command(build_type, unknown_args, package_selection_args=[], continue_on_failure=False, jobs=None, 283 | cmake_build_type=None, workspace_root=None): 284 | cmake_args = [] 285 | if cmake_build_type is None: 286 | cmake_build_type = get_config('cmake_build_type', 'Release', workspace_root) 287 | if cmake_build_type: 288 | cmake_args.append(f'-DCMAKE_BUILD_TYPE={cmake_build_type}') 289 | 290 | cmake_args += get_config('extra_cmake_args', [], workspace_root) 291 | extra_build_args = get_config('extra_build_args', [], workspace_root) 292 | 293 | if build_type == BuildType.COLCON: 294 | command = ['colcon', 'build', '--event-handlers', 'desktop_notification-', 'status-'] 295 | if continue_on_failure: 296 | command.append('--continue-on-error') 297 | command += package_selection_args 298 | if jobs is not None: 299 | command += ['--parallel-workers', str(jobs)] 300 | command += unknown_args + extra_build_args 301 | 302 | if cmake_args: 303 | command += ['--cmake-args'] + cmake_args 304 | elif build_type == BuildType.CATKIN_TOOLS: 305 | command = ['catkin', 'build'] 306 | if continue_on_failure: 307 | command.append('--continue-on-failure') 308 | command += package_selection_args 309 | if jobs is not None: 310 | command += ['--jobs', str(jobs)] 311 | command += unknown_args + extra_build_args 312 | if cmake_args: 313 | command += ['--cmake-args'] + cmake_args 314 | elif build_type == BuildType.CATKIN_MAKE: 315 | command = ['catkin_make'] 316 | if continue_on_failure: 317 | raise NotImplementedError() 318 | command += package_selection_args 319 | if jobs is not None: 320 | command += ['--jobs', str(jobs)] 321 | command += unknown_args + extra_build_args 322 | command += cmake_args 323 | else: 324 | raise NotImplementedError(f'Unsupported build type {build_type}') 325 | return command 326 | 327 | 328 | async def run_build_command(build_type, workspace_root, extra_args=[], package_selection_args=[], 329 | continue_on_failure=True, jobs=None, cmake_build_type=None, toggle_graphics=False, 330 | return_build_status=False): 331 | command = generate_build_command(build_type, extra_args, package_selection_args, continue_on_failure, jobs, 332 | cmake_build_type, workspace_root) 333 | stdout_callback = None 334 | stderr_callback = None 335 | 336 | graphic_build = get_config('graphic_build', True) 337 | if toggle_graphics: 338 | graphic_build = not graphic_build 339 | 340 | if graphic_build and build_type != BuildType.CATKIN_MAKE: 341 | build_status = BuildStatus() 342 | display = BuildStatusDisplay(build_status) 343 | if build_type == BuildType.COLCON: 344 | build_status.set_dependencies(await get_colcon_graph(workspace_root, package_selection_args)) 345 | stdout_callback = build_status.out_callback 346 | stderr_callback = build_status.err_callback 347 | elif build_type == BuildType.CATKIN_TOOLS: 348 | build_status.set_dependencies(await get_catkin_tools_graph(workspace_root, package_selection_args)) 349 | stdout_callback = build_status.out_callback 350 | stderr_callback = build_status.err_callback 351 | else: 352 | # Graphical interface not implemented for catkin_make 353 | build_status = None 354 | display = None 355 | 356 | code = await run(command, cwd=workspace_root, 357 | stdout_callback=stdout_callback, stderr_callback=stderr_callback) 358 | 359 | if display: 360 | display.finish() 361 | for line in build_status.error_buffer: 362 | print(line, file=sys.stderr) 363 | build_status.print_status() 364 | 365 | if return_build_status: 366 | return code, build_status 367 | else: 368 | return code 369 | -------------------------------------------------------------------------------- /src/ros_command/commands/rosinterface.py: -------------------------------------------------------------------------------- 1 | import argcomplete 2 | import argparse 3 | import asyncio 4 | import pathlib 5 | import re 6 | 7 | import click 8 | 9 | from betsy_ros import ROSInterface, get_ros_version, get_workspace_root 10 | from betsy_ros.interfaces import list_interfaces as list_ros_interfaces # Avoid backwards incompatible name 11 | 12 | from ros_command.command_lib import get_output, run 13 | from ros_command.completion import PackageCompleter, Completer 14 | 15 | 16 | ACTION_PARTS = ['Goal', 'Result', 'Feedback'] 17 | ACTION_LINE = '# ====== DO NOT MODIFY! AUTOGENERATED FROM AN ACTION DEFINITION ======' 18 | 19 | COMMENT_PATTERN = re.compile(r'^([^#]*[^#\s])?' # The contents of the line (no #, does not end in whitespace) 20 | r'(\s*#.*)$') # Any leading whitespace, a # then the comment contents 21 | 22 | FIELD_LINE = re.compile(r'(?P[\w_/]+)' # geometry_msgs/Point 23 | r'(?P\[\d*\])?' # [4] 24 | r'\s+' 25 | r'(?P[\w_]+)' # points 26 | r'\s*=?\s*' 27 | r'(?P.*)?' 28 | r'$', 29 | re.DOTALL) 30 | PRIMITIVES = ['bool', 31 | 'byte', 32 | 'int8', 'uint8', 33 | 'int16', 'uint16', 34 | 'int32', 'uint32', 35 | 'int64', 'uint64', 36 | 'float32', 'float64', 37 | 'string', 38 | 'time', 'duration' 39 | ] 40 | 41 | 42 | def get_action_parts(base_interface): 43 | for action_part in ACTION_PARTS: 44 | yield ROSInterface(base_interface.package, 'msg', f'{base_interface.name}{action_part}') 45 | 46 | 47 | class InterfaceInterface: 48 | def __init__(self, version, distro, interface_type): 49 | self.version = version 50 | self.distro = distro 51 | self.interface_type = interface_type 52 | self.interface_definition_cache = {} 53 | 54 | def get_base_command(self, verb, interface_type=None, filter_flag=False): 55 | if interface_type is None: 56 | interface_type = self.interface_type 57 | if self.version == 1: 58 | return [f'/opt/ros/{self.distro}/bin/ros{interface_type}', verb] 59 | else: 60 | cmd = [f'/opt/ros/{self.distro}/bin/ros2', 'interface', verb] 61 | if filter_flag: 62 | cmd.append('-' + interface_type[0]) # -m for msg, -s for srv, etc 63 | return cmd 64 | 65 | def parse_interface(self, s, interface_type=None): 66 | return ROSInterface.from_string(s, interface_type or self.interface_type) 67 | 68 | def list_interfaces(self, name_filter=None): 69 | # Implemented with underlying logic to avoid await command 70 | interfaces = list(list_ros_interfaces(self.version, [self.interface_type])) 71 | 72 | if name_filter is None: 73 | return interfaces 74 | 75 | filtered = [] 76 | for interface in interfaces: 77 | if interface.name == name_filter: 78 | filtered.append(interface) 79 | return filtered 80 | 81 | def translate_to_full_names(self, base_s): 82 | pieces = base_s.split('/') 83 | 84 | # If no /, then only the interface name is specified and list all the interfaces that have the same name 85 | if len(pieces) == 1: 86 | return self.list_interfaces(base_s) 87 | else: 88 | return [self.parse_interface(base_s)] 89 | 90 | async def get_interface_definition(self, interface): 91 | command = self.get_base_command('show', interface_type=interface.type) 92 | if self.version == 1: 93 | command.append('-r') # Raw flag (to preserve comments) 94 | command.append(interface.to_string()) 95 | else: 96 | command.append(interface.to_string(two_part=False)) 97 | ret, output, err = await get_output(command) 98 | if err: 99 | click.secho(err, fg='red') 100 | return output.strip().split('\n') 101 | 102 | async def display_type(self, interface, indentation=0, recurse=False, comments=True): 103 | if interface not in self.interface_definition_cache: 104 | self.interface_definition_cache[interface] = await self.get_interface_definition(interface) 105 | 106 | for line in self.interface_definition_cache[interface]: 107 | if line == ACTION_LINE: 108 | continue 109 | elif not comments and line.strip() and line.strip()[0] == '#': 110 | continue 111 | 112 | click.secho(' ' * indentation, nl=False) 113 | m = COMMENT_PATTERN.match(line) 114 | if m: 115 | line, comment = m.groups() 116 | line = line or '' 117 | else: 118 | comment = None 119 | 120 | sub_interface = None 121 | m = FIELD_LINE.match(line) 122 | if m: 123 | fields = m.groupdict() 124 | if fields['field_type'] not in PRIMITIVES: 125 | if '/' not in fields['field_type']: 126 | # Determine inferred package 127 | name = fields['field_type'] 128 | if name == 'Header': 129 | pkg = 'std_msgs' 130 | else: 131 | pkg = interface.package 132 | 133 | sub_interface = ROSInterface(pkg, 'msg', name) 134 | fields['field_type'] = str(sub_interface) 135 | else: 136 | sub_interface = self.parse_interface(fields['field_type'], 'msg') 137 | 138 | click.secho(fields['field_type'], nl=False) 139 | if fields['array']: 140 | click.secho(fields['array'], nl=False) 141 | click.secho(' ' + fields['name'], nl=False, fg='bright_white') 142 | if fields['value']: 143 | click.secho(' = ', nl=False) 144 | click.secho(fields['value'], fg='cyan', nl=False) 145 | elif line == '---': 146 | click.secho(line, nl=False, fg='blue') 147 | else: 148 | click.secho(line, nl=False) 149 | if not comments: 150 | click.secho('') # Newline 151 | else: 152 | click.secho(comment, fg='green') 153 | 154 | if recurse and sub_interface: 155 | await self.display_type(sub_interface, indentation + 4, recurse, comments) 156 | 157 | async def list_packages(self, interface_type=None): 158 | # List all packages with any messages 159 | _, out, _ = await get_output(self.get_base_command('packages', interface_type)) 160 | return sorted(set(filter(None, out.split('\n')))) 161 | 162 | async def list_actions(self, pkg=None): 163 | if pkg: 164 | packages = [pkg] 165 | else: 166 | packages = await self.list_packages('msg') 167 | 168 | results = [] 169 | for pkg in packages: 170 | _, path_s, _ = await get_output([f'/opt/ros/{self.distro}/bin/rospack', 'find', pkg]) 171 | path = pathlib.Path(path_s.strip()) / 'action' 172 | if path.exists(): 173 | for filename in path.glob('*.action'): 174 | results.append(ROSInterface(pkg, 'action', filename.stem)) 175 | return sorted(results) 176 | 177 | 178 | class InterfaceCompleter(Completer): 179 | def __init__(self, workspace_root, interface_interface): 180 | super().__init__(workspace_root, interface_interface.version) 181 | self.interface_interface = interface_interface 182 | 183 | def get_cache_keys(self, **kwargs): 184 | return [str(self.workspace_root), self.interface_interface.interface_type] 185 | 186 | def get_completions(self, **kwargs): 187 | return [interface.to_string(two_part=self.version == 1) 188 | for interface in self.interface_interface.list_interfaces()] 189 | 190 | def filter_values(self, values, prefix=None, **kwargs): 191 | matches = [] 192 | for full_name in values: 193 | interface = ROSInterface.from_string(full_name, interface_type=self.interface_interface.interface_type) 194 | 195 | if prefix: 196 | if interface.name.startswith(prefix): 197 | # If the name matches the prefix, add interface by name 198 | matches.append(interface.name) 199 | elif full_name.startswith(prefix): 200 | # If the full name matches the prefix, add the interface's full name 201 | matches.append(full_name) 202 | 203 | # if the prefix exists and does not match either condition, add nothing 204 | else: 205 | # If there is no prefix, add the interface by name and full_name 206 | matches.append(interface.name) 207 | matches.append(full_name) 208 | 209 | return matches 210 | 211 | 212 | async def main(interface_type): 213 | build_type, workspace_root = get_workspace_root() 214 | version, distro = get_ros_version() 215 | 216 | ii = InterfaceInterface(version, distro, interface_type) 217 | 218 | interface_completer = InterfaceCompleter(workspace_root, ii) 219 | 220 | parser = argparse.ArgumentParser() 221 | subparsers = parser.add_subparsers(dest='verb') 222 | show_parser = subparsers.add_parser('show', aliases=['info']) 223 | show_parser.add_argument('interface_name').completer = interface_completer 224 | show_parser.add_argument('-r', '--recurse', action='store_true') 225 | show_parser.add_argument('-c', '--ignore-comments', action='store_true') 226 | subparsers.add_parser('list') 227 | md5_parser = subparsers.add_parser('md5') 228 | md5_parser.add_argument('interface_name').completer = interface_completer 229 | pkg_parser = subparsers.add_parser('package') 230 | pkg_parser.add_argument('package_name').completer = PackageCompleter(workspace_root) 231 | proto_parser = subparsers.add_parser('proto') 232 | proto_parser.add_argument('interface_name').completer = interface_completer 233 | subparsers.add_parser('packages') 234 | 235 | argcomplete.autocomplete(parser) 236 | 237 | args = parser.parse_args() 238 | if args.verb == 'info': # Alias 239 | args.verb = 'show' 240 | 241 | if version == 1 and interface_type == 'action': 242 | # ROS 1 does not support action commands natively. 243 | if args.verb == 'show': 244 | for interface in ii.translate_to_full_names(args.interface_name): 245 | click.secho(f'[{interface}]', fg='blue') 246 | for component in get_action_parts(interface): 247 | await ii.display_type(component, recurse=args.recurse, comments=not args.ignore_comments) 248 | if not component.name.endswith('Feedback'): 249 | click.secho('---', fg='blue') 250 | elif args.verb == 'md5': 251 | interface = ii.parse_interface(args.interface_name) 252 | for component in get_action_parts(interface): 253 | name = str(component) 254 | cmd = ii.get_base_command(args.verb, interface_type='msg') 255 | cmd.append(name) 256 | click.secho(f'[{name}] ', nl=False) 257 | await run(cmd) 258 | elif args.verb == 'list': 259 | for interface in await ii.list_actions(): 260 | print(interface) 261 | elif args.verb == 'package': 262 | for interface in await ii.list_actions(pkg=args.package_name): 263 | print(interface) 264 | elif args.verb == 'packages': 265 | pkgs = set(interface.package for interface in await ii.list_actions()) 266 | print('\n'.join(sorted(pkgs))) 267 | elif args.verb == 'proto': 268 | raise NotImplementedError('Proto is not implemented for ROS 1, although it could be.') 269 | else: 270 | raise RuntimeError(f'Unknown verb "{args.verb}" for ROS 1 action') 271 | 272 | elif args.verb == 'show': 273 | for interface in ii.translate_to_full_names(args.interface_name): 274 | click.secho(f'[{interface}]', fg='blue') 275 | await ii.display_type(interface, recurse=args.recurse, comments=not args.ignore_comments) 276 | elif args.verb in ['list', 'packages']: 277 | # Pass through to ros2 interface with appropriate args 278 | command = ii.get_base_command(args.verb, filter_flag=True) 279 | await run(command) 280 | elif version == 1: 281 | # Pass through for rosmsg/rossrv 282 | cmd = ii.get_base_command(args.verb) 283 | if args.verb == 'md5': 284 | cmd.append(args.interface_name) 285 | elif args.verb == 'package': 286 | cmd.append(args.package_name) 287 | elif args.verb == 'proto': 288 | raise NotImplementedError('Proto is not implemented for ROS 1, although it could be.') 289 | 290 | code = await run(cmd) 291 | exit(code) 292 | 293 | elif args.verb == 'package': 294 | command = ii.get_base_command('package') 295 | command.append(args.package_name) 296 | _, out, _ = await get_output(command) 297 | # ROS 2 does not provide the type-specific flags for this command (yet) so 298 | # for now we manually filter out the interfaces with the wrong type 299 | for interface in map(ii.parse_interface, filter(None, sorted(out.split('\n')))): 300 | if interface.type == interface_type: 301 | print(interface) 302 | elif args.verb == 'proto': 303 | full_names = ii.translate_to_full_names(args.interface_name) 304 | if not full_names: 305 | click.secho(f'Cannot find interface "{args.interface_name}"', fg='red') 306 | exit(-1) 307 | elif len(full_names) > 1: 308 | click.secho(f'Interface "{args.interface_name}" is ambiguous. Options:', fg='yellow') 309 | for interface in full_names: 310 | click.secho(f' - {interface}', fg='yellow') 311 | exit(-1) 312 | 313 | command = ii.get_base_command('proto') 314 | command.append(full_names[0].to_string(two_piece=False)) 315 | await run(command) 316 | else: 317 | raise NotImplementedError('No equivalent md5 command in ros2') 318 | 319 | 320 | def main_msg(): 321 | loop = asyncio.new_event_loop() 322 | asyncio.set_event_loop(loop) 323 | loop.run_until_complete(main('msg')) 324 | 325 | 326 | def main_srv(): 327 | loop = asyncio.new_event_loop() 328 | asyncio.set_event_loop(loop) 329 | loop.run_until_complete(main('srv')) 330 | 331 | 332 | def main_action(): 333 | loop = asyncio.new_event_loop() 334 | asyncio.set_event_loop(loop) 335 | loop.run_until_complete(main('action')) 336 | --------------------------------------------------------------------------------