feat: Add new gcloud commands, API clients, and third-party libraries across various services.

This commit is contained in:
2026-01-01 20:26:35 +01:00
parent 5e23cbece0
commit a19e592eb7
25221 changed files with 8324611 additions and 0 deletions

View File

@@ -0,0 +1,778 @@
# -*- coding: utf-8 -*- #
# Copyright 2017 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.
"""Classes to specify concept and resource specs.
Concept specs hold information about concepts. "Concepts" are any entity that
has multiple attributes, which can be specified via multiple flags on the
command line. A single concept spec should be created and re-used for the same
concept everywhere it appears.
Resource specs (currently the only type of concept spec used in gcloud) hold
information about a Cloud resource. "Resources" are types of concepts that
correspond to Cloud resources specified by a collection path, such as
'example.projects.shelves.books'. Their attributes correspond to the parameters
of their collection path. As with concept specs, a single resource spec
should be defined and re-used for each collection.
For resources, attributes can be configured by ResourceParameterAttributeConfigs
using kwargs. In many cases, users should also be able to reuse configs for the
same attribute across several resources (for example,
'example.projects.shelves.books.pages' could also use the shelf and project
attribute configs).
"""
from __future__ import absolute_import
from __future__ import division
from __future__ import unicode_literals
import abc
import re
from googlecloudsdk.calliope.concepts import deps as deps_lib
from googlecloudsdk.calliope.concepts import deps_map_util
from googlecloudsdk.calliope.concepts import util as format_util
from googlecloudsdk.command_lib.util.apis import yaml_command_schema_util as util
from googlecloudsdk.core import exceptions
from googlecloudsdk.core import properties
from googlecloudsdk.core import resources
IGNORED_FIELDS = {
'project': 'project',
'projectId': 'project',
'projectsId': 'project',
}
class Error(exceptions.Error):
"""Base class for errors in this module."""
class InitializationError(Error):
"""Raised if a spec fails to initialize."""
class ResourceConfigurationError(Error):
"""Raised if a resource is improperly declared."""
class InvalidResourceArgumentLists(Error):
"""Exception for missing, extra, or out of order arguments."""
def __init__(self, expected, actual):
expected = ['[' + e + ']' if e in IGNORED_FIELDS else e for e in expected]
super(InvalidResourceArgumentLists, self).__init__(
'Invalid resource arguments: Expected [{}], Found [{}].'.format(
', '.join(expected), ', '.join(actual)))
class ConceptSpec(object, metaclass=abc.ABCMeta):
"""Base class for concept args."""
@property
@abc.abstractmethod
def attributes(self):
"""A list of Attribute objects representing the attributes of the concept.
"""
@property
@abc.abstractmethod
def name(self):
"""The name of the overall concept."""
@abc.abstractmethod
def IsAnchor(self, attribute):
"""Returns True if attribute is an anchor."""
@abc.abstractmethod
def IsLeafAnchor(self, attribute):
"""Returns True if attribute is a leaf anchor."""
@abc.abstractmethod
def Initialize(self, fallthroughs_map, parsed_args=None):
"""Initializes the concept using fallthroughs and parsed args."""
@abc.abstractmethod
def Parse(self, attribute_to_args_map, base_fallthroughs_map,
parsed_args=None, plural=False, allow_empty=False):
"""Lazy parsing function for resource."""
@abc.abstractmethod
def BuildFullFallthroughsMap(
self, attribute_to_args_map, base_fallthroughs_map):
"""Builds list of fallthroughs for each attribute."""
def __eq__(self, other):
if not isinstance(other, type(self)):
return False
else:
return self.name == other.name and self.attributes == other.attributes
def __hash__(self):
return hash(self.name) + hash(self.attributes)
class _Attribute(object):
"""A base class for concept attributes.
Attributes:
name: The name of the attribute. Used primarily to control the arg or flag
name corresponding to the attribute. Must be in all lower case.
param_name: corresponds to where the attribute is mapped in the resource
help_text: String describing the attribute's relationship to the concept,
used to generate help for an attribute flag.
required: True if the attribute is required.
fallthroughs: [googlecloudsdk.calliope.concepts.deps_lib.Fallthrough], the
list of sources of data, in priority order, that can provide a value for
the attribute if not given on the command line. These should only be
sources inherent to the attribute, such as associated properties, not
command-specific sources.
completer: core.cache.completion_cache.Completer, the completer associated
with the attribute.
value_type: the type to be accepted by the attribute arg. Defaults to str.
"""
def __init__(self, name, param_name, help_text=None, required=False,
fallthroughs=None, completer=None, value_type=None):
"""Initializes."""
# Check for attributes that mix lower- and uppercase. Camel case is not
# handled consistently among libraries.
if re.search(r'[A-Z]', name) and re.search('r[a-z]', name):
raise ValueError(
'Invalid attribute name [{}]: Attribute names should be in lower '
'snake case (foo_bar) so they can be transformed to flag names.'
.format(name))
self.name = name
self.param_name = param_name or name
self.help_text = help_text
self.required = required
self.fallthroughs = fallthroughs or []
self.completer = completer
self.value_type = value_type or str
def __eq__(self, other):
"""Overrides."""
if not isinstance(other, type(self)):
return False
return (self.name == other.name and self.param_name == other.param_name
and self.help_text == other.help_text
and self.required == other.required
and self.completer == other.completer
and self.fallthroughs == other.fallthroughs
and self.value_type == other.value_type)
def __hash__(self):
return sum(map(hash, [
self.name, self.param_name, self.help_text, self.required,
self.completer, self.value_type])) + sum(map(hash, self.fallthroughs))
class Attribute(_Attribute):
"""An attribute of a resource.
Has all attributes of the base class along with resource-specific attributes.
Attributes:
completion_request_params: {str: str}, a dict of field names to params to
use as static field values in any request to complete this resource.
completion_id_field: str, the ID field of the return value in the
response for completion requests.
"""
def __init__(self, name, completion_request_params=None,
completion_id_field=None, **kwargs):
"""Initializes."""
self.completion_request_params = completion_request_params or {}
self.completion_id_field = completion_id_field
super(Attribute, self).__init__(name, **kwargs)
def __eq__(self, other):
"""Overrides."""
return (super(Attribute, self).__eq__(other)
and self.completer == other.completer
and self.completion_request_params
== other.completion_request_params
and self.completion_id_field == other.completion_id_field)
def __hash__(self):
return super(Attribute, self).__hash__() + sum(
map(hash, [str(self.completer),
str(self.completion_request_params),
self.completion_id_field]))
class ResourceSpec(ConceptSpec):
"""Defines a Cloud resource as a set of attributes for argument creation.
"""
disable_auto_complete = True
@classmethod
def FromYaml(cls, yaml_data, is_positional=None, api_version=None):
"""Constructs an instance of ResourceSpec from yaml data.
Args:
yaml_data: dict, the parsed data from a resources.yaml file under
command_lib/.
is_positional: bool, optional value that determines if anchor argument is
a positional and reformats anchor attribute name accordingly.
api_version: string, overrides the default version in the resource
registry if provided.
Returns:
A ResourceSpec object.
"""
# pylint: disable=g-import-not-at-top
from googlecloudsdk.command_lib.util.apis import registry
# pylint: enable=g-import-not-at-top
collection = registry.GetAPICollection(
yaml_data['collection'], api_version=api_version)
attributes = ParseAttributesFromData(
yaml_data.get('attributes'), collection.detailed_params)
return cls(
resource_collection=collection.full_name,
resource_name=yaml_data['name'],
api_version=collection.api_version,
disable_auto_completers=yaml_data.get(
'disable_auto_completers', ResourceSpec.disable_auto_complete),
plural_name=yaml_data.get('plural_name'),
is_positional=is_positional,
**{attribute.parameter_name: attribute for attribute in attributes})
def __init__(self, resource_collection, resource_name='resource',
api_version=None, disable_auto_completers=disable_auto_complete,
plural_name=None, is_positional=None, **kwargs):
"""Initializes a ResourceSpec.
To use a ResourceSpec, give a collection path such as
'cloudiot.projects.locations.registries', and optionally an
API version.
For each parameter in the collection path, an attribute is added to the
resource spec. Names can be created by default or overridden in the
attribute_configs dict, which maps from the parameter name to a
ResourceParameterAttributeConfig object. ResourceParameterAttributeConfigs
also contain information about the help text that describes the attribute.
Attribute naming: By default, attributes are named after their collection
path param names, or "name" if they are the "anchor" attribute (the final
parameter in the path).
Args:
resource_collection: The collection path of the resource.
resource_name: The name of the resource, which will be used in attribute
help text. Defaults to 'resource'.
api_version: Overrides the default version in the resource
registry.
disable_auto_completers: bool, whether to add completers automatically
where possible.
plural_name: str, the pluralized name. Will be pluralized by default rules
if not given in cases where the resource is referred to in the plural.
is_positional: bool, optional value that determines if anchor argument is
a positional and reformats anchor attribute name accordingly.
**kwargs: Parameter names (such as 'projectsId') from the
collection path, mapped to ResourceParameterAttributeConfigs.
Raises:
ResourceConfigurationError: if the resource is given unknown params or the
collection has no params.
"""
self._name = resource_name
self.plural_name = plural_name
self.collection = resource_collection
self._resources = resources.REGISTRY.Clone()
self._collection_info = self._resources.GetCollectionInfo(
resource_collection, api_version=api_version)
self.disable_auto_completers = disable_auto_completers
collection_params = self._collection_info.GetParams('')
self._attributes = []
self._param_names_map = {}
orig_kwargs = list(kwargs.keys())
# Add attributes.
anchor = False
for i, param_name in enumerate(collection_params):
if i == len(collection_params) - 1:
anchor = True
attribute_config = kwargs.pop(param_name,
ResourceParameterAttributeConfig())
attribute_name = self._AttributeName(param_name, attribute_config,
anchor=anchor,
is_positional=is_positional)
new_attribute = Attribute(
name=attribute_name,
param_name=param_name,
help_text=attribute_config.help_text,
required=True,
fallthroughs=attribute_config.fallthroughs,
completer=attribute_config.completer,
value_type=attribute_config.value_type,
completion_request_params=attribute_config.completion_request_params,
completion_id_field=attribute_config.completion_id_field)
self._attributes.append(new_attribute)
# Keep a map from attribute names to param names. While attribute names
# are used for error messaging and arg creation/parsing, resource parsing
# during command runtime requires parameter names.
self._param_names_map[new_attribute.name] = param_name
if not self._attributes:
raise ResourceConfigurationError('Resource [{}] has no parameters; no '
'arguments will be generated'.format(
self._name))
if kwargs:
raise ResourceConfigurationError('Resource [{}] was given an attribute '
'config for unknown attribute(s): '
'Expected [{}], Found [{}]'
.format(self._name,
', '.join(collection_params),
', '.join(orig_kwargs)))
@property
def attributes(self):
return self._attributes
@property
def name(self):
return self._name
@property
def anchor(self):
"""The "anchor" attribute of the resource."""
# self.attributes cannot be empty; will cause an error on init.
return self.attributes[-1]
def IsAnchor(self, attribute):
"""Convenience method."""
return attribute == self.anchor
def IsLeafAnchor(self, attribute):
"""Convenience method."""
return self.IsAnchor(attribute)
@property
def attribute_to_params_map(self):
"""A map from all attribute names to param names."""
return self._param_names_map
@property
def collection_info(self):
return self._collection_info
# TODO(b/314193603): Add ParamName, AttributeName, and
# attribute_to_params_map to multitype to enable resource completers.
# Then add items to the meta class.
def ParamName(self, attribute_name):
"""Gets the param name from attribute. Used for autocompleters."""
if attribute_name not in self.attribute_to_params_map:
raise ValueError(
'No param name found for attribute [{}]. Existing attributes are '
'[{}]'.format(attribute_name,
', '.join(sorted(self.attribute_to_params_map.keys()))))
return self.attribute_to_params_map[attribute_name]
def AttributeName(self, param_name):
"""Gets the attribute name from param name. Used for autocompleters."""
for attribute_name, p in self.attribute_to_params_map.items():
if p == param_name:
return attribute_name
else:
return None
def Initialize(self, fallthroughs_map, parsed_args=None):
"""Initializes a resource given its fallthroughs.
The fallthrough map is used to derive each resource attribute (including
the anchor). Returns a fully parsed resource object.
Args:
fallthroughs_map: {str: [deps_lib._FallthroughBase]}, a dict of finalized
fallthroughs for the resource.
parsed_args: the argparse namespace.
Returns:
(googlecloudsdk.core.resources.Resource) the fully initialized resource.
Raises:
googlecloudsdk.calliope.concepts.concepts.InitializationError, if the
concept can't be initialized.
"""
params = {}
# Returns a function that can be used to parse each attribute, which will be
# used only if the resource parser does not receive a fully qualified
# resource name.
def LazyGet(name):
return lambda: deps_lib.Get(name, fallthroughs_map, parsed_args)
for attribute in self.attributes:
params[attribute.param_name] = LazyGet(attribute.name)
self._resources.RegisterApiByName(self._collection_info.api_name,
self._collection_info.api_version)
try:
return self._resources.Parse(
deps_lib.Get(
self.anchor.name, fallthroughs_map, parsed_args=parsed_args),
collection=self.collection,
params=params,
api_version=self._collection_info.api_version)
except deps_lib.AttributeNotFoundError as e:
raise InitializationError(
'The [{}] resource is not properly specified.\n'
'{}'.format(self.name, str(e)))
except resources.UserError as e:
raise InitializationError(str(e))
def Parse(self, attribute_to_args_map, base_fallthroughs_map,
parsed_args=None, plural=False, allow_empty=False):
"""Lazy parsing function for resource.
Generates resource based off of the parsed_args (user provided
arguments) and specified fallthrough behavior.
Args:
attribute_to_args_map: {str: str}, A map of attribute names to the names
of their associated flags.
base_fallthroughs_map: {str: [deps.Fallthrough]}, A map of attribute
names to non-argument fallthroughs, including command-level
fallthroughs.
parsed_args: the parsed Namespace.
plural: bool, True if multiple resources can be parsed, False otherwise.
allow_empty: bool, True if resource parsing is allowed to return no
resource, otherwise False.
Returns:
the initialized resources.Resource or a list of resources.Resource if the
resource argument is plural.
"""
if plural:
return self._ParseFromPluralValue(
attribute_to_args_map, base_fallthroughs_map, parsed_args,
allow_empty=allow_empty)
else:
return self._ParseFromValue(
attribute_to_args_map, base_fallthroughs_map, parsed_args,
allow_empty=allow_empty)
def BuildFullFallthroughsMap(
self, attribute_to_args_map, base_fallthroughs_map, parsed_args=None):
"""Generate fallthrough map that is used to resolve resource params.
Used as source of truth for how each attribute is resolved. It is also used
to generate help text for both plural and singular resources.
Fallthroughs are a list of objects that, when called, try different ways of
resolving a resource attribute (see googlecloudsdk.calliope.concepts.
deps_lib._Fallthrough). This method builds a map from the name of each
attribute to its list of fallthroughs.
For each attribute, adds default flag fallthroughs and fully specified
anchor fallthroughs.
Args:
attribute_to_args_map: {str: str}, A map of attribute names to the names
of their associated flags.
base_fallthroughs_map: {str: [deps.Fallthrough]}, A map of attribute
names to non-argument fallthroughs, including command-level
fallthroughs.
parsed_args: Namespace | None, user's CLI input
Returns:
{str: [deps.Fallthrough]}, a map from attribute name to all its
fallthroughs.
"""
fallthroughs_map = {**base_fallthroughs_map}
deps_map_util.AddFlagFallthroughs(
fallthroughs_map, self.attributes, attribute_to_args_map)
deps_map_util.UpdateWithValueFallthrough(
fallthroughs_map, self.anchor.name, parsed_args)
deps_map_util.AddAnchorFallthroughs(
fallthroughs_map, self.attributes, self.anchor, self.collection_info,
fallthroughs_map.get(self.anchor.name, []))
return fallthroughs_map
def _BuildFullFallthroughsMapList(
self, attribute_to_args_map, base_fallthroughs_map, parsed_args=None):
"""Builds fallthrough map for each anchor value specified in a list.
For each anchor value, create a falthrough map to derive the rest
of the resource params. For each attribute, adds flag fallthroughs
and fully specified anchor fallthroughs. For each attribute,
adds default flag fallthroughs and fully specified anchor fallthroughs.
Args:
attribute_to_args_map: {str: str}, A map of attribute names to the names
of their associated flags.
base_fallthroughs_map: FallthroughsMap, A map of attribute names to
non-argument fallthroughs, including command-level fallthroughs.
parsed_args: Namespace, used to parse the anchor value and derive
fully specified fallthroughs.
Returns:
list[FallthroughsMap], fallthrough map for each anchor value
"""
fallthroughs_map = {**base_fallthroughs_map}
deps_map_util.AddFlagFallthroughs(
fallthroughs_map, self.attributes, attribute_to_args_map)
deps_map_util.PluralizeFallthroughs(fallthroughs_map, self.anchor.name)
map_list = deps_map_util.CreateValueFallthroughMapList(
fallthroughs_map, self.anchor.name, parsed_args)
for full_map in map_list:
deps_map_util.AddAnchorFallthroughs(
full_map, self.attributes, self.anchor, self.collection_info,
full_map.get(self.anchor.name, []))
return map_list
def _ParseFromValue(
self, attribute_to_args_map, base_fallthroughs_map,
parsed_args, allow_empty=False):
"""Helper for parsing a singular resource from user input."""
fallthroughs_map = self.BuildFullFallthroughsMap(
attribute_to_args_map, base_fallthroughs_map, parsed_args)
try:
return self.Initialize(
fallthroughs_map, parsed_args=parsed_args)
except InitializationError:
if allow_empty:
return None
raise
def _ParseFromPluralValue(
self, attribute_to_args_map, base_fallthroughs_map,
parsed_args, allow_empty=False):
"""Helper for parsing a list of resources from user input."""
map_list = self._BuildFullFallthroughsMapList(
attribute_to_args_map, base_fallthroughs_map,
parsed_args=parsed_args)
parsed_resources = []
for fallthroughs_map in map_list:
resource = self.Initialize(fallthroughs_map, parsed_args=parsed_args)
parsed_resources.append(resource)
if parsed_resources:
return parsed_resources
elif allow_empty:
return []
else:
return self.Initialize(base_fallthroughs_map, parsed_args=parsed_args)
def _AttributeName(self, param_name, attribute_config, anchor=False,
is_positional=None):
"""Chooses attribute name for a param name.
If attribute_config gives an attribute name, that is used. Otherwise, if the
param is an anchor attribute, 'name' is used, or if not, param_name is used.
Args:
param_name: str, the parameter name from the collection.
attribute_config: ResourceParameterAttributeConfig, the config for the
param_name.
anchor: bool, whether the parameter is the "anchor" or the last in the
collection path.
is_positional: bool, optional value that determines if anchor argument is
a positional and reformats anchor attribute name accordingly.
Returns:
(str) the attribute name.
"""
attribute_name = attribute_config.attribute_name
if attribute_name:
# TODO(b/246766107) We need to investigate if we can reformat the
# attribute names automatically all the time. Currently, the attribute is
# only auto-formatted when positional is specified in resource spec.
# Currently, only resource specs generated by yaml files are passing in
# the is_positional value in order to avoid breaking changes.
if is_positional is None:
return attribute_name
return (format_util.SnakeCase(attribute_name) if is_positional and anchor
else format_util.KebabCase(attribute_name))
if anchor:
return 'name'
return param_name.replace('Id', '_id').lower()
def __eq__(self, other):
return (super(ResourceSpec, self).__eq__(other)
and self.disable_auto_completers == other.disable_auto_completers
and self.attribute_to_params_map == other.attribute_to_params_map)
def __hash__(self):
return super(ResourceSpec, self).__hash__() + sum(
map(hash, [self.disable_auto_completers, self.attribute_to_params_map]))
class ResourceParameterAttributeConfig(object):
"""Configuration used to create attributes from resource parameters."""
@classmethod
def FromData(cls, data):
"""Constructs an attribute config from data defined in the yaml file.
Args:
data: {}, the dict of data from the YAML file for this single attribute.
Returns:
ResourceParameterAttributeConfig
"""
attribute_name = data['attribute_name']
parameter_name = data['parameter_name']
help_text = data['help']
completer = util.Hook.FromData(data, 'completer')
completion_id_field = data.get('completion_id_field', None)
completion_request_params_list = data.get('completion_request_params', [])
completion_request_params = {
param.get('fieldName'): param.get('value')
for param in completion_request_params_list
}
if default_config := DEFAULT_RESOURCE_ATTRIBUTE_CONFIGS.get(attribute_name):
fallthroughs = default_config.fallthroughs.copy()
else:
fallthroughs = []
# Add property fallthroughs.
prop = properties.FromString(data.get('property', ''))
prop_fallthrough = prop and deps_lib.PropertyFallthrough(prop)
if prop_fallthrough and prop_fallthrough not in fallthroughs:
fallthroughs.append(prop_fallthrough)
# Add fallthroughs from python hooks.
fallthrough_data = data.get('fallthroughs', [])
fallthroughs_from_hook = []
for f in fallthrough_data:
if 'value' in f:
fallthroughs_from_hook.append(
deps_lib.ValueFallthrough(
f['value'], f['hint'] if 'hint' in f else None
)
)
elif 'hook' in f:
fallthroughs_from_hook.append(
deps_lib.Fallthrough(util.Hook.FromPath(f['hook']), hint=f['hint'])
)
fallthroughs += fallthroughs_from_hook
return cls(
name=attribute_name,
help_text=help_text,
fallthroughs=fallthroughs,
completer=completer,
completion_id_field=completion_id_field,
completion_request_params=completion_request_params,
parameter_name=parameter_name)
def __init__(self,
name=None,
help_text=None,
fallthroughs=None,
completer=None,
completion_request_params=None,
completion_id_field=None,
value_type=None,
parameter_name=None):
"""Create a resource attribute.
Args:
name: str, the name of the attribute. This controls the naming of flags
based on the attribute.
help_text: str, generic help text for any flag based on the attribute. One
special expansion is available to convert "{resource}" to the name of
the resource.
fallthroughs: [deps_lib.Fallthrough], A list of fallthroughs to use to
resolve the attribute if it is not provided on the command line.
completer: core.cache.completion_cache.Completer, the completer
associated with the attribute.
completion_request_params: {str: value}, a dict of field names to static
values to fill in for the completion request.
completion_id_field: str, the ID field of the return value in the
response for completion commands.
value_type: the type to be accepted by the attribute arg. Defaults to str.
parameter_name: the API parameter name that this attribute maps to.
"""
self.attribute_name = name
self.help_text = help_text
self.fallthroughs = fallthroughs or []
if completer and (completion_request_params or completion_id_field):
raise ValueError('Custom completer and auto-completer should not be '
'specified at the same time')
self.completer = completer
self.completion_request_params = completion_request_params
self.completion_id_field = completion_id_field
self.value_type = value_type or str
self.parameter_name = parameter_name
def ParseAttributesFromData(attributes_data, expected_param_names):
"""Parses a list of ResourceParameterAttributeConfig from yaml data.
Args:
attributes_data: dict, the attributes data defined in
command_lib/resources.yaml file.
expected_param_names: [str], the names of the API parameters that the API
method accepts. Example, ['projectsId', 'instancesId'].
Returns:
[ResourceParameterAttributeConfig].
Raises:
InvalidResourceArgumentLists: if the attributes defined in the yaml file
don't match the expected fields in the API method.
"""
raw_attributes = [
ResourceParameterAttributeConfig.FromData(a) for a in attributes_data
]
registered_param_names = [a.parameter_name for a in raw_attributes]
final_attributes = []
# TODO(b/78851830): improve the time complexity here.
for expected_name in expected_param_names:
if raw_attributes and expected_name == raw_attributes[0].parameter_name:
# Attribute matches expected, add it and continue checking.
final_attributes.append(raw_attributes.pop(0))
elif expected_name in IGNORED_FIELDS:
# Attribute doesn't match but is being ignored. Add an auto-generated
# attribute as a substitute.
# Currently, it would only be the project config.
attribute_name = IGNORED_FIELDS[expected_name]
ignored_attribute = DEFAULT_RESOURCE_ATTRIBUTE_CONFIGS.get(attribute_name)
# Manually add the parameter name, e.g. project, projectId or projectsId.
ignored_attribute.parameter_name = expected_name
final_attributes.append(ignored_attribute)
else:
# It doesn't match (or there are no more registered params) and the
# field is not being ignored, error.
raise InvalidResourceArgumentLists(expected_param_names,
registered_param_names)
if raw_attributes:
# All expected fields were processed but there are still registered
# attribute params remaining, they must be extra.
raise InvalidResourceArgumentLists(expected_param_names,
registered_param_names)
return final_attributes
DEFAULT_PROJECT_ATTRIBUTE_CONFIG = ResourceParameterAttributeConfig(
name='project',
help_text='Project ID of the Google Cloud project for the {resource}.',
fallthroughs=[
# Typically argument fallthroughs should be configured at the command
# level, but the --project flag is currently available in every command.
deps_lib.ArgFallthrough('--project'),
deps_lib.PropertyFallthrough(properties.VALUES.core.project)
])
DEFAULT_RESOURCE_ATTRIBUTE_CONFIGS = {
'project': DEFAULT_PROJECT_ATTRIBUTE_CONFIG}
_DEFAULT_CONFIGS = {'project': DEFAULT_PROJECT_ATTRIBUTE_CONFIG}

View File

@@ -0,0 +1,410 @@
# -*- coding: utf-8 -*- #
# Copyright 2017 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.
"""Classes to handle dependencies for concepts.
At runtime, resources can be parsed and initialized using the information given
in the Deps object. All the information given by the user in the command line is
available in the Deps object. It may also access other information (such as
information provided by the user during a prompt or properties that are changed
during runtime before the Deps object is used) when Get() is called for a given
attribute, depending on the fallthroughs.
"""
from __future__ import absolute_import
from __future__ import division
from __future__ import unicode_literals
import abc
from googlecloudsdk.calliope.concepts import util
from googlecloudsdk.core import exceptions
from googlecloudsdk.core import properties
from googlecloudsdk.core import resources
class Error(exceptions.Error):
"""Base exception type for this module."""
class FallthroughNotFoundError(Error):
"""Raised when an attribute value is not found by a Fallthrough object."""
class AttributeNotFoundError(Error, AttributeError):
"""Raised when an attribute value cannot be found by a Deps object."""
class _FallthroughBase(object, metaclass=abc.ABCMeta):
"""Represents a way to get information about a concept's attribute.
Specific implementations of Fallthrough objects must implement the method:
_Call():
Get a value from information given to the fallthrough.
GetValue() is used by the Deps object to attempt to find the value of an
attribute. The hint property is used to provide an informative error when an
attribute can't be found.
"""
def __init__(self, hint, active=False, plural=False):
"""Initializes a fallthrough to an arbitrary function.
Args:
hint: str | list[str], The user-facing message for the fallthrough
when it cannot be resolved.
active: bool, True if the fallthrough is considered to be "actively"
specified, i.e. on the command line.
plural: bool, whether the expected result should be a list. Should be
False for everything except the "anchor" arguments in a case where a
resource argument is plural (i.e. parses to a list).
"""
self._hint = hint
self.active = active
self.plural = plural
def GetValue(self, parsed_args):
"""Gets a value from information given to the fallthrough.
Args:
parsed_args: the argparse namespace.
Raises:
FallthroughNotFoundError: If the attribute is not found.
Returns:
The value of the attribute.
"""
value = self._Call(parsed_args)
if value:
return self._Pluralize(value)
raise FallthroughNotFoundError()
@abc.abstractmethod
def _Call(self, parsed_args):
pass
def _Pluralize(self, value):
"""Pluralize the result of calling the fallthrough. May be overridden."""
if not self.plural or isinstance(value, list):
return value
return [value] if value else []
@property
def hint(self):
"""String representation of the fallthrough for user-facing messaging."""
return self._hint
def __hash__(self):
return hash(self.hint) + hash(self.active)
def __eq__(self, other):
return (isinstance(other, self.__class__) and other.hint == self.hint and
other.active == self.active and other.plural == self.plural)
class Fallthrough(_FallthroughBase):
"""A fallthrough that can get an attribute value from an arbitrary function."""
def __init__(self, function, hint, active=False, plural=False):
"""Initializes a fallthrough to an arbitrary function.
Args:
function: f() -> value, A no argument function that returns the value of
the argument or None if it cannot be resolved.
hint: str, The user-facing message for the fallthrough when it cannot be
resolved. Should start with a lower-case letter.
active: bool, True if the fallthrough is considered to be "actively"
specified, i.e. on the command line.
plural: bool, whether the expected result should be a list. Should be
False for everything except the "anchor" arguments in a case where a
resource argument is plural (i.e. parses to a list).
Raises:
ValueError: if no hint is provided
"""
if not hint:
raise ValueError('Hint must be provided.')
super(Fallthrough, self).__init__(hint, active=active, plural=plural)
self._function = function
def _Call(self, parsed_args):
del parsed_args
return self._function()
def __eq__(self, other):
return (super(Fallthrough, self).__eq__(other) and
other._function == self._function) # pylint: disable=protected-access
def __hash__(self):
return hash(self._function)
class ValueFallthrough(_FallthroughBase):
"""Gets an attribute from a property."""
def __init__(self, value, hint=None, active=False, plural=False):
"""Initializes a fallthrough for the property associated with the attribute.
Args:
value: str, Denoting the fixed value to provide to the attribute.
hint: str, Optional, If provided, used over default help_text.
active: bool, Optional, whether the value is specified by the user on
the command line.
plural: bool, whether the expected result should be a list. Should be
False for everything except the "anchor" arguments in a case where a
resource argument is plural (i.e. parses to a list).
"""
hint = 'The default is `{}`'.format(value) if hint is None else hint
super(ValueFallthrough, self).__init__(hint, active=active, plural=plural)
self.value = value
def _Call(self, parsed_args):
del parsed_args # Not used.
return self.value
def __eq__(self, other):
if not isinstance(other, self.__class__):
return False
return other.value == self.value
def __hash__(self):
return hash(self.value)
class PropertyFallthrough(_FallthroughBase):
"""Gets an attribute from a property."""
def __init__(self, prop, plural=False):
"""Initializes a fallthrough for the property associated with the attribute.
Args:
prop: googlecloudsdk.core.properties._Property, a property.
plural: bool, whether the expected result should be a list. Should be
False for everything except the "anchor" arguments in a case where a
resource argument is plural (i.e. parses to a list).
"""
hint = 'set the property `{}`'.format(prop)
super(PropertyFallthrough, self).__init__(hint, plural=plural)
self.property = prop
def _Call(self, parsed_args):
del parsed_args # Not used.
try:
return self.property.GetOrFail()
except (properties.InvalidValueError, properties.RequiredPropertyError):
return None
def __eq__(self, other):
if not isinstance(other, self.__class__):
return False
return other.property == self.property
def __hash__(self):
return hash(self.property)
class ArgFallthrough(_FallthroughBase):
"""Gets an attribute from the argparse parsed values for that arg."""
def __init__(self, arg_name, plural=False):
"""Initializes a fallthrough for the argument associated with the attribute.
Args:
arg_name: str, the name of the flag or positional.
plural: bool, whether the expected result should be a list. Should be
False for everything except the "anchor" arguments in a case where a
resource argument is plural (i.e. parses to a list).
"""
super(ArgFallthrough, self).__init__(
'provide the argument `{}` on the command line'.format(arg_name),
active=True,
plural=plural)
self.arg_name = arg_name
def _Call(self, parsed_args):
arg_value = getattr(parsed_args, util.NamespaceFormat(self.arg_name), None)
return arg_value
def _Pluralize(self, value):
if not self.plural:
# Positional arguments will always be stored in argparse as lists, even if
# nargs=1. If not supposed to be plural, transform into a single value.
if isinstance(value, list):
return value[0] if value else None
return value
if value and not isinstance(value, list):
return [value]
return value if value else []
def __eq__(self, other):
if not isinstance(other, self.__class__):
return False
return other.arg_name == self.arg_name
def __hash__(self):
return hash(self.arg_name)
class FullySpecifiedAnchorFallthrough(_FallthroughBase):
"""A fallthrough that gets a parameter from the value of the anchor."""
def __init__(self,
fallthroughs,
collection_info,
parameter_name,
plural=False):
"""Initializes a fallthrough getting a parameter from the anchor.
For anchor arguments which can be plural, returns the list.
Args:
fallthroughs: list[_FallthroughBase], any fallthrough for an anchor arg.
collection_info: the info of the collection to parse the anchor as.
parameter_name: str, the name of the parameter
plural: bool, whether the expected result should be a list. Should be
False for everything except the "anchor" arguments in a case where a
"""
if plural:
hint_suffix = 'with fully specified names'
else:
hint_suffix = 'with a fully specified name'
hint = [f'{f.hint} {hint_suffix}' for f in fallthroughs]
active = all(f.active for f in fallthroughs)
super(FullySpecifiedAnchorFallthrough, self).__init__(
hint, active=active, plural=plural)
self.parameter_name = parameter_name
self.collection_info = collection_info
self._fallthroughs = tuple(fallthroughs) # Make list immutable
self._resources = resources.REGISTRY.Clone()
self._resources.RegisterApiByName(self.collection_info.api_name,
self.collection_info.api_version)
def _GetFromAnchor(self, anchor_value):
"""Returns the parameter value from the parsed anchor resource."""
try:
resource_ref = self._resources.Parse(
anchor_value, collection=self.collection_info.full_name,
api_version=self.collection_info.api_version)
except resources.Error:
return None
# This should only be called for final parsing when the anchor attribute
# has been split up into non-plural fallthroughs; thus, if an AttributeError
# results from the parser being passed a list, skip it for now.
except AttributeError:
return None
return getattr(resource_ref, self.parameter_name, None)
def _Call(self, parsed_args):
try:
anchor_value = GetFromFallthroughs(
self._fallthroughs, parsed_args, attribute_name=self.parameter_name)
except AttributeNotFoundError:
return None
return self._GetFromAnchor(anchor_value)
def __eq__(self, other):
return (isinstance(other, self.__class__) and
other._fallthroughs == self._fallthroughs and
other.collection_info == self.collection_info and
other.parameter_name == self.parameter_name)
def __hash__(self):
return sum(
map(hash, [
self._fallthroughs,
str(self.collection_info),
self.parameter_name
]))
def Get(attribute_name, attribute_to_fallthroughs_map, parsed_args=None):
"""Gets the value of an attribute based on fallthrough information.
If the attribute value is not provided by any of the fallthroughs, an
error is raised with a list of ways to provide information about the
attribute.
Args:
attribute_name: str, the name of the attribute.
attribute_to_fallthroughs_map: {str: [_FallthroughBase], a map of attribute
names to lists of fallthroughs.
parsed_args: a parsed argparse namespace.
Returns:
the value of the attribute.
Raises:
AttributeNotFoundError: if no value can be found.
"""
fallthroughs = attribute_to_fallthroughs_map.get(attribute_name, [])
return GetFromFallthroughs(
fallthroughs, parsed_args, attribute_name=attribute_name)
def GetFromFallthroughs(fallthroughs, parsed_args, attribute_name=None):
"""Gets the value of an attribute based on fallthrough information.
If the attribute value is not provided by any of the fallthroughs, an
error is raised with a list of ways to provide information about the
attribute.
Args:
fallthroughs: [_FallthroughBase], list of fallthroughs.
parsed_args: a parsed argparse namespace.
attribute_name: str, the name of the attribute. Used for error message,
omitted if not provided.
Returns:
the value of the attribute.
Raises:
AttributeNotFoundError: if no value can be found.
"""
for fallthrough in fallthroughs:
try:
return fallthrough.GetValue(parsed_args)
except FallthroughNotFoundError:
continue
hints = GetHints(fallthroughs)
fallthroughs_summary = '\n'.join(
['- {}'.format(hint) for hint in hints])
raise AttributeNotFoundError(
'Failed to find attribute{}. The attribute can be set in the '
'following ways: \n{}'.format(
'' if attribute_name is None else ' [{}]'.format(attribute_name),
fallthroughs_summary))
def GetHints(fallthroughs):
"""Gathers deduped hints from list of fallthroughs."""
# Create list of non-repeating hints. Dictionary preserves order.
# This is needed when more than one fallthrough has the same hint.
# Usually occurs for FullySpecifiedFallthroughs with different
# resource collections.
hints_set = {}
for f in fallthroughs:
new_hints = f.hint if isinstance(f.hint, list) else [f.hint]
for hint in new_hints:
if hint in hints_set:
continue
hints_set[hint] = True
return list(hints_set.keys())

View File

@@ -0,0 +1,265 @@
# -*- coding: utf-8 -*- #
# Copyright 2023 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.
"""Utilities for generating and updating fallthrough maps."""
import copy
from googlecloudsdk.calliope.concepts import deps as deps_lib
def AddFlagFallthroughs(
base_fallthroughs_map, attributes, attribute_to_args_map):
"""Adds flag fallthroughs to fallthrough map.
Iterates through each attribute and prepends a flag fallthrough.
This allows resource attributes to be resolved to flag first. For example:
{'book': [deps.ValueFallthrough('foo')]}
will update to something like...
{
'book': [
deps.ArgFallthrough('--foo'),
deps.ValueFallthrough('foo')
]
}
Args:
base_fallthroughs_map: {str: [deps._FallthroughBase]}, A map of attribute
names to fallthroughs
attributes: list[concepts.Attribute], list of attributes associated
with the resource
attribute_to_args_map: {str: str}, A map of attribute names to the names
of their associated flags.
"""
for attribute in attributes:
current_fallthroughs = base_fallthroughs_map.get(attribute.name, [])
if arg_name := attribute_to_args_map.get(attribute.name):
arg_fallthrough = deps_lib.ArgFallthrough(arg_name)
else:
arg_fallthrough = None
if arg_fallthrough:
filtered_fallthroughs = [
f for f in current_fallthroughs if f != arg_fallthrough]
fallthroughs = [arg_fallthrough] + filtered_fallthroughs
else:
fallthroughs = current_fallthroughs
base_fallthroughs_map[attribute.name] = fallthroughs
def AddAnchorFallthroughs(
base_fallthroughs_map, attributes, anchor, collection_info,
anchor_fallthroughs):
"""Adds fully specified fallthroughs to fallthrough map.
Iterates through each attribute and prepends a fully specified fallthrough.
This allows resource attributes to resolve to the fully specified anchor
value first. For example:
{'book': [deps.ValueFallthrough('foo')]}
will udpate to something like...
{
'book': [
deps.FullySpecifiedAnchorFallthrough(anchor_fallthroughs),
deps.ValueFallthrough('foo')
]
}
Args:
base_fallthroughs_map: {str: [deps._FallthroughBase]}, A map of attribute
names to fallthroughs
attributes: list[concepts.Attribute], list of attributes associated
with the resource
anchor: concepts.Attribute, attribute that the other attributes should
resolve to if fully specified
collection_info: the info of the collection to parse the anchor as
anchor_fallthroughs: list[deps._FallthroughBase], fallthroughs used to
resolve the anchor value
"""
for attribute in attributes:
current_fallthroughs = base_fallthroughs_map.get(attribute.name, [])
anchor_based_fallthrough = deps_lib.FullySpecifiedAnchorFallthrough(
anchor_fallthroughs, collection_info, attribute.param_name)
if attribute != anchor:
filtered_fallthroughs = [
f for f in current_fallthroughs if f != anchor_based_fallthrough]
fallthroughs = [anchor_based_fallthrough] + filtered_fallthroughs
else:
fallthroughs = current_fallthroughs
base_fallthroughs_map[attribute.name] = fallthroughs
def UpdateWithValueFallthrough(
base_fallthroughs_map, attribute_name, parsed_args):
"""Shortens fallthrough list to a single deps.ValueFallthrough.
Used to replace the attribute_name entry in a fallthrough map to a
single ValueFallthrough. For example:
{'book': [deps.Fallthrough(lambda: 'foo')]}
will update to something like...
{'book': [deps.ValueFallthrough('foo')]}
Args:
base_fallthroughs_map: {str: [deps._FallthroughBase]}, A map of attribute
names to fallthroughs we are updating
attribute_name: str, entry in fallthrough map we are updating
parsed_args: Namespace | None, used to derive the value for ValueFallthrough
"""
if not parsed_args:
return
attribute_value, attribute_fallthrough = _GetFallthroughAndValue(
attribute_name, base_fallthroughs_map, parsed_args)
if attribute_fallthrough:
_UpdateMapWithValueFallthrough(
base_fallthroughs_map, attribute_value, attribute_name,
attribute_fallthrough)
def CreateValueFallthroughMapList(
base_fallthroughs_map, attribute_name, parsed_args):
"""Generates a list of fallthrough maps for each anchor value in a list.
For each anchor value, generate a fallthrough map. For example, if user
provides anchor values ['foo', 'bar'] and a base fallthrough like...
{'book': [deps.ArgFallthrough('--book')]}
will generate something like...
[
{'book': [deps.ValueFallthrough('foo')]},
{'book': [deps.ValueFallthrough('bar')]}
]
Args:
base_fallthroughs_map: {str: [deps._FallthroughBase]}, A map of attribute
names to fallthroughs we are updating
attribute_name: str, entry in fallthrough map we are updating
parsed_args: Namespace | None, used to derive the value for ValueFallthrough
Returns:
list[{str: deps._FallthroughBase}], a list of fallthrough maps for
each parsed anchor value
"""
attribute_values, attribute_fallthrough = _GetFallthroughAndValue(
attribute_name, base_fallthroughs_map, parsed_args)
map_list = []
if not attribute_fallthrough:
return map_list
for value in attribute_values:
new_map = {**base_fallthroughs_map}
_UpdateMapWithValueFallthrough(
new_map, value, attribute_name, attribute_fallthrough)
map_list.append(new_map)
return map_list
def PluralizeFallthroughs(base_fallthroughs_map, attribute_name):
"""Updates fallthrough map entry to make fallthroughs plural.
For example:
{'book': [deps.ArgFallthrough('--foo')]}
will update to something like...
{'book': [deps.ArgFallthrough('--foo'), plural=True]}
Args:
base_fallthroughs_map: {str: [deps.Fallthrough]}, A map of attribute
names to fallthroughs we are updating
attribute_name: str, entry in fallthrough map we are updating
"""
given_fallthroughs = base_fallthroughs_map.get(attribute_name, [])
base_fallthroughs_map[attribute_name] = [
_PluralizeFallthrough(fallthrough)
for fallthrough in given_fallthroughs
]
def _PluralizeFallthrough(fallthrough):
plural_fallthrough = copy.deepcopy(fallthrough)
plural_fallthrough.plural = True
return plural_fallthrough
def _UpdateMapWithValueFallthrough(
base_fallthroughs_map, value, attribute_name, attribute_fallthrough):
value_fallthrough = deps_lib.ValueFallthrough(
value,
attribute_fallthrough.hint,
active=attribute_fallthrough.active)
base_fallthroughs_map[attribute_name] = [value_fallthrough]
def _GetFallthroughAndValue(attribute_name, fallthroughs_map, parsed_args):
"""Derives value and fallthrough used to derives value from map."""
for possible_fallthrough in fallthroughs_map.get(attribute_name, []):
try:
value = possible_fallthrough.GetValue(parsed_args)
return (value, possible_fallthrough)
except deps_lib.FallthroughNotFoundError:
continue
else:
return (None, None)
def ValidateFallthroughMap(fallthroughs_map):
"""Validates fallthrough map to ensure fallthrough map is not invalid.
Fallthrough maps are only invalid if an inactive fallthrough comes before
an active fallthrough. It could result in an active fallthrough that can
never be reached.
Args:
fallthroughs_map: {str: [deps._FallthroughBase]}, A map of attribute
names to fallthroughs we are validating
Returns:
(bool, str), bool for whether fallthrough map is valid and str for
the error message
"""
for attr, fallthroughs in fallthroughs_map.items():
inactive_fallthrough = None
for fallthrough in fallthroughs:
if inactive_fallthrough and fallthrough.active:
active_str = fallthrough.__class__.__name__
inactive_str = inactive_fallthrough.__class__.__name__
msg = (f'Invalid Fallthrough Map: Fallthrough map at [{attr}] contains '
f'inactive fallthrough [{inactive_str}] before active '
f'fallthrough [{active_str}]. Fix the order so that active '
f'fallthrough [{active_str}] is reachable or remove active '
f'fallthrough [{active_str}].')
return False, msg
if not fallthrough.active:
inactive_fallthrough = fallthrough
else:
return True, None

View File

@@ -0,0 +1,132 @@
# -*- coding: utf-8 -*- #
# Copyright 2017 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.
"""Classes for runtime handling of concept arguments."""
from __future__ import absolute_import
from __future__ import division
from __future__ import unicode_literals
from googlecloudsdk.calliope import parser_errors
from googlecloudsdk.calliope.concepts import util
from googlecloudsdk.core import exceptions
import six
class Error(exceptions.Error):
"""Base class for errors in this module."""
class ParseError(Error):
"""Raised if a concept fails to parse."""
def __init__(self, presentation_name, message):
msg = 'Error parsing [{}].\n{}'.format(presentation_name, message)
super(ParseError, self).__init__(msg)
class RepeatedConceptName(Error):
"""Raised when adding a concept if one with the given name already exists."""
def __init__(self, concept_name):
msg = 'Repeated concept name [{}].'.format(concept_name)
super(RepeatedConceptName, self).__init__(msg)
class RuntimeHandler(object):
"""A handler to hold information about all concept arguments in a command.
The handler is assigned to 'CONCEPTS' in the argparse namespace and has an
attribute to match the name of each concept argument in lower snake case.
"""
def __init__(self):
# This is set by the ArgumentInterceptor later.
self.parsed_args = None
self._arg_name_lookup = {}
self._all_concepts = []
def ParsedArgs(self):
"""Basically a lazy property to use during lazy concept parsing."""
return self.parsed_args
def AddConcept(self, name, concept_info, required=True):
"""Adds a concept handler for a given concept.
Args:
name: str, the name to be used for the presentation spec.
concept_info: ConceptInfo, the object that holds dependencies of the
concept.
required: bool, True if the concept must be parseable, False if not.
Raises:
RepeatedConceptName: If the given "name" has already been used with a
concept.
"""
# pylint: disable=g-import-not-at-top
from googlecloudsdk.calliope.concepts import concepts
# pylint: enable=g-import-not-at-top
class LazyParse(object):
"""Class provided when accessing a concept to lazily parse from args."""
def __init__(self, parse, arg_getter):
self.parse = parse
self.arg_getter = arg_getter
def Parse(self):
try:
return self.parse(self.arg_getter())
except concepts.InitializationError as e:
if required:
raise ParseError(name, six.text_type(e))
return None
if hasattr(self, name):
raise RepeatedConceptName(name)
setattr(self, name, LazyParse(concept_info.Parse, self.ParsedArgs))
self._all_concepts.append({
'name': name,
'concept_info': concept_info,
'required': required,
})
for _, arg_name in six.iteritems(concept_info.attribute_to_args_map):
self._arg_name_lookup[util.NormalizeFormat(arg_name)] = concept_info
def ArgNameToConceptInfo(self, arg_name):
return self._arg_name_lookup.get(util.NormalizeFormat(arg_name))
def Reset(self):
for concept_details in self._all_concepts:
concept_details['concept_info'].ClearCache()
def GetValue(self, dest):
"""Returns the value of the argument registered for dest.
Based on argparse.Namespace.GetValue().
Args:
dest: The dest of a registered argument.
Raises:
UnknownDestinationException: If no arg is registered for dest.
Returns:
The value of the argument registered for dest.
"""
try:
return getattr(self, dest)
except AttributeError:
raise parser_errors.UnknownDestinationException(
'No registered concept arg for destination [{}].'.format(dest))

View File

@@ -0,0 +1,609 @@
# -*- coding: utf-8 -*- #
# Copyright 2017 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.
"""Classes to define multitype concept specs."""
from __future__ import absolute_import
from __future__ import division
from __future__ import unicode_literals
import enum
from googlecloudsdk.calliope.concepts import concepts
from googlecloudsdk.calliope.concepts import deps as deps_lib
from googlecloudsdk.calliope.concepts import deps_map_util
from googlecloudsdk.core import exceptions
from googlecloudsdk.core import log
from googlecloudsdk.core.console import console_io
class Error(exceptions.Error):
"""Base class for errors in this module."""
class ConfigurationError(Error):
"""Raised if the spec is misconfigured."""
class ConflictingTypesError(Error):
"""Raised if there are multiple or no possible types for the spec."""
def __init__(
self, name, concept_specs, specified_attributes, fallthroughs_map):
attributes = _GetAttrStr(specified_attributes)
directions = _GetDirections(name, fallthroughs_map, concept_specs)
message = (f'Failed to determine type of [{name}] resource. '
f'You specified attributes [{attributes}].\n{directions}')
super(ConflictingTypesError, self).__init__(message)
class InitializationError(concepts.InitializationError):
"""Raised if a spec fails to initialize."""
def __init__(
self, name, concept_specs, specified_attributes, fallthroughs_map):
attributes = _GetAttrStr(specified_attributes)
directions = _GetDirections(name, fallthroughs_map, concept_specs)
super(InitializationError, self).__init__(
(f'[{name}] resource missing required data. '
f'You specified attributes [{attributes}].\n{directions}'))
class MultitypeResourceSpec(concepts.ConceptSpec):
"""A concept spec that can have multiple possible types.
Creating a multitype concept spec requires a name and a list of
concept specs. For example, to create a spec out of two other specs, a
project_foo_spec and an organization_foo_spec:
proj_org_foo_spec = MultitypeResourceSpec(
'projorgfoo', project_foo_spec, organization_foo_spec)
The command should parse the concept in the same way as always, obtaining a
TypedConceptResult:
result = args.CONCEPTS.proj_org_foo.Parse()
To check the type of the result and use it, the user might do:
if result.concept_type == type(result.concept_type).PROJFOO:
_HandleProjectResource(result.result)
else:
_HandleOrgResource(result.result)
Attributes:
name: str, the name of the concept
plural_name: str, the pluralized name. Will be pluralized by default rules
if not given in cases where the resource is referred to in the plural.
attributes: [concepts._Attribute], a list of attributes of the concept.
type_enum: enum.Enum, an Enum class representing the available types.
"""
def __init__(self, name, *concept_specs, **kwargs):
self._name = name
self._plural_name = kwargs.get('plural_name', None)
self._allow_inactive = kwargs.get('allow_inactive', False)
self._concept_specs = concept_specs
self._attributes = []
self._attribute_to_types_map = {}
self.disable_auto_completers = True
self._name_to_concepts = {}
final_names = []
for concept_spec in self._concept_specs:
name = self._GetUniqueNameForSpec(concept_spec, final_names)
final_names.append(name)
self._name_to_concepts[name] = concept_spec
self.type_enum = enum.Enum('Type', final_names)
attr_map = {}
for spec in self._concept_specs:
for i, attribute in enumerate(spec.attributes):
attr_name = attribute.name
if attr_name in attr_map and attribute != attr_map[attr_name][1]:
raise ConfigurationError(
'Multiple non-equivalent attributes found with name '
f'[{attribute.name}]')
attr_map[attr_name] = (i, attribute)
self._attribute_to_types_map.setdefault(attr_name, []).append(
(self.type_enum[self._ConceptToName(spec)]))
attr_list = sorted(list(attr_map.values()), key=lambda x: x[0])
self._attributes = [attr[1] for attr in attr_list]
@property
def name(self):
return self._name
@property
def attributes(self):
return self._attributes
def IsAnchor(self, attribute):
"""Returns True if attribute is an anchor in at least one concept."""
return any(attribute == spec.anchor for spec in self._concept_specs)
def IsLeafAnchor(self, attribute):
"""Returns True if attribute is an anchor in at least one concept.
Attribute can only be a leaf anchor if it is an anchor for at least
one concept AND not an attribute in any other resource.
Args:
attribute: concepts.Attribute, attribute we are checking
Returns:
bool, whether attribute is a leaf anchor
"""
if not self.IsAnchor(attribute):
return False
# Not a leaf if it's a non-anchor attribute in at least one spec.
if any(attribute in spec.attributes and attribute.name != spec.anchor.name
for spec in self._concept_specs):
return False
return True
def Pluralize(self, attribute, plural=False):
return plural and self.IsLeafAnchor(attribute)
def Initialize(self, full_fallthroughs_map, parsed_args=None):
"""Generates a parsed resource based on fallthroughs and user input.
Determines which attributes are actively specified (i.e. on the command
line) in order to determine which type of concept is being specified by the
user. The rules are:
1) If *exactly one* concept spec can be initialized using ALL explicilty
specified attributes, return it.
2) If *exactly one* concept spec can be initialized using ALL explicilty
specified attributes and some non-active attributes, return it.
3) If more than one concept spec can be initialized using ALL
explicitly specified attributes, prompt user or emit
ConflictingTypesError
4) If no concept specs can be initialized, emit IntitializationError
Args:
full_fallthroughs_map: {str: [deps_lib._FallthroughBase]}, a dict of
finalized fallthroughs for the resource.
parsed_args: the argparse namespace.
Returns:
A TypedConceptResult that stores the type of the parsed concept and the
raw parsed concept (such as a resource reference).
Raises:
InitializationError: if the concept's attributes are underspecified and
cannot be initialized from data.
ConflictingTypesError: if more than one possible type exists.
"""
# (1) Try to determine if one resource can be parsed from a fully
# specified uri. Extra attributes are ignored.
fully_specified_resources = []
for concept_type in self.type_enum:
anchor_name = self._name_to_concepts[concept_type.name].anchor.name
# Parse resources only using actively specified anchor value.
anchor_fallthroughs = full_fallthroughs_map.get(anchor_name, [])
anchor_fallthrough_map = {
anchor_name: [f for f in anchor_fallthroughs if f.active]
}
if (parsed_resource := self._GetParsedResource(
concept_type, anchor_fallthrough_map, parsed_args)):
fully_specified_resources.append(parsed_resource)
if len(fully_specified_resources) == 1:
return fully_specified_resources[0]
# (2) Try to determine if one resource can be parsed from actively
# specified attributes. No extra attributes can be actively specifed.
active_fallthroughs_map = {
attr: [f for f in fallthroughs if f.active]
for attr, fallthroughs in full_fallthroughs_map.items()
}
actively_specified = self._GetSpecifiedAttributes(
active_fallthroughs_map, parsed_args=parsed_args)
actively_specified_resources = self._FilterTypesByAttribute(
actively_specified,
self._GetParsedResources(active_fallthroughs_map, parsed_args))
if len(actively_specified_resources) == 1:
return actively_specified_resources[0]
# (3) Determine if any resource can be parsed from active and inactive
# fallthroughs. No extra attributes can be actively specified.
all_specified = self._GetSpecifiedAttributes(
full_fallthroughs_map, parsed_args=parsed_args)
parsed_resources = self._GetParsedResources(
full_fallthroughs_map, parsed_args)
if not parsed_resources:
raise InitializationError(
self.name, self._concept_specs, all_specified,
full_fallthroughs_map)
# Only filter out types that have too many actively specified attributes
specified_resources = self._FilterTypesByAttribute(
actively_specified, parsed_resources)
if len(specified_resources) == 1:
return specified_resources[0]
else:
return self._PromptOrErrorConflictingTypes(
all_specified, full_fallthroughs_map, parsed_resources)
def Parse(self, attribute_to_args_map, base_fallthroughs_map,
parsed_args=None, plural=False, allow_empty=False):
"""Lazy parsing function for resource.
Generates resource based off of the parsed_args (user provided
arguments) and specified fallthrough behavior.
Args:
attribute_to_args_map: {str: str}, A map of attribute names to the names
of their associated flags.
base_fallthroughs_map: {str: [deps_lib.Fallthrough]} A map of attribute
names to non-argument fallthroughs, including command-level
fallthroughs.
parsed_args: the parsed Namespace.
plural: bool, True if multiple resources can be parsed, False otherwise.
allow_empty: bool, True if resource parsing is allowed to return no
resource, otherwise False.
Returns:
A TypedConceptResult or a list of TypedConceptResult objects containing
the parsed resource or resources.
Raises:
ValueError: if fallthrough map contains invalid fallthrough order.
"""
if base_fallthroughs_map:
valid, msg = deps_map_util.ValidateFallthroughMap(base_fallthroughs_map)
if not valid:
raise ValueError(msg)
if not plural:
value = self._ParseFromValue(
attribute_to_args_map, base_fallthroughs_map,
parsed_args, allow_empty)
else:
value = self._ParseFromPluralValue(
attribute_to_args_map, base_fallthroughs_map, parsed_args,
allow_empty)
self._PrintParseStatus(value)
return value
def BuildFullFallthroughsMap(
self, attribute_to_args_map, base_fallthroughs_map, parsed_args=None):
"""Generate fallthrough map that is used to resolve resource params.
Used as source of truth for how each attribute is resolved. It is also used
to generate help text for both plural and singular resources.
Fallthroughs are a list of objects that, when called, try different ways of
resolving a resource attribute (see googlecloudsdk.calliope.concepts.
deps_lib._Fallthrough). This method builds a map from the name of each
attribute to its list of fallthroughs.
For each attribute, adds default flag fallthroughs and fully specified
anchor fallthroughs.
Args:
attribute_to_args_map: {str: str}, A map of attribute names to the names
of their associated flags.
base_fallthroughs_map: {str: [deps.Fallthrough]}, A map of attribute
names to non-argument fallthroughs, including command-level
fallthroughs.
parsed_args: Namespace | None, user's CLI input
Returns:
{str: [deps.Fallthrough]}, a map from attribute name to all its
fallthroughs.
"""
# For each concept, add a flag, value, or fully specified fallthrough.
# deps_map_util automatically removes duplicated fallthroughs from
# lower down the in the fallthrough list. For example, for attribute
# location, if location is in more than one concept spec, it
# will still only have one `--location` flag fallthrough in its
# fallthrough list.
fallthroughs_map = {**base_fallthroughs_map}
# Add flag and value fallthroughs first
for resource_spec in self._concept_specs:
deps_map_util.AddFlagFallthroughs(
fallthroughs_map, resource_spec.attributes, attribute_to_args_map)
deps_map_util.UpdateWithValueFallthrough(
fallthroughs_map, resource_spec.anchor.name, parsed_args)
# Add fully specified fallthroughs to non-anchor params
map_without_anchors = {**fallthroughs_map}
for resource_spec in self._concept_specs:
deps_map_util.AddAnchorFallthroughs(
fallthroughs_map, resource_spec.attributes, resource_spec.anchor,
resource_spec.collection_info,
map_without_anchors.get(resource_spec.anchor.name, []))
return fallthroughs_map
def _BuildFullFallthroughsMapList(
self, anchor, attribute_to_args_map, base_fallthroughs_map,
parsed_args=None):
"""Builds fallthrough map for each anchor value specified in a list.
For each anchor value parsed, create a falthrough map to derive the rest
of the resource params. For each attribute, adds flag fallthroughs
and fully specified anchor fallthroughs. For each attribute,
adds default flag fallthroughs and fully specified anchor fallthroughs.
Args:
anchor: attributes.Anchor, the anchor attribute we are parsing
attribute_to_args_map: {str: str}, A map of attribute names to the names
of their associated flags.
base_fallthroughs_map: FallthroughsMap, A map of attribute names to
non-argument fallthroughs, including command-level fallthroughs.
parsed_args: Namespace, used to parse the anchor value and derive
fully specified fallthroughs.
Returns:
list[FallthroughsMap], fallthrough map for each anchor value
"""
fallthroughs_map = {**base_fallthroughs_map}
# Do not include other leaf anchors not related to this anchor
attributes = [
attr for attr in self.attributes
if not self.IsLeafAnchor(attr) or attr.name == anchor.name]
deps_map_util.AddFlagFallthroughs(
fallthroughs_map, attributes, attribute_to_args_map)
deps_map_util.PluralizeFallthroughs(fallthroughs_map, anchor.name)
map_list = deps_map_util.CreateValueFallthroughMapList(
fallthroughs_map, anchor.name, parsed_args)
for full_map in map_list:
for spec in self._concept_specs:
if spec.anchor.name != anchor.name:
continue
deps_map_util.AddAnchorFallthroughs(
full_map, spec.attributes, spec.anchor, spec.collection_info,
full_map.get(spec.anchor.name, []))
return map_list
def _ParseFromValue(
self, attribute_to_args_map, base_fallthroughs_map,
parsed_args, allow_empty=False):
"""Parses a singular resource from user input."""
fallthroughs_map = self.BuildFullFallthroughsMap(
attribute_to_args_map, base_fallthroughs_map, parsed_args)
try:
return self.Initialize(fallthroughs_map, parsed_args=parsed_args)
except InitializationError:
if allow_empty:
return TypedConceptResult(None, None)
raise
def _ParseFromPluralValue(
self, attribute_to_args_map, base_fallthroughs_map,
parsed_args, allow_empty=False):
"""Parses a list of resources from user input."""
results = []
for attribute in self.attributes:
if self.IsLeafAnchor(attribute):
results += self._ParseFromPluralLeaf(
attribute_to_args_map, base_fallthroughs_map, attribute,
parsed_args=parsed_args)
if results:
return results
# If no resources were found from the "leaf" anchors, then we are looking
# for a single parent resource (whose anchor is a non-"leaf" anchor).
parent = self._ParseFromValue(
attribute_to_args_map, base_fallthroughs_map, parsed_args,
allow_empty=allow_empty)
if parent.result is not None:
return [parent]
else:
return []
def _ParseFromPluralLeaf(
self, attribute_to_args_map, base_fallthroughs_map, anchor, parsed_args):
"""Helper for parsing a list of results using a single anchor value."""
parsed_resources = []
map_list = self._BuildFullFallthroughsMapList(
anchor, attribute_to_args_map, base_fallthroughs_map, parsed_args)
for fallthroughs_map in map_list:
resource = self.Initialize(
fallthroughs_map, parsed_args=parsed_args)
if resource.result is not None:
parsed_resources.append(resource)
return parsed_resources
def _GetParsedResource(self, concept_type, fallthroughs_map, parsed_args):
"""Helper method to get the parsed resource using actively specified args."""
try:
concept_spec = self._name_to_concepts[concept_type.name]
parsed_resource = concept_spec.Initialize(
fallthroughs_map, parsed_args=parsed_args)
return TypedConceptResult(parsed_resource, concept_type)
except concepts.InitializationError:
return None
def _GetParsedResources(self, fallthroughs_map, parsed_args):
"""Helper method to get the parsed resources using actively specified args.
"""
types = []
for concept_type in self.type_enum:
if (parsed_resource := self._GetParsedResource(
concept_type, fallthroughs_map, parsed_args)):
types.append(parsed_resource)
return types
def _ConceptToName(self, concept_spec):
"""Helper to get the type enum name for a concept spec."""
for name, spec in self._name_to_concepts.items():
if spec == concept_spec:
return name
else:
return None
def _GetSpecifiedAttributes(self, fallthroughs_map, parsed_args=None):
"""Get a list of attributes that are actively specified in runtime."""
specified = []
for attribute in self.attributes:
try:
value = deps_lib.Get(
attribute.name, fallthroughs_map, parsed_args=parsed_args)
except deps_lib.AttributeNotFoundError:
continue
if value is not None:
specified.append(attribute)
return specified
def _FilterTypesByAttribute(self, attribute_info, concept_result):
"""Fitlers out types that do not contain actively specified attribute."""
possible_types = []
for candidate in concept_result:
for attribute in attribute_info:
if candidate.concept_type not in self._attribute_to_types_map.get(
attribute.name, []):
break
else:
possible_types.append(candidate)
return possible_types
def _GetUniqueNameForSpec(self, resource_spec, final_names):
"""Overrides this functionality from generic multitype concept specs."""
del final_names
# If all resources have different names, use their names.
resource_names = [spec.name for spec in self._concept_specs]
if len(set(resource_names)) == len(resource_names):
return resource_spec.name
# Otherwise, use the collection name.
other_collection_names = [
spec.collection for spec in self._concept_specs]
other_collection_names.pop(self._concept_specs.index(resource_spec))
if any(resource_spec.collection == n for n in other_collection_names):
raise ValueError('Attempting to create a multitype spec with duplicate '
'collections. Collection name: [{}]'.format(
resource_spec.collection))
else:
return resource_spec.collection
def _PromptOrErrorConflictingTypes(
self, specified_attributes, full_fallthroughs_map, parsed_resources):
"""If one or more type is parsed, send prompt for user to confirm.
If user is unable to confirm resource type, raise ConflictingTypesError
Args:
specified_attributes: list[Attribute], list of explicitly specified
resource attributes
full_fallthroughs_map: {str: [deps_lib._FallthroughBase]}, a dict of
finalized fallthroughs for the resource.
parsed_resources: list[TypedConceptResult], list of parsed resources
Returns:
concepts.Resource, resource user elects to specify
Raises:
ConflictingTypesError: if user is not able to specify preferred resource.
"""
if not console_io.CanPrompt():
raise ConflictingTypesError(
self.name, self._concept_specs, specified_attributes,
full_fallthroughs_map)
guess_list = [guess.result.RelativeName() for guess in parsed_resources]
attr_str = _GetAttrStr(specified_attributes)
try:
selected_index = console_io.PromptChoice(
guess_list,
message=(f'Failed determine type of [{self.name}] resource. '
f'You specified attributes [{attr_str}].\n'
'Did you mean to specify one of the following resources?'),
prompt_string=('Please enter your numeric choice. Defaults to'),
cancel_option=True,
default=len(guess_list)) # default to cancel option
except console_io.OperationCancelledError:
raise ConflictingTypesError(
self.name, self._concept_specs, specified_attributes,
full_fallthroughs_map)
return parsed_resources[selected_index]
def _PrintParseStatus(self, parsed_resource):
"""Helper to print the status of the parsed resource.
Args:
parsed_resource: TypedConceptResult | list[TypedConceptResult],
parsed resource or list of parsed resources
"""
if parsed_resource is None:
return
if isinstance(parsed_resource, list):
resources = ', '.join((
resource.result and resource.result.RelativeName()
for resource in parsed_resource))
log.status.Print(f'Parsed [{self.name}] resources: [{resources}]')
else:
resource = (
parsed_resource.result and parsed_resource.result.RelativeName())
log.status.Print(f'Parsed [{self.name}] resource: {resource}')
class TypedConceptResult(object):
"""A small wrapper to hold the results of parsing a multityped concept."""
def __init__(self, result, concept_type):
"""Initializes.
Args:
result: the parsed concept, such as a resource reference.
concept_type: the enum value of the type of the result.
"""
self.result = result
self.concept_type = concept_type
def _GetAttrStr(attributes):
"""Helper to format a list of attributes into a string."""
return ', '.join([attr.name for attr in attributes])
def _GetDirections(name, full_fallthroughs_map, concept_specs):
"""Aggregates directions on how to specify each type of resource."""
directions = []
for spec in concept_specs:
attribute_directions = _GetAttributeDirections(
spec.attributes, full_fallthroughs_map)
directions.append(
f'\nTo specify [{name}] as type {spec.collection}, specify only '
f'the following attributes.')
directions.append(attribute_directions)
return '\n\n'.join(directions)
def _GetAttributeDirections(attributes, full_fallthroughs_map):
"""Aggregates directions on how to set resource attribute."""
directions = []
for i, attribute in enumerate(attributes):
fallthroughs = full_fallthroughs_map.get(attribute.name, [])
tab = ' ' * 4
to_specify = (f'{i + 1}. To provide [{attribute.name}] attribute, do one '
'of the following:')
hints = (f'\n{tab}- {hint}' for hint in deps_lib.GetHints(fallthroughs))
directions.append(to_specify + ''.join(hints))
return '\n\n'.join(directions)

View File

@@ -0,0 +1,69 @@
# -*- coding: utf-8 -*- #
# Copyright 2017 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.
"""Utilities for resource args."""
from __future__ import absolute_import
from __future__ import division
from __future__ import unicode_literals
PREFIX = '--'
def StripPrefix(arg_name):
if arg_name.startswith(PREFIX):
return arg_name[len(PREFIX):]
return arg_name
def KebabCase(arg_name):
return arg_name.replace('_', '-')
def SnakeCase(arg_name):
return arg_name.replace('-', '_')
def IsPositional(arg_name):
"""Confirms if an arg name is for a positional or a flag."""
return not arg_name.startswith(PREFIX)
def NormalizeFormat(arg_name):
"""Converts arg name to lower snake case, no '--' prefix."""
return SnakeCase(StripPrefix(arg_name)).lower()
def NamespaceFormat(arg_name):
if IsPositional(arg_name):
return arg_name
return NormalizeFormat(arg_name)
def FlagNameFormat(arg_name):
"""Format a string as a flag name."""
return PREFIX + KebabCase(StripPrefix(arg_name)).lower()
def MetavarFormat(arg_name):
"""Gets arg name in upper snake case."""
return SnakeCase(arg_name.lstrip('-')).upper()
def PositionalFormat(arg_name):
"""Format a string as a positional."""
return SnakeCase(StripPrefix(arg_name)).upper()