# -*- coding: utf-8 -*- # # Copyright 2015 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. """A module for the Cloud SDK CLI tree external representation.""" from __future__ import absolute_import from __future__ import division from __future__ import unicode_literals import json import os import re import sys import textwrap from googlecloudsdk.calliope import walker from googlecloudsdk.core import config from googlecloudsdk.core import exceptions from googlecloudsdk.core import module_util from googlecloudsdk.core.util import files import six # Lazy import modules to improve tab completion performance. # Alternatively, this module could be reorganized to separate tree loading from # dumping but that would have significant fallout for the module's usage # throughout the code base. # pylint:disable=g-import-not-at-top # This module is the CLI tree generator. VERSION is a stamp that is used to # detect breaking changes. If an external CLI tree version does not exactly # match VERSION then it is incompatible and must be regenerated or ignored. # Any changes to the serialized CLI dict attribute names or value semantics # must increment VERSION. For this reason it's a monotonically increasing # integer string and not a semver. VERSION = '1' DEFAULT_CLI_NAME = 'gcloud' # A READONLY tree is accepted and never regenerated by default. CLI_VERSION_READONLY = 'READONLY' # UNKNOWN is used when we don't know how to regenerate an existing tree. CLI_VERSION_UNKNOWN = 'UNKNOWN' # The release CLI version is a semver. In pre-prelease and test environments # it could be a constant string or YYYY.MM.DD stamp, respectively. For test # statis the stamp is replaced by a fixed string. TEST_CLI_VERSION_HEAD = 'HEAD' TEST_CLI_VERSION_TEST = 'TEST' LOOKUP_ARGUMENTS = 'arguments' LOOKUP_CLI_VERSION = 'CLI_VERSION' LOOKUP_VERSION = 'VERSION' LOOKUP_ATTR = 'attr' LOOKUP_CAPSULE = 'capsule' LOOKUP_CATEGORY = 'category' LOOKUP_CHOICES = 'choices' LOOKUP_HIDDEN_CHOICES = 'hidden_choices' LOOKUP_COMMANDS = 'commands' LOOKUP_COMPLETER = 'completer' LOOKUP_CONSTRAINTS = 'constraints' LOOKUP_DEFAULT = 'default' LOOKUP_DESCRIPTION = 'description' LOOKUP_FLAGS = 'flags' LOOKUP_GROUP = 'group' LOOKUP_GROUPS = 'groups' LOOKUP_INVERTED_SYNOPSIS = 'inverted_synopsis' LOOKUP_IS_GLOBAL = 'is_global' LOOKUP_IS_GROUP = 'is_group' LOOKUP_IS_HIDDEN = 'is_hidden' LOOKUP_IS_MUTEX = 'is_mutex' LOOKUP_IS_POSITIONAL = 'is_positional' LOOKUP_IS_REQUIRED = 'is_required' LOOKUP_NAME = 'name' LOOKUP_ALTERNATIVE_NAMES = 'alternative_names' LOOKUP_NARGS = 'nargs' LOOKUP_PATH = 'path' LOOKUP_POSITIONALS = 'positionals' LOOKUP_PROPERTY = 'property' LOOKUP_RELEASE = 'release' LOOKUP_REQUIRED = 'required' LOOKUP_SECTIONS = 'sections' LOOKUP_TYPE = 'type' LOOKUP_UNIVERSE_COMPATIBLE = 'universe_compatible' LOOKUP_DEFAULT_UNIVERSE_COMPATIBLE = 'default_universe_compatible' LOOKUP_VALUE = 'value' class Error(exceptions.Error): """Base exception for this module.""" class CliCommandVersionError(Error): """Loaded CLI tree CLI command version mismatch.""" class SdkRootNotFoundError(Error): """Raised if SDK root is not found.""" class SdkConfigNotFoundError(Error): """Raised if SDK root config/ does not exist.""" class SdkDataCliNotFoundError(Error): """Raised if SDK root data/cli/ does not exist.""" class CliTreeVersionError(Error): """Loaded CLI tree version mismatch.""" class CliTreeLoadError(Error): """CLI tree load error.""" def _IsRunningUnderTest(): """Mock function that returns True if running under test.""" return False def _GetDefaultCliCommandVersion(): """Return the default CLI command version.""" if _IsRunningUnderTest(): # test installation - return a constant version for reproducability return TEST_CLI_VERSION_TEST version = config.CLOUD_SDK_VERSION if version != TEST_CLI_VERSION_HEAD: # normal installation return version try: from googlecloudsdk.core.updater import update_manager manager = update_manager.UpdateManager() components = manager.GetCurrentVersionsInformation() # personal installation version = components['core'] # YYYY.MM.DD more informative than HEAD except (KeyError, exceptions.Error): # HEAD will have to do pass return version def _GetDescription(arg): """Returns the most detailed description from arg.""" from googlecloudsdk.calliope import usage_text return usage_text.GetArgDetails(arg) def _NormalizeDescription(description): """Normalizes description text. Args: description: str, The text to be normalized. Returns: str, The normalized text. """ if callable(description): description = description() if description: description = textwrap.dedent(description) return six.text_type(description or '') class Argument(object): """Group, Flag or Positional argument. Attributes: attr: dict, Miscellaneous {name: value} attributes. description: str, The help text. is_hidden: bool, True if the argument help text is disabled. is_group: bool, True if this is an argument group. is_positional: bool, True if this is a positional argument. is_mutex: bool, True if this is a mutex group. is_required: bool, The argument must be specified. """ def __init__(self, arg): self.attr = {} self.description = _NormalizeDescription(_GetDescription(arg)) self.is_group = False self.is_hidden = getattr(arg, 'is_hidden', getattr(arg, 'hidden', False)) self.is_positional = False self.is_mutex = getattr(arg, 'is_mutex', getattr(arg, 'mutex', False)) self.is_required = arg.is_required class FlagOrPositional(Argument): """Group, Flag or Positional argument. Attributes: category: str, The argument help category name. completer: str, Resource completer module path. default: (self.type), The default flag value or None if no default. description: str, The help text. name: str, The normalized name ('_' => '-'). nargs: {0, 1, '?', '*', '+'} value: str, The argument value documentation name. alternative_names: list, The list of alternative names. """ def __init__(self, arg, name): super(FlagOrPositional, self).__init__(arg) self.category = getattr(arg, LOOKUP_CATEGORY, '') completer = getattr(arg, LOOKUP_COMPLETER, None) if completer: try: # A calliope.parser_completer.ArgumentCompleter object. completer_class = completer.completer_class except AttributeError: # An argparse callable completer. completer_class = completer completer = module_util.GetModulePath(completer_class) self.completer = completer self.default = arg.default self.description = _NormalizeDescription(_GetDescription(arg)) self.name = six.text_type(name) self.alternative_names = getattr(arg, LOOKUP_ALTERNATIVE_NAMES, []) self.nargs = six.text_type(arg.nargs or 0) if arg.metavar: self.value = six.text_type(arg.metavar) else: self.value = self.name.lstrip('-').replace('-', '_').upper() self._Scrub() def _Scrub(self): """Scrubs private paths in the default value and description. Argument default values and "The default is ..." description text are the only places where dynamic private file paths can leak into the cli_tree. This method is called on all args. The test is rudimentary but effective. Any default value that looks like an absolute path on unix or windows is scrubbed. The default value is set to None and the trailing "The default ... is ..." sentence in the description, if any, is deleted. It's OK to be conservative here and match aggressively. """ if not isinstance(self.default, six.string_types): return if not re.match(r'/|[A-Za-z]:\\', self.default): return self.default = None match = re.match( r'(.*\.) The default (value )?is ', self.description, re.DOTALL ) if match: self.description = match.group(1) class Flag(FlagOrPositional): """Flag info. Attributes: choices: list|dict, The list of static choices. is_global: bool, True if the flag is global (inherited from the root). type: str, The flag value type name. """ def __init__(self, flag, name): from googlecloudsdk.calliope import arg_parsers super(Flag, self).__init__(flag, name) self.choices = [] self.is_global = flag.is_global # ArgParse does not have an explicit Boolean flag type. By # convention a flag with arg.nargs=0 and action='store_true' or # action='store_false' is a Boolean flag. arg.type gives no hint # (arg.type=bool would have been so easy) and we don't have access # to args.action here. Even then the flag can take on non-Boolean # values. If arg.default is not specified then it will be None, but # it can be set to anything. So we do a conservative 'truthiness' # test here. if flag.nargs == 0: self.type = 'bool' self.default = bool(flag.default) else: if flag.type is int or isinstance(flag.default, int): self.type = 'int' elif flag.type is float or isinstance(flag.default, float): self.type = 'float' elif isinstance(flag.type, arg_parsers.ArgDict): self.type = 'dict' elif isinstance(flag.type, arg_parsers.ArgList): self.type = 'list' else: self.type = module_util.GetModulePath(flag.type) or 'string' if flag.choices: choices = sorted(flag.choices) if choices == ['false', 'true']: self.type = 'bool' else: self.choices = flag.choices if hidden_choices := getattr(flag, LOOKUP_HIDDEN_CHOICES, None): self.attr[LOOKUP_HIDDEN_CHOICES] = sorted(hidden_choices) if getattr(flag, LOOKUP_ALTERNATIVE_NAMES, False): self.alternative_names = flag.alternative_names if getattr(flag, LOOKUP_INVERTED_SYNOPSIS, False): self.attr[LOOKUP_INVERTED_SYNOPSIS] = True prop, kind, value = getattr(flag, 'store_property', (None, None, None)) if prop: # This allows actions.Store*Property() to be reconstituted. attr = {LOOKUP_NAME: six.text_type(prop)} if kind == 'bool': flag.type = 'bool' if value: attr[LOOKUP_VALUE] = value self.attr[LOOKUP_PROPERTY] = attr class Positional(FlagOrPositional): """Positional info.""" def __init__(self, positional, name): super(Positional, self).__init__(positional, name) self.is_positional = True if positional.nargs is None: self.nargs = '1' self.is_required = positional.nargs not in (0, '?', '*', '...') class Group(Argument): """Makes a constraint group from a command argument interceptor. Attributes: arguments: [Argument], The list of arguments in the argument group. """ def __init__(self, group, key=None, arguments=None): super(Group, self).__init__(group) self._key = key self.is_group = True self.arguments = arguments class Constraint(Group): """Argument constraint group info.""" def __init__(self, group): order = [] for arg in group.arguments: if arg.is_group: constraint = Constraint(arg) order.append((constraint._key, constraint)) # pylint: disable=protected-access, _key must not be serialized elif arg.is_positional: name = arg.dest.replace('_', '-') order.append(('', Positional(arg, name))) else: for name in arg.option_strings: if name.startswith('--'): name = name.replace('_', '-') flag = Flag(arg, name) flag.alternative_names = [ alt for alt in arg.option_strings if alt != name ] order.append((name, flag)) order = sorted(order, key=lambda item: item[0]) super(Constraint, self).__init__( group, arguments=[item[1] for item in order], key=order[0][0] if order else '', ) class Command(object): """Command/group info. Attributes: capsule: str, The first line of the command docstring. commands: {name:Command}, The subcommands in a command group. constraints: [Argument], Argument constraint tree. flags: {str:Flag}, Command flag dict, indexed by normalized flag name. is_global: bool, True if the command is the root command. is_hidden: bool, True if the command is hidden. is_group: bool, True if the command is a group. path: [str], The command path. is_auto_generated: bool, True if this command or group is auto-generated. universe_compatible: bool, True if the command is universe compatible. default_universe_compatible: bool, True if the command is compatible in the default universe. name: str, The normalized name ('_' => '-'). positionals: [dict], Command positionals list. release: str, The command release name {'preview', 'alpha', 'beta', 'ga'}. sections: {str:str}, Section help dict, indexed by section name. At minimum contains the DESCRIPTION section. """ def __init__(self, command, parent): from googlecloudsdk.core.console import console_io self.commands = {} self.flags = {} self.is_global = not bool(parent) self.is_group = command.is_group self.is_hidden = command.IsHidden() self.is_auto_generated = command.IsAutoGenerated() self.universe_compatible = command.IsUniverseCompatible() self.default_universe_compatible = command.IsDefaultUniverseCompatible() self.name = command.name.replace('_', '-') self.path = command.GetPath() self.positionals = [] self.release = command.ReleaseTrack().id self.sections = {} command_path_string = ' '.join(self.path) parent_path_string = ' '.join(parent.path) if parent else '' self.release, capsule = self.__Release( command, self.release, getattr(command, 'short_help', '') ) # This code block must be meticulous on when and where LazyFormat expansion # is applied to the markdown snippets. First, no expanded text should be # passed as a LazyFormat kwarg. Second, no unexpanded text should appear # in the CLI tree. The LazyFormat calls are ordered to make sure that # doesn't happen. capsule = _NormalizeDescription(capsule) sections = {} self.release, description = self.__Release( command, self.release, getattr(command, 'long_help', '') ) detailed_help = getattr(command, 'detailed_help', {}) sections.update(detailed_help) description = _NormalizeDescription(description) if 'DESCRIPTION' not in sections: sections['DESCRIPTION'] = description notes = command.GetNotesHelpSection() if notes: sections['NOTES'] = notes if sections: for name, contents in six.iteritems(sections): # islower() section names were used to convert markdown in command # docstrings into the static self.section[] entries seen here. if name.isupper(): self.sections[name] = console_io.LazyFormat( _NormalizeDescription(contents), command=command_path_string, index=capsule, description=description, parent_command=parent_path_string, ) self.capsule = console_io.LazyFormat( capsule, command=command_path_string, man_name='.'.join(self.path), top_command=self.path[0] if self.path else '', parent_command=parent_path_string, **sections ) # _parent is explicitly private so it won't appear in serialized output. self._parent = parent if parent: parent.commands[self.name] = self args = command.ai # Collect the command specific flags. for arg in args.flag_args: for name in arg.option_strings: if name.startswith('--'): # Don't include ancestor flags, with the exception of --help. if name != '--help' and self.__Ancestor(name): continue name = name.replace('_', '-') flag = Flag(arg, name) flag.alternative_names = [ alt for alt in arg.option_strings if alt != name ] self.flags[flag.name] = flag # Collect the ancestor flags. for arg in args.ancestor_flag_args: for name in arg.option_strings: if name.startswith('--'): name = name.replace('_', '-') flag = Flag(arg, name) self.flags[flag.name] = flag # Collect the positionals. for arg in args.positional_args: name = arg.dest.replace('_', '-') positional = Positional(arg, name) self.positionals.append(positional) # Collect the arg group constraints. self.constraints = Constraint(args) def __Ancestor(self, flag): """Determines if flag is provided by an ancestor command. Args: flag: str, The flag name (no leading '-'). Returns: bool, True if flag provided by an ancestor command, false if not. """ command = self._parent while command: if flag in command.flags: return True command = command._parent # pylint: disable=protected-access return False def __Release(self, command, release, description): """Determines the release type from the description text. Args: command: Command, The CLI command/group description. release: int, The default release type. description: str, The command description markdown. Returns: (release, description): (int, str), The actual release and description with release prefix omitted. """ description = _NormalizeDescription(description) path = command.GetPath() if len(path) >= 2 and path[1] == 'internal': release = 'INTERNAL' return release, description class CliTreeGenerator(walker.Walker): """Generates an external representation of the gcloud CLI tree. This implements the resource generator for gcloud meta list-gcloud. """ def __init__(self, cli=None, branch=None, *args, **kwargs): """branch is the command path of the CLI subtree to generate.""" super(CliTreeGenerator, self).__init__(*args, cli=cli, **kwargs) self._branch = branch def Visit(self, node, parent, is_group): """Visits each node in the CLI command tree to construct the external rep. Args: node: group/command CommandCommon info. parent: The parent Visit() return value, None at the top level. is_group: True if node is a command group. Returns: The subtree parent value, used here to construct an external rep node. """ if self._Prune(node): return parent return Command(node, parent) def _Prune(self, command): """Returns True if command should be pruned from the CLI tree. Branch pruning is mainly for generating static unit test data. The static tree for the entire CLI would be an unnecessary burden on the depot. self._branch, if not None, is already split into a path with the first name popped. If branch is not a prefix of command.GetPath()[1:] it will be pruned. Args: command: The calliope Command object to check. Returns: True if command should be pruned from the CLI tree. """ # Only prune if branch is not empty. if not self._branch: return False path = command.GetPath() # The top level command is never pruned. if len(path) < 2: return False path = path[1:] # All tracks in the branch are active. if path[0] in ('alpha', 'beta', 'preview'): path = path[1:] for name in self._branch: # branch is longer than path => don't prune. if not path: return False # prefix mismatch => prune. if path[0] != name: return True path.pop(0) # branch is a prefix of path => don't prune. return False _LOOKUP_SERIALIZED_FLAG_LIST = 'SERIALIZED_FLAG_LIST' def _Serialize(tree): """Returns the CLI tree optimized for serialization. Serialized data does not support pointers. The CLI tree can have a lot of redundant data, especially with ancestor flags included with each command. This function collects the flags into the _LOOKUP_SERIALIZED_FLAG_LIST array in the root node and converts the flags dict values to indices into that array. Serialization saves a lot of space and allows the ancestor flags to be included in the LOOKUP_FLAGS dict of each command. It also saves time for users of the tree because the LOOKUP_FLAGS dict also contains the ancestor flags. Apply this function to the CLI tree just before dumping. For the 2017-03 gcloud CLI with alpha and beta included and all ancestor flags included in each command node this function reduces the generation time from ~2m40s to ~35s and the dump file size from 35Mi to 4.3Mi. Args: tree: The CLI tree to be optimized. Returns: The CLI tree optimized for serialization. """ # If tree is already serialized we're done. if getattr(tree, _LOOKUP_SERIALIZED_FLAG_LIST, None): return tree # Collect the dict of all flags. all_flags = {} class _FlagIndex(object): """Flag index + definition.""" def __init__(self, flag): self.flag = flag self.index = 0 def _FlagIndexKey(flag): return '::'.join([ six.text_type(flag.name), '[{}]'.format( ', '.join(six.text_type(n) for n in flag.alternative_names) ), six.text_type(flag.attr), six.text_type(flag.category), '[{}]'.format(', '.join(six.text_type(c) for c in flag.choices)), six.text_type(flag.completer), six.text_type(flag.default), six.text_type(flag.description), six.text_type(flag.is_hidden), six.text_type(flag.is_global), six.text_type(flag.is_group), six.text_type(flag.is_required), six.text_type(flag.nargs), six.text_type(flag.type), six.text_type(flag.value), ]) def _CollectAllFlags(command): for flag in command.flags.values(): all_flags[_FlagIndexKey(flag)] = _FlagIndex(flag) for subcommand in command.commands.values(): _CollectAllFlags(subcommand) _CollectAllFlags(tree) # Order the dict into the ordered tree _LOOKUP_SERIALIZED_FLAG_LIST list and # assign ordered indices to the all_flags dict entry. The indices are ordered # for reproducible serializations for testing. all_flags_list = [] for index, key in enumerate(sorted(all_flags)): fi = all_flags[key] fi.index = index all_flags_list.append(fi.flag) # Replace command flags dict values by the _LOOKUP_SERIALIZED_FLAG_LIST index. # Negative indices index into the command positionals. def _ReplaceConstraintFlagWithIndex(arguments): positional_index = 0 for i, arg in enumerate(arguments): if isinstance(arg, int): pass elif arg.is_group: _ReplaceConstraintFlagWithIndex(arg.arguments) elif arg.is_positional: positional_index -= 1 arguments[i] = positional_index else: try: arguments[i] = all_flags[_FlagIndexKey(arg)].index except KeyError: pass def _ReplaceFlagWithIndex(command): for name, flag in six.iteritems(command.flags): command.flags[name] = all_flags[_FlagIndexKey(flag)].index _ReplaceConstraintFlagWithIndex(command.constraints.arguments) for subcommand in command.commands.values(): _ReplaceFlagWithIndex(subcommand) _ReplaceFlagWithIndex(tree) setattr(tree, _LOOKUP_SERIALIZED_FLAG_LIST, all_flags_list) return tree def _DumpToFile(tree, f): """Dump helper.""" from googlecloudsdk.core.resource import resource_printer from googlecloudsdk.core.resource import resource_projector resource_printer.Print( resource_projector.MakeSerializable(_Serialize(tree)), 'json', out=f ) def CliTreeDir(): """The CLI tree default directory. This directory is part of the installation and its contents are managed by the installer/updater. Raises: SdkRootNotFoundError: If the SDK root directory does not exist. SdkDataCliNotFoundError: If the SDK root data CLI directory does not exist. Returns: The directory path. """ paths = config.Paths() if paths.sdk_root is None: raise SdkRootNotFoundError( 'SDK root not found for this installation. CLI tree cannot be ' 'loaded or generated.' ) directory = os.path.join(paths.sdk_root, 'data', 'cli') if not os.path.isdir(directory): raise SdkDataCliNotFoundError( 'SDK root data CLI directory [{}] not found for this installation. ' 'CLI tree cannot be loaded or generated.'.format(directory) ) return directory def CliTreeConfigDir(): """Returns the CLI tree config directory. This directory is part of the user config directory its contents are stable across releases/installations/updates. Raises: SdkConfigNotFoundError: If the SDK config directory does not exist. Returns: The directory path. """ global_config_dir = config.Paths().global_config_dir cli_tree_config_dir = os.path.join(global_config_dir, 'cli') if os.path.isdir(global_config_dir): if not os.path.isdir(cli_tree_config_dir): os.makedirs(cli_tree_config_dir, exist_ok=True) else: raise SdkConfigNotFoundError( 'CLI config directory [{}] not found for this installation. ' 'CLI tree cannot be loaded or generated.'.format(global_config_dir) ) return cli_tree_config_dir def CliTreePath(name=DEFAULT_CLI_NAME, directory=None): """Returns the CLI tree file path for name, default if directory is None.""" return os.path.join(directory or CliTreeDir(), name + '.json') def CliTreeConfigPath(name=DEFAULT_CLI_NAME, directory=None): """Returns the CLI tree config file path for name, default if directory is None.""" return os.path.join(directory or CliTreeConfigDir(), name + '.json') def _GenerateRoot(cli, path=None, name=DEFAULT_CLI_NAME, branch=None): """Generates and returns the CLI root for name.""" from googlecloudsdk.core.console import progress_tracker if path == '-': message = 'Generating the {} CLI'.format(name) elif path: message = 'Generating the {} CLI and caching in [{}]'.format(name, path) else: message = 'Generating the {} CLI for one-time use (no SDK root)'.format( name ) with progress_tracker.ProgressTracker(message): tree = CliTreeGenerator(cli, branch=branch).Walk(hidden=True) setattr(tree, LOOKUP_VERSION, VERSION) setattr(tree, LOOKUP_CLI_VERSION, _GetDefaultCliCommandVersion()) return tree def Dump(cli, path=None, name=DEFAULT_CLI_NAME, branch=None): """Dumps the CLI tree to a JSON file. The tree is processed by cli_tree._Serialize() to minimize the JSON file size and generation time. Args: cli: The CLI. path: The JSON file path to dump to, the standard output if '-', the default CLI tree path if None. name: The CLI name. branch: The path of the CLI subtree to generate. Returns: The generated CLI tree. """ if path is None: path = CliTreeConfigPath() tree = _GenerateRoot(cli=cli, path=path, name=name, branch=branch) if path == '-': _DumpToFile(tree, sys.stdout) else: with files.FileWriter(path) as f: _DumpToFile(tree, f) from googlecloudsdk.core.resource import resource_projector return resource_projector.MakeSerializable(tree) def _IsUpToDate(tree, path, ignore_errors, verbose): """Returns True if the CLI tree on path is up to date. Args: tree: The loaded CLI tree. path: The path tree was loaded from. ignore_errors: If True then return True if tree versions match. Otherwise raise exceptions on version mismatch. verbose: Display a status line for up to date CLI trees if True. Raises: CliTreeVersionError: tree version mismatch. CliCommandVersionError: CLI command version mismatch. Returns: True if tree versions match. """ expected_tree_version = VERSION actual_tree_version = tree.get(LOOKUP_VERSION) if actual_tree_version != expected_tree_version: if not ignore_errors: raise CliCommandVersionError( 'CLI tree [{}] version is [{}], expected [{}]'.format( path, actual_tree_version, expected_tree_version ) ) return False expected_command_version = _GetDefaultCliCommandVersion() actual_command_version = tree.get(LOOKUP_CLI_VERSION) test_versions = (TEST_CLI_VERSION_HEAD, TEST_CLI_VERSION_TEST) if ( actual_command_version in test_versions or expected_command_version in test_versions ): pass elif actual_command_version != expected_command_version: if not ignore_errors: raise CliCommandVersionError( 'CLI tree [{}] command version is [{}], expected [{}]'.format( path, actual_command_version, expected_command_version ) ) return False if verbose: from googlecloudsdk.core import log log.status.Print( '[{}] CLI tree version [{}] is up to date.'.format( DEFAULT_CLI_NAME, expected_command_version ) ) return True def _Load(path, cli=None, force=False, verbose=False): """Load() helper. Returns a tree or None if the tree failed to load.""" try: if not force: tree = json.loads(files.ReadFileContents(path)) if _IsUpToDate(tree, path, bool(cli), verbose): return tree del tree # Clobber path to make sure it's regenerated. try: os.remove(path) except OSError: pass except files.Error as e: if not cli: raise CliTreeLoadError(six.text_type(e)) return None def _Deserialize(tree): """Returns the deserialization of a serialized CLI tree.""" all_flags_list = tree.get(_LOOKUP_SERIALIZED_FLAG_LIST) if not all_flags_list: # If tree wasn't serialized we're done. return tree tree[_LOOKUP_SERIALIZED_FLAG_LIST] = None del tree[_LOOKUP_SERIALIZED_FLAG_LIST] def _ReplaceConstraintIndexWithArgReference(arguments, positionals): for i, arg in enumerate(arguments): if isinstance(arg, int): if arg < 0: # a positional index arguments[i] = positionals[-(arg + 1)] else: # a flag index arguments[i] = all_flags_list[arg] elif arg.get(LOOKUP_IS_GROUP, False): _ReplaceConstraintIndexWithArgReference( arg.get(LOOKUP_ARGUMENTS), positionals ) def _ReplaceIndexWithFlagReference(command): flags = command[LOOKUP_FLAGS] for name, index in six.iteritems(flags): flags[name] = all_flags_list[index] arguments = command[LOOKUP_CONSTRAINTS][LOOKUP_ARGUMENTS] _ReplaceConstraintIndexWithArgReference( arguments, command[LOOKUP_POSITIONALS] ) for subcommand in command[LOOKUP_COMMANDS].values(): _ReplaceIndexWithFlagReference(subcommand) _ReplaceIndexWithFlagReference(tree) return tree def Load( path=None, cli=None, force=False, one_time_use_ok=False, verbose=False ): """Loads the default CLI tree from the json file path. Args: path: The path name of the JSON file the CLI tree was dumped to. None for the default CLI tree path. cli: The CLI. If not None and path fails to import, a new CLI tree is generated, written to path, and returned. force: Update an existing tree by forcing it to be out of date if True. one_time_use_ok: If True and the load fails then the CLI tree is generated on the fly for one time use. verbose: Display a status line for up to date CLI trees if True. Raises: CliTreeVersionError: loaded tree version mismatch CliTreeLoadError: load errors Returns: The CLI tree. """ if path is None: try: path = CliTreeConfigPath() except SdkConfigNotFoundError: if cli and one_time_use_ok: from googlecloudsdk.core.resource import resource_projector tree = _GenerateRoot(cli) return resource_projector.MakeSerializable(tree) raise # First try to load the tree. tree = _Load(path, cli=cli, force=force, verbose=verbose) if not tree: # The load failed. Regenerate and attempt to load again. Dump(cli=cli, path=path) tree = _Load(path) return _Deserialize(tree) def Node( command=None, commands=None, constraints=None, flags=None, path=None, positionals=None, description=None, ): """Creates and returns a CLI tree node dict.""" path = [] if command: path.append(command) if not description: description = 'The {} command.'.format(command) return { LOOKUP_CAPSULE: '', LOOKUP_COMMANDS: commands or {}, LOOKUP_CONSTRAINTS: constraints or {}, LOOKUP_FLAGS: flags or {}, LOOKUP_IS_GROUP: True, LOOKUP_IS_HIDDEN: False, LOOKUP_PATH: path, LOOKUP_POSITIONALS: positionals or {}, LOOKUP_RELEASE: 'GA', LOOKUP_SECTIONS: {'DESCRIPTION': description}, }