# -*- coding: utf-8 -*- # # Copyright 2013 Google LLC. All Rights Reserved. # # 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. """The calliope CLI/API is a framework for building library interfaces.""" from __future__ import absolute_import from __future__ import division from __future__ import unicode_literals import argparse import collections import os import re import sys import types import uuid import argcomplete from googlecloudsdk.calliope import actions from googlecloudsdk.calliope import backend from googlecloudsdk.calliope import base as calliope_base from googlecloudsdk.calliope import command_loading from googlecloudsdk.calliope import exceptions from googlecloudsdk.calliope import parser_errors from googlecloudsdk.calliope import parser_extensions from googlecloudsdk.core import argv_utils from googlecloudsdk.core import config from googlecloudsdk.core import log from googlecloudsdk.core import metrics from googlecloudsdk.core import properties from googlecloudsdk.core import yaml from googlecloudsdk.core.configurations import named_configs from googlecloudsdk.core.console import console_attr from googlecloudsdk.core.util import encoding from googlecloudsdk.core.util import files from googlecloudsdk.core.util import pkg_resources import six _COMMAND_SUFFIX = '.py' _FLAG_FILE_LINE_NAME = '---flag-file-line-' class _FlagLocation(object): """--flags-file (file,line_col) location.""" def __init__(self, file_name, line_col): self.file_name = file_name self.line = line_col.line + 1 # ruamel does 0-offset line numbers? def __str__(self): return '{}:{}'.format(self.file_name, self.line) class _ArgLocations(object): """--flags-file (arg,locations) info.""" def __init__(self, arg, file_name, line_col, locations=None): self.arg = arg self.locations = locations.locations[:] if locations else [] self.locations.append(_FlagLocation(file_name, line_col)) def __str__(self): return ';'.join([six.text_type(location) for location in self.locations]) def FileInStack(self, file_name): """Returns True if file_name is in the locations stack.""" return any([file_name == x.file_name for x in self.locations]) def _AddFlagsFileFlags(inject, flags_file, parent_locations=None): """Recursively append the flags file flags to inject.""" flag = calliope_base.FLAGS_FILE_FLAG.name if parent_locations and parent_locations.FileInStack(flags_file): raise parser_errors.ArgumentError( '{} recursive reference ({}).'.format(flag, parent_locations)) # Load the YAML flag:value dict or list of dicts. List of dicts allows # flags to be specified more than once. if flags_file == '-': contents = sys.stdin.read() elif not os.path.exists(flags_file): raise parser_errors.ArgumentError( '{} [{}] not found.'.format(flag, flags_file)) else: contents = files.ReadFileContents(flags_file) if not contents: raise parser_errors.ArgumentError( '{} [{}] is empty.'.format(flag, flags_file)) data = yaml.load(contents, location_value=True) group = data if isinstance(data, list) else [data] # Generate the list of args to inject. for member in group: if not isinstance(member.value, dict): raise parser_errors.ArgumentError( '{}:{}: {} file must contain a dictionary or list of dictionaries ' 'of flags.'.format(flags_file, member.lc.line + 1, flag)) for arg, obj in six.iteritems(member.value): line_col = obj.lc value = yaml.strip_locations(obj) if arg == flag: # The flags-file YAML arg value can be a path or list of paths. file_list = obj.value if isinstance(obj.value, list) else [obj.value] for path in file_list: locations = _ArgLocations(arg, flags_file, line_col, parent_locations) _AddFlagsFileFlags(inject, path, locations) continue if isinstance(value, (type(None), bool)): separate_value_arg = False elif isinstance(value, (list, dict)): separate_value_arg = True else: separate_value_arg = False arg = '{}={}'.format(arg, value) inject.append(_FLAG_FILE_LINE_NAME) inject.append(_ArgLocations(arg, flags_file, line_col, parent_locations)) inject.append(arg) if separate_value_arg: # Add the already lexed arg and with one swoop we sidestep all flag # value and command line interpreter quoting issues. The ArgList and # ArgDict arg parsers have been adjusted to handle this. inject.append(value) def _ApplyFlagsFile(args): """Applies FLAGS_FILE_FLAG in args and returns the new args. The basic algorithm is arg list manipulation, done before ArgParse is called. This function reaps all FLAGS_FILE_FLAG args from the command line, and recursively from the flags files, and inserts them into a new args list by replacing the --flags-file=YAML-FILE flag by its constituent flags. This preserves the left-to-right precedence of the argument parser. Internal _FLAG_FILE_LINE_NAME flags are also inserted into args. This specifies the flags source file and line number for each flags file flag, and is used to construct actionable error messages. Args: args: The original args list. Returns: A new args list with all FLAGS_FILE_FLAG args replaced by their constituent flags. """ flag = calliope_base.FLAGS_FILE_FLAG.name flag_eq = flag + '=' if not any([arg == flag or arg.startswith(flag_eq) for arg in args]): return args # Find and replace all file flags by their constituent flags peek = False new_args = [] for arg in args: if peek: peek = False _AddFlagsFileFlags(new_args, arg) elif arg == flag: peek = True elif arg.startswith(flag_eq): _AddFlagsFileFlags(new_args, arg[len(flag_eq):]) else: new_args.append(arg) return new_args class RunHook(object): """Encapsulates a function to be run before or after command execution. The function should take **kwargs so that more things can be passed to the functions in the future. """ def __init__(self, func, include_commands=None, exclude_commands=None): """Constructs the hook. Args: func: function, The function to run. include_commands: str, A regex for the command paths to run. If not provided, the hook will be run for all commands. exclude_commands: str, A regex for the command paths to exclude. If not provided, nothing will be excluded. """ self.__func = func self.__include_commands = include_commands if include_commands else '.*' self.__exclude_commands = exclude_commands def Run(self, command_path): """Runs this hook if the filters match the given command. Args: command_path: str, The calliope command path for the command that was run. Returns: bool, True if the hook was run, False if it did not match. """ if not re.match(self.__include_commands, command_path): return False if self.__exclude_commands and re.match(self.__exclude_commands, command_path): return False self.__func(command_path=command_path) return True class _SetFlagsFileLine(argparse.Action): """FLAG_INTERNAL_FLAG_FILE_LINE action.""" def __call__(self, parser, namespace, values, option_string=None): if not hasattr(parser, 'flags_locations'): setattr(parser, 'flags_locations', collections.defaultdict(set)) parser.flags_locations[values.arg].add(six.text_type(values)) FLAG_INTERNAL_FLAG_FILE_LINE = calliope_base.Argument( _FLAG_FILE_LINE_NAME, default=None, action=_SetFlagsFileLine, hidden=True, help='Internal *--flags-file* flag, line number, and source file.') class CLILoader(object): """A class to encapsulate loading the CLI and bootstrapping the REPL.""" # Splits a path like foo.bar.baz into 2 groups: foo.bar, and baz. Group 1 is # optional. PATH_RE = re.compile(r'(?:([\w\.]+)\.)?([^\.]+)') def __init__(self, name, command_root_directory, allow_non_existing_modules=False, logs_dir=None, version_func=None, known_error_handler=None, yaml_command_translator=None): """Initialize Calliope. Args: name: str, The name of the top level command, used for nice error reporting. command_root_directory: str, The path to the directory containing the main CLI module. allow_non_existing_modules: True to allow extra module directories to not exist, False to raise an exception if a module does not exist. logs_dir: str, The path to the root directory to store logs in, or None for no log files. version_func: func, A function to call for a top-level -v and --version flag. If None, no flags will be available. known_error_handler: f(x)->None, A function to call when an known error is handled. It takes a single argument that is the exception. yaml_command_translator: YamlCommandTranslator, An instance of a translator that will be used to load commands written as a yaml spec. Raises: backend.LayoutException: If no command root directory is given. """ self.__name = name self.__command_root_directory = command_root_directory if not self.__command_root_directory: raise command_loading.LayoutException( 'You must specify a command root directory.') self.__allow_non_existing_modules = allow_non_existing_modules self.__logs_dir = logs_dir or config.Paths().logs_dir self.__version_func = version_func self.__known_error_handler = known_error_handler self.__yaml_command_translator = yaml_command_translator self.__pre_run_hooks = [] self.__post_run_hooks = [] self.__modules = [] self.__modules_by_parent = collections.defaultdict(list) self.__missing_components = {} self.__release_tracks = {} @property def yaml_command_translator(self): return self.__yaml_command_translator def AddReleaseTrack(self, release_track, path, component=None): """Adds a release track to this CLI tool. A release track (like alpha, beta...) will appear as a subgroup under the main entry point of the tool. All groups and commands will be replicated under each registered release track. You can implement your commands to behave differently based on how they are called. Args: release_track: base.ReleaseTrack, The release track you are adding. path: str, The full path the directory containing the root of this group. component: str, The name of the component this release track is in, if you want calliope to auto install it for users. Raises: ValueError: If an invalid track is registered. """ if not release_track.prefix: raise ValueError('You may only register alternate release tracks that ' 'have a different prefix.') self.__release_tracks[release_track] = (path, component) def AddModule(self, name, path, component=None): """Adds a module to this CLI tool. If you are making a CLI that has subgroups, use this to add in more directories of commands. Args: name: str, The name of the group to create under the main CLI. If this is to be placed under another group, a dotted name can be used. path: str, The full path the directory containing the commands for this group. component: str, The name of the component this command module is in, if you want calliope to auto install it for users. """ self.__modules.append((name, path, component)) def GetModules(self): """Returns modules added to this CLI tool.""" return self.__modules def GetModulesByParent(self): """Returns info about added modules (if any) for each parent command group. Returns: {str: [(str, bool, str)]}, Mapping of parent group to list of (module_name, module_is_command, module_impl_path) tuples for each additional module. """ return self.__modules_by_parent def RegisterPreRunHook(self, func, include_commands=None, exclude_commands=None): """Register a function to be run before command execution. Args: func: function, The function to run. See RunHook for more details. include_commands: str, A regex for the command paths to run. If not provided, the hook will be run for all commands. exclude_commands: str, A regex for the command paths to exclude. If not provided, nothing will be excluded. """ hook = RunHook(func, include_commands, exclude_commands) self.__pre_run_hooks.append(hook) def RegisterPostRunHook(self, func, include_commands=None, exclude_commands=None): """Register a function to be run after command execution. Args: func: function, The function to run. See RunHook for more details. include_commands: str, A regex for the command paths to run. If not provided, the hook will be run for all commands. exclude_commands: str, A regex for the command paths to exclude. If not provided, nothing will be excluded. """ hook = RunHook(func, include_commands, exclude_commands) self.__post_run_hooks.append(hook) def ComponentsForMissingCommand(self, command_path): """Gets the components that need to be installed to run the given command. Args: command_path: [str], The path of the command being run. Returns: [str], The component names of the components that should be installed. """ path_string = '.'.join(command_path) return [component for path, component in six.iteritems(self.__missing_components) if path_string == path or path_string.startswith(path + '.')] def ReplicateCommandPathForAllOtherTracks(self, command_path): """Finds other release tracks this command could be in. The returned values are not necessarily guaranteed to exist because the commands could be disabled for that particular release track. It is up to the caller to determine if the commands actually exist before attempting use. Args: command_path: [str], The path of the command being run. Returns: {ReleaseTrack: [str]}, A mapping of release track to command path of other places this command could be found. """ # Only a single element, it's just the root of the tree. if len(command_path) < 2: return [] # Determine if the first token is a release track name. track = calliope_base.ReleaseTrack.FromPrefix(command_path[1]) if track and track not in self.__release_tracks: # Make sure it's actually a track that we are using in this CLI. track = None # Remove the track from the path to get back to the GA version of the # command, or keep the existing path if it is not in a track (already GA). root = command_path[0] sub_path = command_path[2:] if track else command_path[1:] if not sub_path: # There are no parts to the path other than the track. return [] results = dict() # Calculate how this command looks under each alternate release track. for t in self.__release_tracks: results[t] = [root] + [t.prefix] + sub_path if track: # If the incoming command had a release track, remove that one from # alternate suggestions but add GA. del results[track] results[calliope_base.ReleaseTrack.GA] = [root] + sub_path return results def Generate(self): """Uses the registered information to generate the CLI tool. Returns: CLI, The generated CLI tool. """ # Register additional modules (if any) to be loaded later. for module_dot_path, module_dir_path, component in self.__modules: is_command = module_dir_path.endswith(_COMMAND_SUFFIX) if is_command: module_dir_path = module_dir_path[:-len(_COMMAND_SUFFIX)] match = CLILoader.PATH_RE.match(module_dot_path) root, cmd_or_grp_name = match.group(1, 2) impl_path = self.__ValidateCommandOrGroupInfo( module_dir_path, allow_non_existing_modules=self.__allow_non_existing_modules) # Create a mapping under the parent for each release track that exists. for track in [calliope_base.ReleaseTrack.GA, *self.__release_tracks]: if impl_path: parent_group_name = '.'.join( [self.__name] + ([track.prefix] if track.prefix else []) + ([root.replace('_', '-')] if root else [])) self.__modules_by_parent[parent_group_name].append( (cmd_or_grp_name, is_command, impl_path)) elif component: group_name = '.'.join( [self.__name] + ([track.prefix] if track.prefix else []) + [module_dot_path.replace('_', '-')]) self.__missing_components[group_name] = component # The root group of the CLI. impl_path = self.__ValidateCommandOrGroupInfo( self.__command_root_directory, allow_non_existing_modules=False) top_group = backend.CommandGroup( [impl_path], [self.__name], calliope_base.ReleaseTrack.GA, uuid.uuid4().hex, self, None) self.__AddBuiltinGlobalFlags(top_group) # Sub groups for each alternate release track. track_names = set(track.prefix for track in self.__release_tracks.keys()) for track, (module_dir, component) in six.iteritems(self.__release_tracks): impl_path = self.__ValidateCommandOrGroupInfo( module_dir, allow_non_existing_modules=self.__allow_non_existing_modules) if impl_path: # Add the release track sub group into the top group. # pylint: disable=protected-access top_group._groups_to_load[track.prefix] = [impl_path] # Override the release track because this is specifically a top level # release track group. track_group = top_group.LoadSubElement( track.prefix, allow_empty=True, release_track_override=track) # Copy all the root elements of the top group into the release group. top_group.CopyAllSubElementsTo(track_group, ignore=track_names) elif component: self.__missing_components[f'{self.__name}.{track.prefix}'] = component cli = self.__MakeCLI(top_group) return cli def __ValidateCommandOrGroupInfo( self, impl_path, allow_non_existing_modules=False): """Generates the information necessary to be able to load a command group. The group might actually be loaded now if it is the root of the SDK, or the information might be saved for later if it is to be lazy loaded. Args: impl_path: str, The file path to the command implementation for this command or group. allow_non_existing_modules: True to allow this module directory to not exist, False to raise an exception if this module does not exist. Raises: LayoutException: If the module directory does not exist and allow_non_existing is False. Returns: impl_path or None if the module directory does not exist and allow_non_existing is True. """ module_root, module = os.path.split(impl_path) if not pkg_resources.IsImportable(module, module_root): if allow_non_existing_modules: return None raise command_loading.LayoutException( 'The given module directory does not exist: {0}'.format( impl_path)) return impl_path def __AddBuiltinGlobalFlags(self, top_element): """Adds in calliope builtin global flags. This needs to happen immediately after the top group is loaded and before any other groups are loaded. The flags must be present so when sub groups are loaded, the flags propagate down. Args: top_element: backend._CommandCommon, The root of the command tree. """ calliope_base.FLAGS_FILE_FLAG.AddToParser(top_element.ai) calliope_base.FLATTEN_FLAG.AddToParser(top_element.ai) calliope_base.FORMAT_FLAG.AddToParser(top_element.ai) if self.__version_func is not None: top_element.ai.add_argument( '-v', '--version', do_not_propagate=True, category=calliope_base.COMMONLY_USED_FLAGS, action=actions.FunctionExitAction(self.__version_func), help='Print version information and exit. This flag is only available' ' at the global level.') top_element.ai.add_argument( '--configuration', metavar='CONFIGURATION', category=calliope_base.COMMONLY_USED_FLAGS, help="""\ File name of the configuration to use for this command invocation. For more information on how to use configurations, run: `gcloud topic configurations`. You can also use the {0} environment variable to set the equivalent of this flag for a terminal session.""".format(config.CLOUDSDK_ACTIVE_CONFIG_NAME)) top_element.ai.add_argument( '--verbosity', choices=log.OrderedVerbosityNames(), default=log.DEFAULT_VERBOSITY_STRING, category=calliope_base.COMMONLY_USED_FLAGS, help='Override the default verbosity for this command.', action=actions.StoreProperty(properties.VALUES.core.verbosity)) top_element.ai.add_argument( '--user-output-enabled', default=None, # Tri-valued, None => don't override the property. action=actions.StoreBooleanProperty( properties.VALUES.core.user_output_enabled ), help='Print user intended output to the console.', ) top_element.ai.add_argument( '--log-http', default=None, # Tri-valued, None => don't override the property. action=actions.StoreBooleanProperty(properties.VALUES.core.log_http), help='Log all HTTP server requests and responses to stderr.') top_element.ai.add_argument( '--authority-selector', default=None, action=actions.StoreProperty(properties.VALUES.auth.authority_selector), hidden=True, help='THIS ARGUMENT NEEDS HELP TEXT.') top_element.ai.add_argument( '--authorization-token-file', default=None, action=actions.StoreProperty( properties.VALUES.auth.authorization_token_file), hidden=True, help='THIS ARGUMENT NEEDS HELP TEXT.') top_element.ai.add_argument( '--credential-file-override', action=actions.StoreProperty( properties.VALUES.auth.credential_file_override), hidden=True, help='THIS ARGUMENT NEEDS HELP TEXT.') # Timeout value for HTTP requests. top_element.ai.add_argument( '--http-timeout', default=None, action=actions.StoreProperty(properties.VALUES.core.http_timeout), hidden=True, help='THIS ARGUMENT NEEDS HELP TEXT.') # --flags-file source line number hook. FLAG_INTERNAL_FLAG_FILE_LINE.AddToParser(top_element.ai) def __MakeCLI(self, top_element): """Generate a CLI object from the given data. Args: top_element: The top element of the command tree (that extends backend.CommandCommon). Returns: CLI, The generated CLI tool. """ # Don't bother setting up logging if we are just doing a completion. if '_ARGCOMPLETE' not in os.environ or '_ARGCOMPLETE_TRACE' in os.environ: log.AddFileLogging(self.__logs_dir) verbosity_string = encoding.GetEncodedValue(os.environ, '_ARGCOMPLETE_TRACE') if verbosity_string: verbosity = log.VALID_VERBOSITY_STRINGS.get(verbosity_string) log.SetVerbosity(verbosity) # Pre-load all commands if lazy loading is disabled. if properties.VALUES.core.disable_command_lazy_loading.GetBool(): top_element.LoadAllSubElements(recursive=True) cli = CLI(self.__name, top_element, self.__pre_run_hooks, self.__post_run_hooks, self.__known_error_handler) return cli class _CompletionFinder(argcomplete.CompletionFinder): """Calliope overrides for argcomplete.CompletionFinder. This makes calliope ArgumentInterceptor and actions objects visible to the argcomplete monkeypatcher. """ def _patch_argument_parser(self): ai = self._parser self._parser = ai.parser active_parsers = super(_CompletionFinder, self)._patch_argument_parser() if ai: self._parser = ai return active_parsers def _get_completions(self, comp_words, cword_prefix, cword_prequote, last_wordbreak_pos): active_parsers = self._patch_argument_parser() parsed_args = parser_extensions.Namespace() self.completing = True try: self._parser.parse_known_args(comp_words[1:], namespace=parsed_args) except BaseException: # pylint: disable=broad-except pass self.completing = False completions = self.collect_completions( active_parsers, parsed_args, cword_prefix, lambda *_: None) completions = self.filter_completions(completions) return self.quote_completions( completions, cword_prequote, last_wordbreak_pos) def quote_completions(self, completions, cword_prequote, last_wordbreak_pos): """Returns the completion (less aggressively) quoted for the shell. If the word under the cursor started with a quote (as indicated by a nonempty ``cword_prequote``), escapes occurrences of that quote character in the completions, and adds the quote to the beginning of each completion. Otherwise, escapes *most* characters that bash splits words on (``COMP_WORDBREAKS``), and removes portions of completions before the first colon if (``COMP_WORDBREAKS``) contains a colon. If there is only one completion, and it doesn't end with a **continuation character** (``/``, ``:``, or ``=``), adds a space after the completion. Args: completions: The current completion strings. cword_prequote: The current quote character in progress, '' if none. last_wordbreak_pos: The index of the last wordbreak. Returns: The completions quoted for the shell. """ # The *_special character sets are the only non-cosmetic changes from the # argcomplete original. We drop { '!', ' ', '\n' } from _NO_QUOTE_SPECIAL # and { '!' } from _DOUBLE_QUOTE_SPECIAL. argcomplete should make these # settable properties. no_quote_special = '\\();<>|&$* \t\n`"\'' double_quote_special = '\\`"$' single_quote_special = '\\' continuation_special = '=/:' no_escaping_shells = ('tcsh', 'fish', 'zsh') # If the word under the cursor was quoted, escape the quote char. # Otherwise, escape most special characters and specially handle most # COMP_WORDBREAKS chars. if not cword_prequote: # Bash mangles completions which contain characters in COMP_WORDBREAKS. # This workaround has the same effect as __ltrim_colon_completions in # bash_completion (extended to characters other than the colon). if last_wordbreak_pos: completions = [c[last_wordbreak_pos + 1:] for c in completions] special_chars = no_quote_special elif cword_prequote == '"': special_chars = double_quote_special else: special_chars = single_quote_special if encoding.GetEncodedValue(os.environ, '_ARGCOMPLETE_SHELL') in no_escaping_shells: # these shells escape special characters themselves. special_chars = '' elif cword_prequote == "'": # Nothing can be escaped in single quotes, so we need to close # the string, escape the single quote, then open a new string. special_chars = '' completions = [c.replace("'", r"'\''") for c in completions] for char in special_chars: completions = [c.replace(char, '\\' + char) for c in completions] if getattr(self, 'append_space', False): # Similar functionality in bash was previously turned off by supplying # the "-o nospace" option to complete. Now it is conditionally disabled # using "compopt -o nospace" if the match ends in a continuation # character. This code is retained for environments where this isn't # done natively. continuation_chars = continuation_special if len(completions) == 1 and completions[0][-1] not in continuation_chars: if not cword_prequote: completions[0] += ' ' return completions def _ArgComplete(ai, **kwargs): """Runs argcomplete.autocomplete on a calliope argument interceptor.""" if '_ARGCOMPLETE' not in os.environ: return mute_stderr = None namespace = None try: # Monkeypatch argcomplete argparse Namespace to be the Calliope extended # Namespace so the parsed_args passed to the completers is an extended # Namespace object. namespace = argcomplete.argparse.Namespace argcomplete.argparse.Namespace = parser_extensions.Namespace # Monkeypatch disable argcomplete.mute_stderr if the caller wants to see # error output. This is indispensible for debugging Cloud SDK completers. # It's much less verbose than the argcomplete _ARC_DEBUG output. if '_ARGCOMPLETE_TRACE' in os.environ: mute_stderr = argcomplete.mute_stderr def _DisableMuteStderr(): pass argcomplete.mute_stderr = _DisableMuteStderr completer = _CompletionFinder() # pylint: disable=not-callable completer( ai, always_complete_options=False, **kwargs) finally: if namespace: argcomplete.argparse.Namespace = namespace if mute_stderr: argcomplete.mute_stderr = mute_stderr def _SubParsersActionCall(self, parser, namespace, values, option_string=None): """argparse._SubParsersAction.__call__ version 1.2.1 MonkeyPatch.""" del option_string # pylint: disable=protected-access parser_name = values[0] arg_strings = values[1:] # set the parser name if requested if self.dest is not argparse.SUPPRESS: setattr(namespace, self.dest, parser_name) # select the parser try: parser = self._name_parser_map[parser_name] except KeyError: tup = parser_name, ', '.join(self._name_parser_map) msg = argparse._('unknown parser %r (choices: %s)' % tup) raise argparse.ArgumentError(self, msg) # parse all the remaining options into the namespace # store any unrecognized options on the object, so that the top # level parser can decide what to do with them namespace, arg_strings = parser.parse_known_args(arg_strings, namespace) if arg_strings: vars(namespace).setdefault(argparse._UNRECOGNIZED_ARGS_ATTR, []) getattr(namespace, argparse._UNRECOGNIZED_ARGS_ATTR).extend(arg_strings) # pylint: enable=protected-access class CLI(object): """A generated command line tool.""" def __init__(self, name, top_element, pre_run_hooks, post_run_hooks, known_error_handler): # pylint: disable=protected-access self.__name = name self.__parser = top_element._parser self.__top_element = top_element self.__pre_run_hooks = pre_run_hooks self.__post_run_hooks = post_run_hooks self.__known_error_handler = known_error_handler def _TopElement(self): return self.__top_element @property def name(self): return self.__name @property def top_element(self): return self.__top_element def IsValidCommand(self, cmd): """Checks if given command exists. Args: cmd: [str], The command path not including any arguments. Returns: True, if the given command exist, False otherwise. """ return self.__top_element.IsValidSubPath(cmd) def Execute(self, args=None, call_arg_complete=True): """Execute the CLI tool with the given arguments. Args: args: [str], The arguments from the command line or None to use sys.argv call_arg_complete: Call the _ArgComplete function if True Returns: The result of executing the command determined by the command implementation. Raises: ValueError: for ill-typed arguments. """ if isinstance(args, six.string_types): raise ValueError('Execute expects an iterable of strings, not a string.') # The argparse module does not handle unicode args when run in Python 2 # because it uses str(x) even when type(x) is unicode. This sets itself up # for failure because it converts unicode strings back to byte strings which # will trigger ASCII codec exceptions. It works in Python 3 because str() is # equivalent to unicode() in Python 3. The next Pythonically magic and dirty # statement coaxes the Python 3 behavior out of argparse running in # Python 2. Doing it here ensures that the workaround is in place for # calliope argparse use cases. argparse.str = six.text_type # We need the argparse 1.2.1 patch in _SubParsersActionCall. if argparse.__version__ == '1.1': argparse._SubParsersAction.__call__ = _SubParsersActionCall # pylint: disable=protected-access if call_arg_complete: _ArgComplete(self.__top_element.ai) if not args: args = argv_utils.GetDecodedArgv()[1:] # Look for a --configuration flag and update property state based on # that before proceeding to the main argparse parse step. named_configs.FLAG_OVERRIDE_STACK.PushFromArgs(args) properties.VALUES.PushInvocationValues() # Set the command name in case an exception happens before the command name # is finished parsing. command_path_string = self.__name specified_arg_names = None # Convert py2 args to text. argv = [console_attr.Decode(arg) for arg in args] if six.PY2 else args old_user_output_enabled = None old_verbosity = None try: args = self.__parser.parse_args(_ApplyFlagsFile(argv)) if args.CONCEPT_ARGS is not None: args.CONCEPT_ARGS.ParseConcepts() calliope_command = args._GetCommand() # pylint: disable=protected-access command_path_string = '.'.join(calliope_command.GetPath()) specified_arg_names = args.GetSpecifiedArgNames() # If the CLI has not been reloaded since the last command execution (e.g. # in test runs), args.CONCEPTS may contain cached values. if args.CONCEPTS is not None: args.CONCEPTS.Reset() # -h|--help|--document are dispatched by parse_args and never get here. # Now that we have parsed the args, reload the settings so the flags will # take effect. These will use the values from the properties. old_user_output_enabled = log.SetUserOutputEnabled(None) old_verbosity = log.SetVerbosity(None) # Set the command_name property so it is persisted until the process ends. # Only do this for the top level command that can be detected by looking # at the stack. It will have one initial level, and another level added by # the PushInvocationValues earlier in this method. if len(properties.VALUES.GetInvocationStack()) == 2: properties.VALUES.metrics.command_name.Set(command_path_string) # Set the invocation value for all commands, this is lost when popped properties.VALUES.SetInvocationValue( properties.VALUES.metrics.command_name, command_path_string, None) for hook in self.__pre_run_hooks: hook.Run(command_path_string) resources = calliope_command.Run(cli=self, args=args) for hook in self.__post_run_hooks: hook.Run(command_path_string) # Preserve generator or static list resources. if isinstance(resources, types.GeneratorType): def _Yield(): """Activates generator exceptions.""" try: for resource in resources: yield resource except Exception as exc: # pylint: disable=broad-except self._HandleAllErrors(exc, command_path_string, specified_arg_names) return _Yield() # Do this last. If there is an error, the error handler will log the # command execution along with the error. metrics.Commands( command_path_string, config.CLOUD_SDK_VERSION, specified_arg_names ) return resources except exceptions.DryRunError as exc: return exc.request except Exception as exc: # pylint: disable=broad-except self._HandleAllErrors(exc, command_path_string, specified_arg_names) finally: properties.VALUES.PopInvocationValues() named_configs.FLAG_OVERRIDE_STACK.Pop() # Reset these values to their previous state now that we popped the flag # values. if old_user_output_enabled is not None: log.SetUserOutputEnabled(old_user_output_enabled) if old_verbosity is not None: log.SetVerbosity(old_verbosity) def _HandleAllErrors(self, exc, command_path_string, specified_arg_names): """Handle all errors. Args: exc: Exception, The exception that was raised. command_path_string: str, The '.' separated command path. specified_arg_names: [str], The specified arg named scrubbed for metrics. Raises: exc or a core.exceptions variant that does not produce a stack trace. """ if ( 'CLOUDSDK_CORE_DRY_RUN' in os.environ and os.environ['CLOUDSDK_CORE_DRY_RUN'] == '1' ): # in dry run mode, we want to raise exception to get reason of failure raise exc error_extra_info = {'error_code': getattr(exc, 'exit_code', 1)} # Returns exc.payload.status if available. Otherwise, None. http_status_code = getattr(getattr(exc, 'payload', None), 'status_code', None) if http_status_code is not None: error_extra_info['http_status_code'] = http_status_code metrics.Commands( command_path_string, config.CLOUD_SDK_VERSION, specified_arg_names, error=exc.__class__, error_extra_info=error_extra_info) metrics.Error(command_path_string, exc.__class__, specified_arg_names, error_extra_info=error_extra_info) exceptions.HandleError(exc, command_path_string, self.__known_error_handler)