# Copyright (C) 2018 Google Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Provides tab completion functionality for CLIs built with Fire.""" from __future__ import absolute_import from __future__ import division from __future__ import print_function import collections import copy import inspect from fire import inspectutils import six def Script(name, component, default_options=None, shell='bash'): if shell == 'fish': return _FishScript(name, _Commands(component), default_options) return _BashScript(name, _Commands(component), default_options) def _BashScript(name, commands, default_options=None): """Returns a Bash script registering a completion function for the commands. Args: name: The first token in the commands, also the name of the command. commands: A list of all possible commands that tab completion can complete to. Each command is a list or tuple of the string tokens that make up that command. default_options: A dict of options that can be used with any command. Use this if there are flags that can always be appended to a command. Returns: A string which is the Bash script. Source the bash script to enable tab completion in Bash. """ default_options = default_options or set() global_options, options_map, subcommands_map = _GetMaps( name, commands, default_options ) bash_completion_template = """# bash completion support for {name} # DO NOT EDIT. # This script is autogenerated by fire/completion.py. _complete-{identifier}() {{ local cur prev opts lastcommand COMPREPLY=() prev="${{COMP_WORDS[COMP_CWORD-1]}}" cur="${{COMP_WORDS[COMP_CWORD]}}" lastcommand=$(get_lastcommand) opts="{default_options}" GLOBAL_OPTIONS="{global_options}" {checks} COMPREPLY=( $(compgen -W "${{opts}}" -- ${{cur}}) ) return 0 }} get_lastcommand() {{ local lastcommand i lastcommand= for ((i=0; i < ${{#COMP_WORDS[@]}}; ++i)); do if [[ ${{COMP_WORDS[i]}} != -* ]] && [[ -n ${{COMP_WORDS[i]}} ]] && [[ ${{COMP_WORDS[i]}} != $cur ]]; then lastcommand=${{COMP_WORDS[i]}} fi done echo $lastcommand }} filter_options() {{ local opts opts="" for opt in "$@" do if ! option_already_entered $opt; then opts="$opts $opt" fi done echo $opts }} option_already_entered() {{ local opt for opt in ${{COMP_WORDS[@]:0:COMP_CWORD}} do if [ $1 == $opt ]; then return 0 fi done return 1 }} is_prev_global() {{ local opt for opt in $GLOBAL_OPTIONS do if [ $opt == $prev ]; then return 0 fi done return 1 }} complete -F _complete-{identifier} {command} """ check_wrapper = """ case "${{lastcommand}}" in {lastcommand_checks} esac""" lastcommand_check_template = """ {command}) {opts_assignment} opts=$(filter_options $opts) ;;""" opts_assignment_subcommand_template = """ if is_prev_global; then opts="${{GLOBAL_OPTIONS}}" else opts="{options} ${{GLOBAL_OPTIONS}}" fi""" opts_assignment_main_command_template = """ opts="{options} ${{GLOBAL_OPTIONS}}" """ def _GetOptsAssignmentTemplate(command): if command == name: return opts_assignment_main_command_template else: return opts_assignment_subcommand_template lines = [] commands_set = set() commands_set.add(name) commands_set = commands_set.union(set(subcommands_map.keys())) commands_set = commands_set.union(set(options_map.keys())) for command in commands_set: opts_assignment = _GetOptsAssignmentTemplate(command).format( options=' '.join( sorted(options_map[command].union(subcommands_map[command])) ), ) lines.append( lastcommand_check_template.format( command=command, opts_assignment=opts_assignment) ) lastcommand_checks = '\n'.join(lines) checks = check_wrapper.format( lastcommand_checks=lastcommand_checks, ) return ( bash_completion_template.format( name=name, command=name, checks=checks, default_options=' '.join(default_options), identifier=name.replace('/', '').replace('.', '').replace(',', ''), global_options=' '.join(global_options), ) ) def _FishScript(name, commands, default_options=None): """Returns a Fish script registering a completion function for the commands. Args: name: The first token in the commands, also the name of the command. commands: A list of all possible commands that tab completion can complete to. Each command is a list or tuple of the string tokens that make up that command. default_options: A dict of options that can be used with any command. Use this if there are flags that can always be appended to a command. Returns: A string which is the Fish script. Source the fish script to enable tab completion in Fish. """ default_options = default_options or set() global_options, options_map, subcommands_map = _GetMaps( name, commands, default_options ) fish_source = """function __fish_using_command set cmd (commandline -opc) for i in (seq (count $cmd) 1) switch $cmd[$i] case "-*" case "*" if [ $cmd[$i] = $argv[1] ] return 0 else return 1 end end end return 1 end function __option_entered_check set cmd (commandline -opc) for i in (seq (count $cmd)) switch $cmd[$i] case "-*" if [ $cmd[$i] = $argv[1] ] return 1 end end end return 0 end function __is_prev_global set cmd (commandline -opc) set global_options {global_options} set prev (count $cmd) for opt in $global_options if [ "--$opt" = $cmd[$prev] ] echo $prev return 0 end end return 1 end """ subcommand_template = ("complete -c {name} -n '__fish_using_command " "{command}' -f -a {subcommand}\n") flag_template = ("complete -c {name} -n " "'__fish_using_command {command};{prev_global_check} and " "__option_entered_check --{option}' -l {option}\n") prev_global_check = ' and __is_prev_global;' for command in set(subcommands_map.keys()).union(set(options_map.keys())): for subcommand in subcommands_map[command]: fish_source += subcommand_template.format( name=name, command=command, subcommand=subcommand, ) for option in options_map[command].union(global_options): check_needed = command != name fish_source += flag_template.format( name=name, command=command, prev_global_check=prev_global_check if check_needed else '', option=option.lstrip('--'), ) return fish_source.format( global_options=' '.join( '"{option}"'.format(option=option) for option in global_options ) ) def MemberVisible(component, name, member, class_attrs=None, verbose=False): """Returns whether a member should be included in auto-completion or help. Determines whether a member of an object with the specified name should be included in auto-completion or help text(both usage and detailed help). If the member name starts with '__', it will always be excluded. If it starts with only one '_', it will be included for all non-string types. If verbose is True, the members, including the private members, are included. When not in verbose mode, some modules and functions are excluded as well. Args: component: The component containing the member. name: The name of the member. member: The member itself. class_attrs: (optional) If component is a class, provide this as: inspectutils.GetClassAttrsDict(component). If not provided, it will be computed. verbose: Whether to include private members. Returns A boolean value indicating whether the member should be included. """ if isinstance(name, six.string_types) and name.startswith('__'): return False if verbose: return True if (member is absolute_import or member is division or member is print_function): return False if isinstance(member, type(absolute_import)) and six.PY34: return False if inspect.ismodule(member) and member is six: # TODO(dbieber): Determine more generally which modules to hide. return False if inspect.isclass(component): # If class_attrs has not been provided, compute it. if class_attrs is None: class_attrs = inspectutils.GetClassAttrsDict(class_attrs) or {} class_attr = class_attrs.get(name) if class_attr: # Methods and properties should only be accessible on instantiated # objects, not on uninstantiated classes. if class_attr.kind in ('method', 'property'): return False # Backward compatibility notes: Before Python 3.8, namedtuple attributes # were properties. In Python 3.8, they have type tuplegetter. tuplegetter = getattr(collections, '_tuplegetter', type(None)) if isinstance(class_attr.object, tuplegetter): return False if (six.PY2 and inspect.isfunction(component) and name in ('func_closure', 'func_code', 'func_defaults', 'func_dict', 'func_doc', 'func_globals', 'func_name')): return False if (six.PY2 and inspect.ismethod(component) and name in ('im_class', 'im_func', 'im_self')): return False if isinstance(name, six.string_types): return not name.startswith('_') return True # Default to including the member def VisibleMembers(component, class_attrs=None, verbose=False): """Returns a list of the members of the given component. If verbose is True, then members starting with _ (normally ignored) are included. Args: component: The component whose members to list. class_attrs: (optional) If component is a class, you may provide this as: inspectutils.GetClassAttrsDict(component). If not provided, it will be computed. If provided, this determines how class members will be treated for visibility. In particular, methods are generally hidden for non-instantiated classes, but if you wish them to be shown (e.g. for completion scripts) then pass in a different class_attr for them. verbose: Whether to include private members. Returns: A list of tuples (member_name, member) of all members of the component. """ if isinstance(component, dict): members = component.items() else: members = inspect.getmembers(component) # If class_attrs has not been provided, compute it. if class_attrs is None: class_attrs = inspectutils.GetClassAttrsDict(component) return [ (member_name, member) for member_name, member in members if MemberVisible(component, member_name, member, class_attrs=class_attrs, verbose=verbose) ] def _CompletionsFromArgs(fn_args): """Takes a list of fn args and returns a list of the fn's completion strings. Args: fn_args: A list of the args accepted by a function. Returns: A list of possible completion strings for that function. """ completions = [] for arg in fn_args: arg = arg.replace('_', '-') completions.append('--{arg}'.format(arg=arg)) return completions def Completions(component, verbose=False): """Gives possible Fire command completions for the component. A completion is a string that can be appended to a command to continue that command. These are used for TAB-completions in Bash for Fire CLIs. Args: component: The component whose completions to list. verbose: Whether to include all completions, even private members. Returns: A list of completions for a command that would so far return the component. """ if inspect.isroutine(component) or inspect.isclass(component): spec = inspectutils.GetFullArgSpec(component) return _CompletionsFromArgs(spec.args + spec.kwonlyargs) if isinstance(component, (tuple, list)): return [str(index) for index in range(len(component))] if inspect.isgenerator(component): # TODO(dbieber): There are currently no commands available for generators. return [] return [ _FormatForCommand(member_name) for member_name, _ in VisibleMembers(component, verbose=verbose) ] def _FormatForCommand(token): """Replaces underscores with hyphens, unless the token starts with a token. This is because we typically prefer hyphens to underscores at the command line, but we reserve hyphens at the start of a token for flags. This becomes relevant when --verbose is activated, so that things like __str__ don't get transformed into --str--, which would get confused for a flag. Args: token: The token to transform. Returns: The transformed token. """ if not isinstance(token, six.string_types): token = str(token) if token.startswith('_'): return token return token.replace('_', '-') def _Commands(component, depth=3): """Yields tuples representing commands. To use the command from Python, insert '.' between each element of the tuple. To use the command from the command line, insert ' ' between each element of the tuple. Args: component: The component considered to be the root of the yielded commands. depth: The maximum depth with which to traverse the member DAG for commands. Yields: Tuples, each tuple representing one possible command for this CLI. Only traverses the member DAG up to a depth of depth. """ if inspect.isroutine(component) or inspect.isclass(component): for completion in Completions(component, verbose=False): yield (completion,) if inspect.isroutine(component): return # Don't descend into routines. if depth < 1: return # By setting class_attrs={} we don't hide methods in completion. for member_name, member in VisibleMembers(component, class_attrs={}, verbose=False): # TODO(dbieber): Also skip components we've already seen. member_name = _FormatForCommand(member_name) yield (member_name,) for command in _Commands(member, depth - 1): yield (member_name,) + command def _IsOption(arg): return arg.startswith('-') def _GetMaps(name, commands, default_options): """Returns sets of subcommands and options for each command. Args: name: The first token in the commands, also the name of the command. commands: A list of all possible commands that tab completion can complete to. Each command is a list or tuple of the string tokens that make up that command. default_options: A dict of options that can be used with any command. Use this if there are flags that can always be appended to a command. Returns: global_options: A set of all options of the first token of the command. subcommands_map: A dict storing set of subcommands for each command/subcommand. options_map: A dict storing set of options for each subcommand. """ global_options = copy.copy(default_options) options_map = collections.defaultdict(lambda: copy.copy(default_options)) subcommands_map = collections.defaultdict(set) for command in commands: if len(command) == 1: if _IsOption(command[0]): global_options.add(command[0]) else: subcommands_map[name].add(command[0]) elif command: subcommand = command[-2] arg = _FormatForCommand(command[-1]) if _IsOption(arg): args_map = options_map else: args_map = subcommands_map args_map[subcommand].add(arg) args_map[subcommand.replace('_', '-')].add(arg) return global_options, options_map, subcommands_map