1# -*- coding: utf-8 -*- #
2# Copyright 2017 Google LLC. All Rights Reserved.
3#
4# Licensed under the Apache License, Version 2.0 (the "License");
5# you may not use this file except in compliance with the License.
6# You may obtain a copy of the License at
7#
8#    http://www.apache.org/licenses/LICENSE-2.0
9#
10# Unless required by applicable law or agreed to in writing, software
11# distributed under the License is distributed on an "AS IS" BASIS,
12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13# See the License for the specific language governing permissions and
14# limitations under the License.
15
16"""Utilities for generating and parsing arguments from API fields."""
17
18from __future__ import absolute_import
19from __future__ import division
20from __future__ import unicode_literals
21
22from collections import OrderedDict
23import re
24
25from apitools.base.protorpclite import messages
26from apitools.base.py import encoding
27from googlecloudsdk.calliope import arg_parsers
28from googlecloudsdk.calliope import base
29from googlecloudsdk.core import properties
30from googlecloudsdk.core.resource import resource_property
31from googlecloudsdk.core.util import http_encoding
32
33import six
34
35# Used to determine if a value has been set for an argument
36UNSPECIFIED = object()
37
38
39class Error(Exception):
40  """Base exception for this module."""
41  pass
42
43
44class UnknownFieldError(Error):
45  """The referenced field could not be found in the message object."""
46
47  def __init__(self, field_name, message):
48    super(UnknownFieldError, self).__init__(
49        'Field [{}] not found in message [{}]. Available fields: [{}]'
50        .format(field_name, message.__class__.__name__,
51                ', '.join(f.name for f in message.all_fields())))
52
53
54class InvalidFieldPathError(Error):
55  """The referenced field path could not be found in the message object."""
56
57  def __init__(self, field_path, message, reason):
58    super(InvalidFieldPathError, self).__init__(
59        'Invalid field path [{}] for message [{}]. Details: [{}]'
60        .format(field_path, message.__class__.__name__, reason))
61
62
63class ArgumentGenerationError(Error):
64  """Generic error when we can't auto generate an argument for an api field."""
65
66  def __init__(self, field_name, reason):
67    super(ArgumentGenerationError, self).__init__(
68        'Failed to generate argument for field [{}]: {}'
69        .format(field_name, reason))
70
71
72def GetFieldFromMessage(message, field_path):
73  """Extract the field object from the message using a dotted field path.
74
75  If the field does not exist, an error is logged.
76
77  Args:
78    message: The apitools message to dig into.
79    field_path: str, The dotted path of attributes and sub-attributes.
80
81  Returns:
82    The Field object.
83  """
84  fields = field_path.split('.')
85  for f in fields[:-1]:
86    message = _GetField(message, f).type
87  return _GetField(message, fields[-1])
88
89
90def GetFieldValueFromMessage(message, field_path):
91  """Extract the value of the field given a dotted field path.
92
93  If the field_path does not exist, an error is logged.
94
95  Args:
96    message: The apitools message to dig into.
97    field_path: str, The dotted path of attributes and sub-attributes.
98
99  Raises:
100    InvalidFieldPathError: When the path is invalid.
101
102  Returns:
103    The value or if not set, None.
104  """
105  root_message = message
106  fields = field_path.split('.')
107  for i, f in enumerate(fields):
108    index_found = re.match(r'(.+)\[(\d+)\]$', f)
109    if index_found:
110      # Split field path segment (e.g. abc[1]) into abc and 1.
111      f, index = index_found.groups()
112      index = int(index)
113
114    try:
115      field = message.field_by_name(f)
116    except KeyError:
117      raise InvalidFieldPathError(field_path, root_message,
118                                  UnknownFieldError(f, message))
119    if index_found:
120      if not field.repeated:
121        raise InvalidFieldPathError(
122            field_path, root_message,
123            'Index cannot be specified for non-repeated field [{}]'.format(f))
124    else:
125      if field.repeated and i < len(fields) - 1:
126        raise InvalidFieldPathError(
127            field_path, root_message,
128            'Index needs to be specified for repeated field [{}]'.format(f))
129
130    message = getattr(message, f)
131    if message and index_found:
132      message = message[index] if index < len(message) else None
133
134    if not message and i < len(fields) - 1:
135      if isinstance(field, messages.MessageField):
136        # Create an instance of the message so we can continue down the path, to
137        # verify if the path is valid.
138        message = field.type()
139      else:
140        raise InvalidFieldPathError(
141            field_path, root_message,
142            '[{}] is not a valid field on field [{}]'
143            .format(f, field.type.__name__))
144
145  return message
146
147
148def SetFieldInMessage(message, field_path, value):
149  """Sets the given field in the message object.
150
151  Args:
152    message: A constructed apitools message object to inject the value into.
153    field_path: str, The dotted path of attributes and sub-attributes.
154    value: The value to set.
155  """
156  fields = field_path.split('.')
157  for f in fields[:-1]:
158    sub_message = getattr(message, f)
159    is_repeated = _GetField(message, f).repeated
160    if not sub_message:
161      sub_message = _GetField(message, f).type()
162      if is_repeated:
163        sub_message = [sub_message]
164      setattr(message, f, sub_message)
165    message = sub_message[0] if is_repeated else sub_message
166  field_type = _GetField(message, fields[-1]).type
167  if isinstance(value, dict):
168    value = encoding.PyValueToMessage(field_type, value)
169  if isinstance(value, list):
170    for i, item in enumerate(value):
171      if isinstance(item, dict):
172        value[i] = encoding.PyValueToMessage(field_type, item)
173  setattr(message, fields[-1], value)
174
175
176def _GetField(message, field_name):
177  try:
178    return message.field_by_name(field_name)
179  except KeyError:
180    raise UnknownFieldError(field_name, message)
181
182
183# TODO(b/64147277): Pass this down from the generator, don't hard code.
184DEFAULT_PARAMS = {'project': properties.VALUES.core.project.Get,
185                  'projectId': properties.VALUES.core.project.Get,
186                  'projectsId': properties.VALUES.core.project.Get,
187                 }
188
189
190def GetFromNamespace(namespace, arg_name, fallback=None, use_defaults=False):
191  """Gets the given argument from the namespace."""
192  if arg_name.startswith('--'):
193    arg_name = arg_name[2:]
194  normalized_arg_name = arg_name.replace('-', '_')
195  value = getattr(namespace, normalized_arg_name, None)
196  if not value and fallback:
197    value = fallback()
198  if not value and use_defaults:
199    value = DEFAULT_PARAMS.get(arg_name, lambda: None)()
200  return value
201
202
203def Limit(method, namespace):
204  """Gets the value of the limit flag (if present)."""
205  if (hasattr(namespace, 'limit') and method.IsPageableList() and
206      method.ListItemField()):
207    return getattr(namespace, 'limit')
208
209
210def PageSize(method, namespace):
211  """Gets the value of the page size flag (if present)."""
212  if (hasattr(namespace, 'page_size') and method.IsPageableList() and
213      method.ListItemField() and method.BatchPageSizeField()):
214    return getattr(namespace, 'page_size')
215
216
217class RepeatedMessageBindableType(object):
218  """An interface for custom type generators that bind directly to a message.
219
220  An argparse type function converts the parsed string into an object. Some
221  types (like ArgDicts) can only be generated once we know what message it will
222  be bound to (because the spec of the ArgDict depends on the fields and types
223  in the message. This interface allows encapsulating the logic to generate a
224  type function at the point when the message it is being bound to is known.
225  """
226
227  def GenerateType(self, message):
228    """Generates an argparse type function to use to parse the argument.
229
230    Args:
231      message: The apitools message class.
232    """
233    pass
234
235  def Action(self):
236    """The argparse action to use for this argument.
237
238    'store' is the default action, but sometimes something like 'append' might
239    be required to allow the argument to be repeated and all values collected.
240
241    Returns:
242      str, The argparse action to use.
243    """
244    return 'store'
245
246
247def GenerateFlag(field, attributes, fix_bools=True, category=None):
248  """Generates a flag for a single field in a message.
249
250  Args:
251    field: The apitools field object.
252    attributes: yaml_command_schema.Argument, The attributes to use to
253      generate the arg.
254    fix_bools: True to generate boolean flags as switches that take a value or
255      False to just generate them as regular string flags.
256    category: The help category to put the flag in.
257
258  Raises:
259    ArgumentGenerationError: When an argument could not be generated from the
260      API field.
261
262  Returns:
263    calliope.base.Argument, The generated argument.
264  """
265  variant = field.variant if field else None
266  t = attributes.type or TYPES.get(variant, None)
267
268  choices = None
269  if attributes.choices is not None:
270    choice_map = {c.arg_value: c.help_text for c in attributes.choices}
271    # If help text is provided, give a choice map. Otherwise, just use the
272    # choice values.
273    choices = (choice_map if any(choice_map.values())
274               else sorted(choice_map.keys()))
275  elif variant == messages.Variant.ENUM:
276    choices = [EnumNameToChoice(name) for name in sorted(field.type.names())]
277
278  action = attributes.action
279  if t == bool and fix_bools and not action:
280    # For boolean flags, we want to create a flag with action 'store_true'
281    # rather than a flag that takes a value and converts it to a boolean. Only
282    # do this if not using a custom action.
283    action = 'store_true'
284  # Default action is store if one was not provided.
285  action = action or 'store'
286
287  # pylint: disable=g-explicit-bool-comparison, only an explicit False should
288  # override this, None just means to do the default.
289  repeated = (field and field.repeated) and attributes.repeated != False
290
291  if repeated:
292    if action != 'store':
293      raise ArgumentGenerationError(
294          field.name,
295          'The field is repeated but is using a custom action. You might'
296          ' want to set repeated: False in your arg spec.')
297    if t:
298      # A special ArgDict wrapper type was given, bind it to the message so it
299      # can generate the message from the key/value pairs.
300      if isinstance(t, RepeatedMessageBindableType):
301        action = t.Action()
302        t = t.GenerateType(field.type)
303      # If a simple type was provided, just use a list of that type (even if it
304      # is a message). The type function will be responsible for converting to
305      # the correct value. If type is an ArgList or ArgDict, don't try to wrap
306      # it.
307      elif not isinstance(t, arg_parsers.ArgList):
308        t = arg_parsers.ArgList(element_type=t, choices=choices)
309        # Don't register the choices on the argparse arg because it is validated
310        # by the ArgList.
311        choices = None
312  elif isinstance(t, RepeatedMessageBindableType):
313    raise ArgumentGenerationError(
314        field.name, 'The given type can only be used on repeated fields.')
315
316  if field and not t and action == 'store' and not attributes.processor:
317    # The type is unknown and there is no custom action or processor, we don't
318    # know what to do with this.
319    raise ArgumentGenerationError(
320        field.name, 'The field is of an unknown type. You can specify a type '
321                    'function or a processor to manually handle this argument.')
322
323  name = attributes.arg_name
324  arg = base.Argument(
325      name if attributes.is_positional else '--' + name,
326      category=category if not attributes.is_positional else None,
327      action=action,
328      completer=attributes.completer,
329      help=attributes.help_text,
330      hidden=attributes.hidden,
331  )
332  if attributes.default != UNSPECIFIED:
333    arg.kwargs['default'] = attributes.default
334  if action != 'store_true':
335    # For this special action type, it won't accept a bunch of the common
336    # kwargs, so we can only add them if not generating a boolean flag.
337    metavar = attributes.metavar or name
338    arg.kwargs['metavar'] = resource_property.ConvertToAngrySnakeCase(
339        metavar.replace('-', '_'))
340    arg.kwargs['type'] = t
341    arg.kwargs['choices'] = choices
342  if not attributes.is_positional:
343    arg.kwargs['required'] = attributes.required
344  return arg
345
346
347def ConvertValue(field, value, repeated=None, processor=None, choices=None):
348  """Coverts the parsed value into something to insert into a request message.
349
350  If a processor is registered, that is called on the value.
351  If a choices mapping was provided, each value is mapped back into its original
352  value.
353  If the field is an enum, the value will be looked up by name and the Enum type
354  constructed.
355
356  Args:
357    field: The apitools field object.
358    value: The parsed value. This must be a scalar for scalar fields and a list
359      for repeated fields.
360    repeated: bool, Set to False if this arg was forced to be singular even
361      though the API field it corresponds to is repeated.
362    processor: A function to process the value before putting it into the
363      message.
364    choices: {str: str} A mapping of argument value, to enum API enum value.
365
366  Returns:
367    The value to insert into the message.
368  """
369  # pylint: disable=g-explicit-bool-comparison, only an explicit False should
370  # override this, None just means to do the default.
371  arg_repeated = field.repeated and repeated != False
372
373  if processor:
374    value = processor(value)
375  else:
376    valid_choices = None
377    if choices:
378      valid_choices = choices.keys()
379      if field.variant == messages.Variant.ENUM:
380        api_names = field.type.names()
381      else:
382        api_names = []
383      CheckValidEnumNames(api_names, choices.values())
384      if arg_repeated:
385        value = [_MapChoice(choices, v) for v in value]
386      else:
387        value = _MapChoice(choices, value)
388    if field.variant == messages.Variant.ENUM:
389      t = field.type
390      if arg_repeated:
391        value = [ChoiceToEnum(v, t, valid_choices=valid_choices) for v in value]
392      else:
393        value = ChoiceToEnum(value, t, valid_choices=valid_choices)
394
395  if field.repeated and not arg_repeated and not isinstance(value, list):
396    # If we manually made this arg singular, but it is actually a repeated field
397    # wrap it in a list.
398    value = [value]
399  return value
400
401
402def _MapChoice(choices, value):
403  if isinstance(value, six.string_types):
404    value = value.lower()
405  return choices.get(value, value)
406
407
408def ParseResourceIntoMessage(ref, method, message, resource_method_params=None,
409                             request_id_field=None, use_relative_name=True):
410  """Set fields in message corresponding to a resource.
411
412  Args:
413    ref: googlecloudsdk.core.resources.Resource, the resource reference.
414    method: the API method.
415    message: apitools Message object.
416    resource_method_params: {str: str}, A mapping of API method parameter name
417      to resource ref attribute name, if any
418    request_id_field: str, the name that the ID of the resource arg takes if the
419      API method params and the resource params don't match.
420    use_relative_name: Used ref.RelativeName() if True, otherwise ref.Name().
421  """
422  resource_method_params = resource_method_params or {}
423  resource_method_params = resource_method_params.copy()
424
425  # This only happens for non-list methods where the API method params don't
426  # match the resource parameters (basically only create methods). In this
427  # case, we re-parse the resource as its parent collection (to fill in the
428  # API parameters, and we insert the name of the resource itself into the
429  # correct position in the body of the request method.
430  if (request_id_field and method.resource_argument_collection.detailed_params
431      != method.request_collection.detailed_params):
432    # Sets the name of the resource in the message object body.
433    SetFieldInMessage(message, request_id_field, ref.Name())
434    # Create a reference for the parent resource to put in the API params.
435    ref = ref.Parent(
436        parent_collection=method.request_collection.full_name)
437
438  ref_name = ref.RelativeName() if use_relative_name else ref.Name()
439  for p in method.params:
440    value = getattr(ref, resource_method_params.pop(p, p), ref_name)
441    SetFieldInMessage(message, p, value)
442  for message_field_name, ref_param_name in resource_method_params.items():
443    value = getattr(ref, ref_param_name, ref_name)
444    SetFieldInMessage(message, message_field_name, value)
445
446
447def ParseStaticFieldsIntoMessage(message, static_fields=None):
448  """Set fields in message corresponding to a dict of static field values.
449
450  Args:
451    message: the Apitools message.
452    static_fields: dict of fields to values.
453  """
454  static_fields = static_fields or {}
455  for field_path, value in six.iteritems(static_fields):
456    field = GetFieldFromMessage(message, field_path)
457    SetFieldInMessage(
458        message, field_path, ConvertValue(field, value))
459
460
461def ParseExistingMessageIntoMessage(message, existing_message, method):
462  """Sets fields in message based on an existing message.
463
464  This function is used for get-modify-update pattern. The request type of
465  update requests would be either the same as the response type of get requests
466  or one field inside the request would be the same as the get response.
467
468  For example:
469  1) update.request_type_name = ServiceAccount
470     get.response_type_name = ServiceAccount
471  2) update.request_type_name = updateInstanceRequest
472     updateInstanceRequest.instance = Instance
473     get.response_type_name = Instance
474
475  If the existing message has the same type as the message to be sent for the
476  request, then return the existing message instead. If they are different, find
477  the field in the message which has the same type as existing_message, then
478  assign exsiting message to that field.
479
480  Args:
481    message: the apitools message to construct a new request.
482    existing_message: the exsting apitools message returned from server.
483    method: APIMethod, the method to generate request for.
484
485  Returns:
486    A modified apitools message to be send to the method.
487  """
488  if type(existing_message) == type(message):  # pylint: disable=unidiomatic-typecheck
489    return existing_message
490
491  # For read-modify-update API calls, the field to modify will exist either in
492  # the request message itself, or in a nested message one level below the
493  # request. Assume at first that it exists in the request message itself:
494  field_path = method.request_field
495  field = message.field_by_name(method.request_field)
496  # If this is not the case, then the field must be nested one level below.
497  if field.message_type != type(existing_message):
498    # We don't know what the name of the field is in the nested message, so we
499    # look through all of them until we find one with the right type.
500    nested_message = field.message_type()
501    for nested_field in nested_message.all_fields():
502      try:
503        if nested_field.message_type == type(existing_message):
504          field_path += '.' + nested_field.name
505          break
506      except AttributeError:  # Ignore non-message fields.
507        pass
508
509  SetFieldInMessage(message, field_path, existing_message)
510  return message
511
512
513def CheckValidEnumNames(api_names, choices_values):
514  """Ensures the api_name given in the spec matches a value from the API."""
515  if api_names:
516    bad_choices = [name for name in choices_values if not (
517        name in api_names or ChoiceToEnumName(
518            six.text_type(name)) in api_names)]
519  else:
520    bad_choices = []
521  if bad_choices:
522    raise arg_parsers.ArgumentTypeError(
523        '{} is/are not valid enum values.'.format(', '.join(bad_choices)))
524
525
526def ChoiceToEnum(choice, enum_type, item_type='choice', valid_choices=None):
527  """Converts the typed choice into an apitools Enum value."""
528  if choice is None:
529    return None
530  name = ChoiceToEnumName(choice)
531  valid_choices = (valid_choices or
532                   [EnumNameToChoice(n) for n in enum_type.names()])
533  try:
534    return enum_type.lookup_by_name(name)
535  except KeyError:
536    raise arg_parsers.ArgumentTypeError(
537        'Invalid {item}: {selection}. Valid choices are: [{values}].'.format(
538            item=item_type,
539            selection=EnumNameToChoice(name),
540            values=', '.join(c for c in sorted(valid_choices))))
541
542
543def ChoiceToEnumName(choice):
544  """Converts a typeable choice to the string representation of the Enum."""
545  return choice.replace('-', '_').upper()
546
547
548def EnumNameToChoice(name):
549  """Converts the name of an Enum value into a typeable choice."""
550  return name.replace('_', '-').lower()
551
552
553_LONG_TYPE = long if six.PY2 else int
554
555
556TYPES = {
557    messages.Variant.DOUBLE: float,
558    messages.Variant.FLOAT: float,
559
560    messages.Variant.INT64: _LONG_TYPE,
561    messages.Variant.UINT64: _LONG_TYPE,
562    messages.Variant.SINT64: _LONG_TYPE,
563
564    messages.Variant.INT32: int,
565    messages.Variant.UINT32: int,
566    messages.Variant.SINT32: int,
567
568    messages.Variant.STRING: six.text_type,
569    messages.Variant.BOOL: bool,
570
571    # TODO(b/70980549): Do something better with bytes.
572    messages.Variant.BYTES: http_encoding.Encode,
573    # For enums, we want to accept upper and lower case from the user, but
574    # always compare against lowercase enum choices.
575    messages.Variant.ENUM: EnumNameToChoice,
576    messages.Variant.MESSAGE: None,
577}
578
579
580def FieldHelpDocs(message, section='Fields'):
581  """Gets the help text for the fields in the request message.
582
583  Args:
584    message: The apitools message.
585    section: str, The section to extract help data from. Fields is the default,
586      may also be Values to extract enum data, for example.
587
588  Returns:
589    {str: str}, A mapping of field name to help text.
590  """
591  field_helps = {}
592  current_field = None
593
594  match = re.search(r'^\s+{}:.*$'.format(section),
595                    message.__doc__ or '', re.MULTILINE)
596  if not match:
597    # Couldn't find any fields at all.
598    return field_helps
599
600  for line in message.__doc__[match.end():].splitlines():
601    match = re.match(r'^\s+(\w+): (.*)$', line)
602    if match:
603      # This line is the start of a new field.
604      current_field = match.group(1)
605      field_helps[current_field] = match.group(2).strip()
606    elif current_field:
607      # Append additional text to the in progress field.
608      to_append = line.strip()
609      if to_append:
610        current_text = field_helps.get(current_field, '')
611        field_helps[current_field] = current_text + ' ' + to_append
612
613  return field_helps
614
615
616def GetRecursiveMessageSpec(message, definitions=None):
617  """Gets the recursive representation of a message as a dictionary.
618
619  Args:
620    message: The apitools message.
621    definitions: A list of message definitions already encountered.
622
623  Returns:
624    {str: object}, A recursive mapping of field name to its data.
625  """
626  if definitions is None:
627    definitions = []
628  if message in definitions:
629    # This message has already been seen along this path,
630    # don't recursive (forever).
631    return {}
632  definitions.append(message)
633  field_helps = FieldHelpDocs(message)
634  data = {}
635  for field in message.all_fields():
636    field_data = {'description': field_helps.get(field.name)}
637    field_data['repeated'] = field.repeated
638    if field.variant == messages.Variant.MESSAGE:
639      field_data['type'] = field.type.__name__
640      fields = GetRecursiveMessageSpec(field.type, definitions=definitions)
641      if fields:
642        field_data['fields'] = fields
643    else:
644      field_data['type'] = field.variant
645      if field.variant == messages.Variant.ENUM:
646        enum_help = FieldHelpDocs(field.type, 'Values')
647        field_data['choices'] = {n: enum_help.get(n)
648                                 for n in field.type.names()}
649
650    data[field.name] = field_data
651  definitions.pop()
652  return data
653
654
655def IsOutputField(help_text):
656  """Determines if the given field is output only based on help text."""
657  return help_text and (
658      help_text.startswith('[Output Only]') or
659      help_text.endswith('@OutputOnly'))
660
661
662class ChoiceEnumMapper(object):
663  """Utility class for mapping apitools Enum messages to argparse choice args.
664
665  Dynamically builds a base.Argument from an enum message.
666  Derives choice values from supplied enum or an optional custom_mapping dict
667  (see below).
668
669  Class Attributes:
670   choices: Either a list of strings [str] specifying the commandline choice
671       values or an ordered dict of choice value to choice help string mappings
672       {str -> str}
673   enum: underlying enum whos values map to supplied choices.
674   choice_arg: base.Argument object
675   choice_mappings: Mapping of argparse choice value strings to enum values.
676   custom_mappings: Optional dict mapping enum values to a custom
677     argparse choice value. To maintain compatiblity with base.ChoiceAgrument(),
678     dict can be either:
679     {str-> str} - Enum String value to choice argument value i.e.
680     {'MY_MUCH_LONGER_ENUM_VALUE':'short-arg'}
681     OR
682     {str -> (str, str)} -  Enum string value to  tuple of
683     (choice argument value, choice help string) i.e.
684     {'MY_MUCH_LONGER_ENUM_VALUE':('short-arg','My short arg help text.')}
685  """
686  _CUSTOM_MAPPING_ERROR = ('custom_mappings must be a dict of enum string '
687                           'values to argparse argument choices. Choices must '
688                           'be either a string or a string tuple of (choice, '
689                           'choice_help_text): [{}]')
690
691  def __init__(self,
692               arg_name,
693               message_enum,
694               custom_mappings=None,
695               help_str=None,
696               required=False,
697               action=None,
698               metavar=None,
699               dest=None,
700               default=None,
701               hidden=False,
702               include_filter=None):
703    """Initialize ChoiceEnumMapper.
704
705    Args:
706      arg_name: str, The name of the argparse argument to create
707      message_enum: apitools.Enum, the enum to map
708      custom_mappings: See Above.
709      help_str: string, pass through for base.Argument,
710        see base.ChoiceArgument().
711      required: boolean,string, pass through for base.Argument,
712          see base.ChoiceArgument().
713      action: string or argparse.Action, string, pass through for base.Argument,
714          see base.ChoiceArgument().
715      metavar: string,  string, pass through for base.Argument,
716          see base.ChoiceArgument()..
717      dest: string, string, pass through for base.Argument,
718          see base.ChoiceArgument().
719      default: string, string, pass through for base.Argument,
720          see base.ChoiceArgument().
721      hidden: boolean, pass through for base.Argument,
722          see base.ChoiceArgument().
723      include_filter: callable, function of type string->bool used to filter
724          enum values from message_enum that should be included in choices.
725          If include_filter returns True for a particular enum value, it will be
726          included otherwise it will be excluded. This is ignored if
727          custom_mappings is specified.
728
729    Raises:
730      ValueError: If no enum is given, mappings are incomplete
731      TypeError: If invalid values are passed for base.Argument or
732       custom_mapping
733    """
734    # pylint:disable=protected-access
735    if not isinstance(message_enum, messages._EnumClass):
736      raise ValueError('Invalid Message Enum: [{}]'.format(message_enum))
737    self._arg_name = arg_name
738    self._enum = message_enum
739    self._custom_mappings = custom_mappings
740    if include_filter is not None and not callable(include_filter):
741      raise TypeError('include_filter must be callable received [{}]'.format(
742          include_filter))
743
744    self._filter = include_filter
745    self._filtered_enum = self._enum
746    self._ValidateAndParseMappings()
747    self._choice_arg = base.ChoiceArgument(
748        arg_name,
749        self.choices,
750        help_str=help_str,
751        required=required,
752        action=action,
753        metavar=metavar,
754        dest=dest,
755        default=default,
756        hidden=hidden)
757
758  def _ValidateAndParseMappings(self):
759    """Validates and parses choice to enum mappings.
760
761    Validates and parses choice to enum mappings including any custom mappings.
762
763    Raises:
764      ValueError: custom_mappings does not contain correct number of mapped
765        values.
766      TypeError: custom_mappings is incorrect type or contains incorrect types
767        for mapped values.
768    """
769    if self._custom_mappings:  # Process Custom Mappings
770      if not isinstance(self._custom_mappings, dict):
771        raise TypeError(
772            self._CUSTOM_MAPPING_ERROR.format(self._custom_mappings))
773      enum_strings = set([x.name for x in self._enum])
774      diff = set(self._custom_mappings.keys()) - enum_strings
775      if diff:
776        raise ValueError('custom_mappings [{}] may only contain mappings'
777                         ' for enum values. invalid values:[{}]'.format(
778                             ', '.join(self._custom_mappings.keys()),
779                             ', '.join(diff)))
780      try:
781        self._ParseCustomMappingsFromTuples()
782      except (TypeError, ValueError):
783        self._ParseCustomMappingsFromStrings()
784
785    else:  # No Custom Mappings so do automagic mapping
786      if callable(self._filter):
787        self._filtered_enum = [
788            e for e in self._enum if self._filter(e.name)
789        ]
790
791      self._choice_to_enum = {
792          EnumNameToChoice(x.name): x
793          for x in self._filtered_enum
794      }
795      self._enum_to_choice = {
796          y.name: x
797          for x, y in six.iteritems(self._choice_to_enum)
798      }
799      self._choices = sorted(self._choice_to_enum.keys())
800
801  def _ParseCustomMappingsFromTuples(self):
802    """Parses choice to enum mappings from custom_mapping with tuples.
803
804     Parses choice mappings from dict mapping Enum strings to a tuple of
805     choice values and choice help {str -> (str, str)} mapping.
806
807    Raises:
808      TypeError - Custom choices are not not valid (str,str) tuples.
809    """
810    self._choice_to_enum = {}
811    self._enum_to_choice = {}
812    self._choices = OrderedDict()
813    for enum_string, (choice, help_str) in sorted(
814        six.iteritems(self._custom_mappings)):
815      self._choice_to_enum[choice] = self._enum(enum_string)
816      self._enum_to_choice[enum_string] = choice
817      self._choices[choice] = help_str
818
819  def _ParseCustomMappingsFromStrings(self):
820    """Parses choice to enum mappings from custom_mapping with strings.
821
822     Parses choice mappings from dict mapping Enum strings to choice
823     values {str -> str} mapping.
824
825    Raises:
826      TypeError - Custom choices are not strings
827    """
828    self._choice_to_enum = {}
829    self._choices = []
830
831    for enum_string, choice_string in sorted(
832        six.iteritems(self._custom_mappings)):
833      if not isinstance(choice_string, six.string_types):
834        raise TypeError(
835            self._CUSTOM_MAPPING_ERROR.format(self._custom_mappings))
836      self._choice_to_enum[choice_string] = self._enum(enum_string)
837      self._choices.append(choice_string)
838    self._enum_to_choice = self._custom_mappings
839
840  def GetChoiceForEnum(self, enum_value):
841    """Converts an enum value to a choice argument value."""
842    return self._enum_to_choice.get(six.text_type(enum_value))
843
844  def GetEnumForChoice(self, choice_value):
845    """Converts a mapped string choice value to an enum."""
846    return self._choice_to_enum.get(choice_value)
847
848  @property
849  def choices(self):
850    return self._choices
851
852  @property
853  def enum(self):
854    return self._enum
855
856  @property
857  def filtered_enum(self):
858    return self._filtered_enum
859
860  @property
861  def choice_arg(self):
862    return self._choice_arg
863
864  @property
865  def choice_mappings(self):
866    return self._choice_to_enum
867
868  @property
869  def custom_mappings(self):
870    return self._custom_mappings
871
872  @property
873  def include_filter(self):
874    return self._filter
875