diff --git a/ros2action/ros2action/command/action.py b/ros2action/ros2action/command/action.py index 24bcb21a3..d8b7cacb3 100644 --- a/ros2action/ros2action/command/action.py +++ b/ros2action/ros2action/command/action.py @@ -12,9 +12,8 @@ # 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): @@ -22,9 +21,9 @@ class ActionCommand(CommandExtension): 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'): diff --git a/ros2cli/ros2cli/cli.py b/ros2cli/ros2cli/cli.py index cb2e2fb66..b27bb335c 100644 --- a/ros2cli/ros2cli/cli.py +++ b/ros2cli/ros2cli/cli.py @@ -16,8 +16,7 @@ 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): @@ -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: diff --git a/ros2cli/ros2cli/command/__init__.py b/ros2cli/ros2cli/command/__init__.py index 4392bccdf..67715b8a5 100644 --- a/ros2cli/ros2cli/command/__init__.py +++ b/ros2cli/ros2cli/command/__init__.py @@ -14,7 +14,9 @@ # limitations under the License. import argparse +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 @@ -49,8 +51,9 @@ 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 @@ -69,6 +72,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 @@ -78,6 +87,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: @@ -112,3 +125,153 @@ def add_subparsers( command_parser, '{cli_name} {name}'.format_map(locals())) 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() + metavar = 'Call `{cli_name} -h` for more detailed ' \ + 'usage.'.format_map(locals()) + subparser = parser.add_subparsers( + title='Commands', description=mutable_description, metavar=metavar) + # 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()): + entry_point = entry_points[name] + 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())): + known_args, _ = root_parser.parse_known_args(args=argv) + + # 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 + extension.add_arguments( + command_parser, '{cli_name} {name}'.format_map(locals())) + 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_ diff --git a/ros2cli/ros2cli/command/daemon.py b/ros2cli/ros2cli/command/daemon.py index e7937148b..105d09037 100644 --- a/ros2cli/ros2cli/command/daemon.py +++ b/ros2cli/ros2cli/command/daemon.py @@ -13,9 +13,8 @@ # 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): @@ -23,10 +22,9 @@ class DaemonCommand(CommandExtension): 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'): diff --git a/ros2cli/ros2cli/entry_points.py b/ros2cli/ros2cli/entry_points.py index cb166605a..446e05c6a 100644 --- a/ros2cli/ros2cli/entry_points.py +++ b/ros2cli/ros2cli/entry_points.py @@ -71,16 +71,19 @@ 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 diff --git a/ros2cli/ros2cli/plugin_system.py b/ros2cli/ros2cli/plugin_system.py index dc656d40f..2088f06eb 100644 --- a/ros2cli/ros2cli/plugin_system.py +++ b/ros2cli/ros2cli/plugin_system.py @@ -37,11 +37,10 @@ class PluginException(Exception): def instantiate_extensions( group_name, *, exclude_names=None, unique_instance=False ): - extension_types = load_entry_points(group_name) + extension_types = load_entry_points( + group_name, exclude_names=exclude_names) extension_instances = {} for extension_name, extension_class in extension_types.items(): - if exclude_names and extension_name in exclude_names: - continue extension_instance = _instantiate_extension( group_name, extension_name, extension_class, unique_instance=unique_instance) diff --git a/ros2component/ros2component/command/component.py b/ros2component/ros2component/command/component.py index 9c0e1816c..f94138656 100644 --- a/ros2component/ros2component/command/component.py +++ b/ros2component/ros2component/command/component.py @@ -12,9 +12,8 @@ # 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 ComponentCommand(CommandExtension): @@ -22,10 +21,9 @@ class ComponentCommand(CommandExtension): def add_arguments(self, parser, cli_name): self._subparser = parser - # get verb extensions and let them add their arguments - verb_extensions = get_verb_extensions('ros2component.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', 'ros2component.verb', required=False) def main(self, *, parser, args): if not hasattr(args, '_verb'): diff --git a/ros2interface/ros2interface/command/interface.py b/ros2interface/ros2interface/command/interface.py index 981519448..ed908acdd 100644 --- a/ros2interface/ros2interface/command/interface.py +++ b/ros2interface/ros2interface/command/interface.py @@ -12,9 +12,8 @@ # 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 InterfaceCommand(CommandExtension): @@ -22,9 +21,9 @@ class InterfaceCommand(CommandExtension): def add_arguments(self, parser, cli_name): self._subparser = parser - verb_extension = get_verb_extensions('ros2interface.verb') - add_subparsers( - parser, cli_name, '_verb', verb_extension, required=False) + # add arguments and sub-commands of verbs + add_subparsers_on_demand( + parser, cli_name, '_verb', 'ros2interface.verb', required=False) def main(self, *, parser, args): if not hasattr(args, '_verb'): diff --git a/ros2lifecycle/ros2lifecycle/command/lifecycle.py b/ros2lifecycle/ros2lifecycle/command/lifecycle.py index 1a9ad8c26..cf9800bb2 100644 --- a/ros2lifecycle/ros2lifecycle/command/lifecycle.py +++ b/ros2lifecycle/ros2lifecycle/command/lifecycle.py @@ -12,9 +12,8 @@ # 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 LifecycleCommand(CommandExtension): @@ -23,10 +22,9 @@ class LifecycleCommand(CommandExtension): def add_arguments(self, parser, cli_name): self._subparser = parser - # get verb extensions and let them add their arguments - verb_extensions = get_verb_extensions('ros2lifecycle.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', 'ros2lifecycle.verb', required=False) def main(self, *, parser, args): if not hasattr(args, '_verb'): diff --git a/ros2multicast/ros2multicast/command/multicast.py b/ros2multicast/ros2multicast/command/multicast.py index 1a1c67763..b5f3bd856 100644 --- a/ros2multicast/ros2multicast/command/multicast.py +++ b/ros2multicast/ros2multicast/command/multicast.py @@ -12,9 +12,8 @@ # 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 MulticastCommand(CommandExtension): @@ -22,10 +21,9 @@ class MulticastCommand(CommandExtension): def add_arguments(self, parser, cli_name): self._subparser = parser - # get verb extensions and let them add their arguments - verb_extensions = get_verb_extensions('ros2multicast.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', 'ros2multicast.verb', required=False) def main(self, *, parser, args): if not hasattr(args, '_verb'): diff --git a/ros2node/ros2node/command/node.py b/ros2node/ros2node/command/node.py index 0edda01d3..f4d8f6167 100644 --- a/ros2node/ros2node/command/node.py +++ b/ros2node/ros2node/command/node.py @@ -12,9 +12,8 @@ # 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 NodeCommand(CommandExtension): @@ -22,10 +21,9 @@ class NodeCommand(CommandExtension): def add_arguments(self, parser, cli_name): self._subparser = parser - # get verb extensions and let them add their arguments - verb_extensions = get_verb_extensions('ros2node.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', 'ros2node.verb', required=False) def main(self, *, parser, args): if not hasattr(args, '_verb'): diff --git a/ros2param/ros2param/command/param.py b/ros2param/ros2param/command/param.py index eff64a23e..c2738b130 100644 --- a/ros2param/ros2param/command/param.py +++ b/ros2param/ros2param/command/param.py @@ -12,9 +12,8 @@ # 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 ParamCommand(CommandExtension): @@ -23,10 +22,9 @@ class ParamCommand(CommandExtension): def add_arguments(self, parser, cli_name): self._subparser = parser - # get verb extensions and let them add their arguments - verb_extensions = get_verb_extensions('ros2param.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', 'ros2param.verb', required=False) def main(self, *, parser, args): if not hasattr(args, '_verb'): diff --git a/ros2pkg/ros2pkg/command/pkg.py b/ros2pkg/ros2pkg/command/pkg.py index c16fc5c8d..523b3ac30 100644 --- a/ros2pkg/ros2pkg/command/pkg.py +++ b/ros2pkg/ros2pkg/command/pkg.py @@ -12,9 +12,8 @@ # 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 PkgCommand(CommandExtension): @@ -22,10 +21,9 @@ class PkgCommand(CommandExtension): def add_arguments(self, parser, cli_name): self._subparser = parser - # get verb extensions and let them add their arguments - verb_extensions = get_verb_extensions('ros2pkg.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', 'ros2pkg.verb', required=False) def main(self, *, parser, args): if not hasattr(args, '_verb'): diff --git a/ros2service/ros2service/command/service.py b/ros2service/ros2service/command/service.py index 3f05f7458..ec4501405 100644 --- a/ros2service/ros2service/command/service.py +++ b/ros2service/ros2service/command/service.py @@ -12,9 +12,8 @@ # 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 ServiceCommand(CommandExtension): @@ -26,10 +25,9 @@ def add_arguments(self, parser, cli_name): '--include-hidden-services', action='store_true', help='Consider hidden services as well') - # get verb extensions and let them add their arguments - verb_extensions = get_verb_extensions('ros2service.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', 'ros2service.verb', required=False) def main(self, *, parser, args): if not hasattr(args, '_verb'): diff --git a/ros2topic/ros2topic/command/topic.py b/ros2topic/ros2topic/command/topic.py index 3a3775cad..8e26de139 100644 --- a/ros2topic/ros2topic/command/topic.py +++ b/ros2topic/ros2topic/command/topic.py @@ -12,9 +12,8 @@ # 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 TopicCommand(CommandExtension): @@ -26,10 +25,9 @@ def add_arguments(self, parser, cli_name): '--include-hidden-topics', action='store_true', help='Consider hidden topics as well') - # get verb extensions and let them add their arguments - verb_extensions = get_verb_extensions('ros2topic.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', 'ros2topic.verb', required=False) def main(self, *, parser, args): if not hasattr(args, '_verb'):