Skip to content

Commit

Permalink
Merge branch 'master' into topic_list_verbose
Browse files Browse the repository at this point in the history
  • Loading branch information
Claire Wang authored Feb 7, 2020
2 parents 22d5a3c + 1d1747c commit 30987f4
Show file tree
Hide file tree
Showing 33 changed files with 315 additions and 176 deletions.
9 changes: 4 additions & 5 deletions ros2action/ros2action/command/action.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,18 @@
# See the License for the specific language governing permissions and
# limitations under the License.

from ros2cli.command import add_subparsers
from ros2cli.command import add_subparsers_on_demand
from ros2cli.command import CommandExtension
from ros2cli.verb import get_verb_extensions


class ActionCommand(CommandExtension):
"""Various action related sub-commands."""

def add_arguments(self, parser, cli_name):
self._subparser = parser
# Get verb extensions and let them add their arguments and sub-commands
verb_extensions = get_verb_extensions('ros2action.verb')
add_subparsers(parser, cli_name, '_verb', verb_extensions, required=False)
# add arguments and sub-commands of verbs
add_subparsers_on_demand(
parser, cli_name, '_verb', 'ros2action.verb', required=False)

def main(self, *, parser, args):
if not hasattr(args, '_verb'):
Expand Down
8 changes: 4 additions & 4 deletions ros2action/ros2action/verb/info.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,14 @@ def main(self, *, args):
for client_name, client_types in action_clients:
if args.show_types:
types_formatted = ', '.join(client_types)
print(' {client_name} [{types_formatted}]'.format_map(locals()))
print(f' {client_name} [{types_formatted}]')
else:
print(' {client_name}'.format_map(locals()))
print(f' {client_name}')
print('Action servers: {}'.format(len(action_servers)))
if not args.count:
for server_name, server_types in action_servers:
if args.show_types:
types_formatted = ', '.join(server_types)
print(' {server_name} [{types_formatted}]'.format_map(locals()))
print(f' {server_name} [{types_formatted}]')
else:
print(' {server_name}'.format_map(locals()))
print(f' {server_name}')
4 changes: 2 additions & 2 deletions ros2action/ros2action/verb/list.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,6 @@ def main(self, *, args):
for name, types in action_names_and_types:
if args.show_types:
types_formatted = ', '.join(types)
print('{name} [{types_formatted}]'.format_map(locals()))
print(f'{name} [{types_formatted}]')
else:
print('{name}'.format_map(locals()))
print(f'{name}')
16 changes: 7 additions & 9 deletions ros2cli/ros2cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,13 @@
import argparse
import signal

from ros2cli.command import add_subparsers
from ros2cli.command import get_command_extensions
from ros2cli.command import add_subparsers_on_demand


def main(*, script_name='ros2', argv=None, description=None, extension=None):
if description is None:
description = '{script_name} is an extensible command-line tool for ' \
'ROS 2.'.format_map(locals())
description = f'{script_name} is an extensible command-line tool ' \
'for ROS 2.'

# top level parser
parser = argparse.ArgumentParser(
Expand All @@ -35,14 +34,13 @@ def main(*, script_name='ros2', argv=None, description=None, extension=None):
if extension:
extension.add_arguments(parser, script_name)
else:
# get command extensions
extensions = get_command_extensions('ros2cli.command')
# get command entry points as needed
selected_extension_key = '_command'
add_subparsers(
parser, script_name, selected_extension_key, extensions,
add_subparsers_on_demand(
parser, script_name, selected_extension_key, 'ros2cli.command',
# hide the special commands in the help
hide_extensions=['extension_points', 'extensions'],
required=False)
required=False, argv=argv)

# register argcomplete hook if available
try:
Expand Down
195 changes: 188 additions & 7 deletions ros2cli/ros2cli/command/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@
# limitations under the License.

import argparse
import inspect
import os
import shlex
import types

from ros2cli.entry_points import get_entry_points
from ros2cli.entry_points import get_first_line_doc
from ros2cli.plugin_system import instantiate_extensions
from ros2cli.plugin_system import PLUGIN_SYSTEM_VERSION
Expand Down Expand Up @@ -42,15 +47,16 @@ def __init__(self):
super(CommandExtension, self).__init__()
satisfies_version(PLUGIN_SYSTEM_VERSION, '^0.1')

def add_arguments(self, parser, cli_name):
def add_arguments(self, parser, cli_name, *, argv=None):
pass

def main(self, *, parser, args):
raise NotImplementedError()


def get_command_extensions(group_name):
extensions = instantiate_extensions(group_name)
def get_command_extensions(group_name, *, exclude_names=None):
extensions = instantiate_extensions(
group_name, exclude_names=exclude_names)
for name, extension in extensions.items():
extension.NAME = name
return extensions
Expand All @@ -69,6 +75,12 @@ def add_subparsers(
For each extension a subparser is created.
If the extension has an ``add_arguments`` method it is being called.
This method is deprecated.
Use the function ``add_subparsers_on_demand`` instead.
Their signatures are almost identical.
Instead of passing the extensions the new function expects the group name
of these extensions.
:param parser: the parent argument parser
:type parser: :py:class:`argparse.ArgumentParser`
:param str cli_name: name of the command line command to which the
Expand All @@ -78,6 +90,10 @@ def add_subparsers(
:param dict command_extensions: dict of command extensions by their name
where each contributes a command with specific arguments
"""
import warnings
warnings.warn(
"'ros2cli.command.add_subparsers' is deprecated, use "
"'ros2cli.command.add_subparsers_on_demand' instead", stacklevel=2)
# add subparser with description of available subparsers
description = ''
if command_extensions:
Expand All @@ -90,10 +106,9 @@ def add_subparsers(
extension = command_extensions[name]
description += '%s %s\n' % (
name.ljust(max_length), get_first_line_doc(extension))
metavar = 'Call `{cli_name} <command> -h` for more detailed ' \
'usage.'.format_map(locals())
subparser = parser.add_subparsers(
title='Commands', description=description, metavar=metavar)
title='Commands', description=description,
metavar=f'Call `{cli_name} <command> -h` for more detailed usage.')
# use a name which doesn't collide with any argument
# but is readable when shown as part of the the usage information
subparser.dest = ' ' + dest.lstrip('_')
Expand All @@ -109,6 +124,172 @@ def add_subparsers(
command_parser.set_defaults(**{dest: extension})
if hasattr(extension, 'add_arguments'):
extension.add_arguments(
command_parser, '{cli_name} {name}'.format_map(locals()))
command_parser, f'{cli_name} {name}')

return subparser


class MutableString:
"""Behave like str with the ability to change the value of an instance."""

def __init__(self):
self.value = ''

def __getattr__(self, name):
return getattr(self.value, name)

def __iter__(self):
return self.value.__iter__()


def add_subparsers_on_demand(
parser, cli_name, dest, group_name, hide_extensions=None,
required=True, argv=None
):
"""
Create argparse subparser for each extension on demand.
The ``cli_name`` is used for the title and description of the
``add_subparsers`` function call.
For each extension a subparser is created is necessary.
If no extension has been selected by command line arguments all first level
extension must be loaded and instantiated.
If a specific extension has been selected by command line arguments the
sibling extension can be skipped and only that one extension (as well as
potentially its recursive extensions) are loaded and instantiated.
If the extension has an ``add_arguments`` method it is being called.
:param parser: the parent argument parser
:type parser: :py:class:`argparse.ArgumentParser`
:param str cli_name: name of the command line command to which the
subparsers are being added
:param str dest: name of the attribute under which the selected extension
will be stored
:param str group_name: the name of the ``entry_point`` group identifying
the extensions to be added
:param list hide_extensions: an optional list of extension names which
should be skipped
:param bool required: a flag if the command is a required argument
:param list argv: the list of command line arguments (default:
``sys.argv``)
"""
# add subparser without a description for now
mutable_description = MutableString()
subparser = parser.add_subparsers(
title='Commands', description=mutable_description,
metavar=f'Call `{cli_name} <command> -h` for more detailed usage.')
# use a name which doesn't collide with any argument
# but is readable when shown as part of the the usage information
subparser.dest = ' ' + dest.lstrip('_')
subparser.required = required

# add entry point specific sub-parsers but without a description and
# arguments for now
entry_points = get_entry_points(group_name)
command_parsers = {}
for name in sorted(entry_points.keys()):
command_parser = subparser.add_parser(
name,
formatter_class=argparse.RawDescriptionHelpFormatter)
command_parsers[name] = command_parser

# temporarily attach root parser to each command parser
# in order to parse known args
root_parser = getattr(parser, '_root_parser', parser)
with SuppressUsageOutput({parser} | set(command_parsers.values())):
args = argv
# for completion use the arguments provided by the argcomplete env var
if _is_completion_requested():
args = shlex.split(os.environ['COMP_LINE'])[1:]
try:
known_args, _ = root_parser.parse_known_args(args=args)
except SystemExit:
if not _is_completion_requested():
raise
# if the partial arguments can't be parsed use no known args
known_args = argparse.Namespace(**{subparser.dest: None})

# check if a specific subparser is selected
name = getattr(known_args, subparser.dest)
if name is None:
# add description for all command extensions to the root parser
command_extensions = get_command_extensions(group_name)
if command_extensions:
description = ''
max_length = max(
len(name) for name in command_extensions.keys()
if hide_extensions is None or name not in hide_extensions)
for name in sorted(command_extensions.keys()):
if hide_extensions is not None and name in hide_extensions:
continue
extension = command_extensions[name]
description += '%s %s\n' % (
name.ljust(max_length), get_first_line_doc(extension))
command_parser = command_parsers[name]
command_parser.set_defaults(**{dest: extension})
mutable_description.value = description
else:
# add description for the selected command extension to the subparser
command_extensions = get_command_extensions(
group_name, exclude_names=set(entry_points.keys() - {name}))
extension = command_extensions[name]
command_parser = command_parsers[name]
command_parser.set_defaults(**{dest: extension})
command_parser.description = get_first_line_doc(extension)

# add the arguments for the requested extension
if hasattr(extension, 'add_arguments'):
command_parser = command_parsers[name]
command_parser._root_parser = root_parser
signature = inspect.signature(extension.add_arguments)
kwargs = {}
if 'argv' in signature.parameters:
kwargs['argv'] = argv
extension.add_arguments(
command_parser, f'{cli_name} {name}', **kwargs)
del command_parser._root_parser

return subparser


class SuppressUsageOutput:
"""Context manager to suppress help action during `parse_known_args`."""

def __init__(self, parsers):
"""
Construct a SuppressUsageOutput.
:param parsers: The parsers
"""
self._parsers = parsers
self._callbacks = {}

def __enter__(self): # noqa: D105
for p in self._parsers:
self._callbacks[p] = p.print_help, p.exit
# temporary prevent printing usage early if help is requested
p.print_help = lambda: None
# temporary prevent help action to exit early,
# but keep exiting on invalid arguments
p.exit = types.MethodType(_ignore_zero_exit(p.exit), p)

return self

def __exit__(self, *args): # noqa: D105
for p, callbacks in self._callbacks.items():
p.print_help, p.exit = callbacks


def _ignore_zero_exit(original_exit_handler):
def exit_(self, status=0, message=None):
nonlocal original_exit_handler
if status == 0:
return
return original_exit_handler(status=status, message=message)

return exit_


def _is_completion_requested():
return os.environ.get('_ARGCOMPLETE') == '1'
10 changes: 4 additions & 6 deletions ros2cli/ros2cli/command/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,20 +13,18 @@
# See the License for the specific language governing permissions and
# limitations under the License.

from ros2cli.command import add_subparsers
from ros2cli.command import add_subparsers_on_demand
from ros2cli.command import CommandExtension
from ros2cli.verb import get_verb_extensions


class DaemonCommand(CommandExtension):
"""Various daemon related sub-commands."""

def add_arguments(self, parser, cli_name):
self._subparser = parser
# get verb extensions and let them add their arguments
verb_extensions = get_verb_extensions('ros2cli.daemon.verb')
add_subparsers(
parser, cli_name, '_verb', verb_extensions, required=False)
# add arguments and sub-commands of verbs
add_subparsers_on_demand(
parser, cli_name, '_verb', 'ros2cli.daemon.verb', required=False)

def main(self, *, parser, args):
if not hasattr(args, '_verb'):
Expand Down
8 changes: 5 additions & 3 deletions ros2cli/ros2cli/entry_points.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,22 +71,24 @@ def get_entry_points(group_name):
return entry_points


def load_entry_points(group_name):
def load_entry_points(group_name, *, exclude_names=None):
"""
Load the entry points for a specific group.
:param str group_name: the name of the ``entry_point`` group
:param iterable exclude_names: the names of the entry points to exclude
:returns: mapping of entry point name to loaded entry point
:rtype: dict
"""
extension_types = {}
for entry_point in get_entry_points(group_name).values():
if exclude_names and entry_point.name in exclude_names:
continue
try:
extension_type = entry_point.load()
except Exception as e: # noqa: F841
logger.warning(
"Failed to load entry point '{entry_point.name}': {e}"
.format_map(locals()))
f"Failed to load entry point '{entry_point.name}': {e}")
continue
extension_types[entry_point.name] = extension_type
return extension_types
Expand Down
4 changes: 3 additions & 1 deletion ros2cli/ros2cli/node/direct.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ def timer_callback():
nonlocal timeout_reached
timeout_reached = True

rclpy.init()
argv = getattr(args, 'argv', [])

rclpy.init(args=argv)

node_name_suffix = getattr(
args, 'node_name_suffix', '_%d' % os.getpid())
Expand Down
Loading

0 comments on commit 30987f4

Please sign in to comment.