522 lines
18 KiB
Python
522 lines
18 KiB
Python
# -*- coding: utf-8 -*- #
|
|
# Copyright 2015 Google LLC. All Rights Reserved.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
"""Resource display for all calliope commands.
|
|
|
|
The print_format string passed to resource_printer.Print() is determined in this
|
|
order:
|
|
(1) Display disabled and resources not consumed if user output is disabled.
|
|
(2) The explicit --format flag format string.
|
|
(3) The explicit Display() method.
|
|
(4) The DisplayInfo format from the parser.
|
|
(5) Otherwise no output but the resources are consumed.
|
|
|
|
Format expressions are left-to-right composable. Each format expression is a
|
|
string tuple
|
|
|
|
< NAME [ATTRIBUTE...] (PROJECTION...) >
|
|
|
|
where only one of the three elements need be present.
|
|
"""
|
|
|
|
from __future__ import absolute_import
|
|
from __future__ import division
|
|
from __future__ import unicode_literals
|
|
|
|
from googlecloudsdk.calliope import display_taps
|
|
from googlecloudsdk.core import exceptions
|
|
from googlecloudsdk.core import log
|
|
from googlecloudsdk.core import module_util
|
|
from googlecloudsdk.core import properties
|
|
from googlecloudsdk.core.cache import cache_update_ops
|
|
from googlecloudsdk.core.resource import resource_lex
|
|
from googlecloudsdk.core.resource import resource_printer
|
|
from googlecloudsdk.core.resource import resource_projection_spec
|
|
from googlecloudsdk.core.resource import resource_property
|
|
from googlecloudsdk.core.resource import resource_reference
|
|
from googlecloudsdk.core.resource import resource_transform
|
|
from googlecloudsdk.core.util import peek_iterable
|
|
import six
|
|
|
|
|
|
class Error(exceptions.Error):
|
|
"""Base exception for this module."""
|
|
|
|
|
|
class CommandNeedsAddCacheUpdater(Error):
|
|
"""Command needs an AddCacheUpdater() call."""
|
|
|
|
|
|
class CommandShouldntHaveAddCacheUpdater(Error):
|
|
"""Command has an AddCacheUpdater() call and shouldn't."""
|
|
|
|
|
|
class Displayer(object):
|
|
"""Implements the resource display method.
|
|
|
|
Dispatches the global flags args by constructing a format string and letting
|
|
resource_printer.Print() do the heavy lifting.
|
|
|
|
Attributes:
|
|
_args: The argparse.Namespace given to command.Run().
|
|
_command: The Command object that generated the resources to display.
|
|
_defaults: The resource format and filter default projection.
|
|
_format: The printer format string.
|
|
_info: The resource info or None if not registered.
|
|
_printer: The printer object.
|
|
_printer_is_initialized: True if self._printer has been initialized.
|
|
_resources: The resources to display, returned by command.Run().
|
|
_transform_uri: A transform function that returns the URI for a resource.
|
|
"""
|
|
|
|
# A command with these flags might return incomplete resource lists.
|
|
_CORRUPT_FLAGS = ('async', 'filter', 'limit')
|
|
|
|
def __init__(self, command, args, resources=None, display_info=None):
|
|
"""Constructor.
|
|
|
|
Args:
|
|
command: The Command object.
|
|
args: The argparse.Namespace given to the command.Run().
|
|
resources: The resources to display, returned by command.Run(). May be
|
|
omitted if only GetFormat() will be called.
|
|
display_info: The DisplayInfo object reaped from parser.AddDisplayInfo()
|
|
in the command path.
|
|
"""
|
|
self._args = args
|
|
self._cache_updater = None
|
|
self._command = command
|
|
self._defaults = None
|
|
self._default_format_used = False
|
|
self._format = None
|
|
self._filter = None
|
|
self._info = None
|
|
self._printer = None
|
|
self._printer_is_initialized = False
|
|
self._resources = resources
|
|
if not display_info:
|
|
# pylint: disable=protected-access
|
|
display_info = args.GetDisplayInfo()
|
|
if display_info:
|
|
self._cache_updater = display_info.cache_updater
|
|
self._defaults = resource_projection_spec.ProjectionSpec(
|
|
defaults=self._defaults,
|
|
symbols=display_info.transforms,
|
|
aliases=display_info.aliases,
|
|
)
|
|
self._format = display_info.format
|
|
self._flatten = display_info.flatten
|
|
self._filter = display_info.filter
|
|
self._transform_uri = self._defaults.symbols.get(
|
|
'uri', resource_transform.TransformUri
|
|
)
|
|
self._defaults.symbols[
|
|
resource_transform.GetTypeDataName('conditionals')
|
|
] = args
|
|
|
|
def _GetFlag(self, flag_name):
|
|
"""Returns the value of flag_name in args, None if it is unknown or unset.
|
|
|
|
Args:
|
|
flag_name: The flag name string sans leading '--'.
|
|
|
|
Returns:
|
|
The flag value or None if it is unknown or unset.
|
|
"""
|
|
if flag_name == 'async':
|
|
# The async flag has a special destination since 'async' is a reserved
|
|
# keyword as of Python 3.7.
|
|
return getattr(self._args, 'async_', None)
|
|
return getattr(self._args, flag_name, None)
|
|
|
|
def _AddUriCacheTap(self):
|
|
"""Taps a resource Uri cache updater into self.resources if needed."""
|
|
|
|
from googlecloudsdk.calliope import base # pylint: disable=g-import-not-at-top, circular dependency
|
|
|
|
if self._cache_updater == cache_update_ops.NoCacheUpdater:
|
|
return
|
|
|
|
if not self._cache_updater:
|
|
# pylint: disable=protected-access
|
|
if not isinstance(
|
|
self._command,
|
|
(
|
|
base.CreateCommand,
|
|
base.DeleteCommand,
|
|
base.ListCommand,
|
|
base.RestoreCommand,
|
|
),
|
|
):
|
|
return
|
|
if 'AddCacheUpdater' in properties.VALUES.core.lint.Get():
|
|
# pylint: disable=protected-access
|
|
raise CommandNeedsAddCacheUpdater(
|
|
'`{}` needs a parser.display_info.AddCacheUpdater() call.'.format(
|
|
' '.join(self._args._GetCommand().GetPath())
|
|
)
|
|
)
|
|
return
|
|
|
|
if any([self._GetFlag(flag) for flag in self._CORRUPT_FLAGS]):
|
|
return
|
|
|
|
# pylint: disable=protected-access
|
|
if isinstance(self._command, (base.CreateCommand, base.RestoreCommand)):
|
|
cache_update_op = cache_update_ops.AddToCacheOp(self._cache_updater)
|
|
elif isinstance(self._command, base.DeleteCommand):
|
|
cache_update_op = cache_update_ops.DeleteFromCacheOp(self._cache_updater)
|
|
elif isinstance(self._command, base.ListCommand):
|
|
cache_update_op = cache_update_ops.ReplaceCacheOp(self._cache_updater)
|
|
else:
|
|
raise CommandShouldntHaveAddCacheUpdater(
|
|
'Cache updater [{}] not expected for [{}] `{}`.'.format(
|
|
module_util.GetModulePath(self._cache_updater),
|
|
module_util.GetModulePath(self._args._GetCommand()),
|
|
' '.join(self._args._GetCommand().GetPath()),
|
|
)
|
|
)
|
|
|
|
tap = display_taps.UriCacher(cache_update_op, self._transform_uri)
|
|
self._resources = peek_iterable.Tapper(self._resources, tap)
|
|
|
|
def _GetSortKeys(self):
|
|
"""Returns the list of --sort-by [(key, reverse)] tuples.
|
|
|
|
Returns:
|
|
The list of --sort-by [(key, reverse)] tuples, None if --sort-by was not
|
|
specified. The keys are ordered from highest to lowest precedence.
|
|
"""
|
|
if not self._GetFlag('sort_by'):
|
|
return None
|
|
keys = []
|
|
for name in self._args.sort_by:
|
|
# ~name reverses the sort for name.
|
|
if name.startswith('~'):
|
|
name = name.lstrip('~')
|
|
reverse = True
|
|
else:
|
|
reverse = False
|
|
# Slices default to the first list element for consistency.
|
|
name = name.replace('[]', '[0]')
|
|
keys.append((resource_lex.Lexer(name).Key(), reverse))
|
|
return keys
|
|
|
|
def _SortResources(self, keys, reverse):
|
|
"""_AddSortByTap helper that sorts the resources by keys.
|
|
|
|
Args:
|
|
keys: The ordered list of parsed resource keys from highest to lowest
|
|
precedence.
|
|
reverse: Sort by the keys in descending order if True, otherwise
|
|
ascending.
|
|
"""
|
|
|
|
def _GetKey(r, key):
|
|
"""Returns the value for key in r that can be compared with None."""
|
|
value = resource_property.Get(r, key, None)
|
|
# Some types (datetime for example) preclude comparisons with None.
|
|
# This converts the value to a string and uses that ordering.
|
|
try:
|
|
assert None < value
|
|
return value
|
|
except (AssertionError, TypeError):
|
|
return six.text_type(value)
|
|
|
|
self._resources = sorted(
|
|
self._resources,
|
|
key=lambda r: [_GetKey(r, k) for k in keys],
|
|
reverse=reverse,
|
|
)
|
|
|
|
def _AddSortByTap(self):
|
|
"""Sorts the resources using the --sort-by keys."""
|
|
if not resource_property.IsListLike(self._resources):
|
|
return
|
|
sort_keys = self._GetSortKeys()
|
|
if not sort_keys:
|
|
return
|
|
self._args.sort_by = None
|
|
# This loop partitions the keys into groups having the same reverse value.
|
|
# The groups are then applied in reverse order to maintain the original
|
|
# precedence.
|
|
#
|
|
# This is not a pure tap since, by necessity, it consumes self._resources
|
|
# to sort and converts it to a list.
|
|
groups = [] # [(group_keys, group_reverse)] LIFO to preserve precedence
|
|
group_keys = [] # keys for current group
|
|
group_reverse = False
|
|
for key, reverse in sort_keys:
|
|
if not group_keys:
|
|
group_reverse = reverse
|
|
elif group_reverse != reverse:
|
|
groups.insert(0, (group_keys, group_reverse))
|
|
group_keys = []
|
|
group_reverse = reverse
|
|
group_keys.append(key)
|
|
if group_keys:
|
|
groups.insert(0, (group_keys, group_reverse))
|
|
|
|
# Finally sort the resources by groups having the same reverse value.
|
|
for keys, reverse in groups:
|
|
self._SortResources(keys, reverse)
|
|
|
|
def _AddFilterTap(self):
|
|
"""Taps a resource filter into self.resources if needed."""
|
|
expression = self._GetFilter()
|
|
if not expression:
|
|
return
|
|
tap = display_taps.Filterer(expression, self._defaults)
|
|
self._resources = peek_iterable.Tapper(self._resources, tap)
|
|
|
|
def _AddFlattenTap(self):
|
|
"""Taps one or more resource flatteners into self.resources if needed."""
|
|
|
|
def _Slice(key):
|
|
"""Helper to add one flattened slice tap."""
|
|
tap = display_taps.Flattener(key)
|
|
# Apply the flatteners from left to right so the innermost flattener
|
|
# flattens the leftmost slice. The outer flatteners can then access
|
|
# the flattened keys to the left.
|
|
self._resources = peek_iterable.Tapper(self._resources, tap)
|
|
|
|
keys = self._GetFlatten()
|
|
if not keys:
|
|
return
|
|
for key in keys:
|
|
flattened_key = []
|
|
sliced = False
|
|
for k in resource_lex.Lexer(key).Key():
|
|
if k is None:
|
|
sliced = True
|
|
_Slice(flattened_key)
|
|
else:
|
|
sliced = False
|
|
flattened_key.append(k)
|
|
if not sliced:
|
|
_Slice(flattened_key)
|
|
|
|
def _AddLimitTap(self):
|
|
"""Taps a resource limit into self.resources if needed."""
|
|
limit = self._GetFlag('limit')
|
|
if limit is None or limit < 0:
|
|
return
|
|
tap = display_taps.Limiter(limit)
|
|
self._resources = peek_iterable.Tapper(self._resources, tap)
|
|
|
|
def _AddPageTap(self):
|
|
"""Taps a resource pager into self.resources if needed."""
|
|
page_size = self._GetFlag('page_size')
|
|
if page_size is None or page_size <= 0:
|
|
return
|
|
tap = display_taps.Pager(page_size)
|
|
self._resources = peek_iterable.Tapper(self._resources, tap)
|
|
|
|
def _AddUriReplaceTap(self):
|
|
"""Taps a resource Uri replacer into self.resources if needed."""
|
|
|
|
# Explicit bool comparison to distinguish between the standard --uri flag
|
|
# vs. a potential custom --uri=URI flag that takes a value.
|
|
if self._GetFlag('uri') is not True: # pylint: disable=g-bool-id-comparison
|
|
return
|
|
|
|
tap = display_taps.UriReplacer(self._transform_uri)
|
|
self._resources = peek_iterable.Tapper(self._resources, tap)
|
|
|
|
def _GetResourceInfoDefaults(self):
|
|
"""Returns the default symbols for --filter and --format."""
|
|
if not self._info:
|
|
return self._defaults
|
|
symbols = self._info.GetTransforms()
|
|
if not symbols and not self._info.defaults:
|
|
return self._defaults
|
|
return resource_projection_spec.ProjectionSpec(
|
|
defaults=resource_projection_spec.CombineDefaults(
|
|
[self._info.defaults, self._defaults]
|
|
),
|
|
symbols=symbols,
|
|
)
|
|
|
|
def _GetExplicitFormat(self):
|
|
"""Determines the explicit format.
|
|
|
|
Returns:
|
|
format: The format string, '' if there is no explicit format, or None
|
|
"""
|
|
return self._args.format or ''
|
|
|
|
def _GetDefaultFormat(self):
|
|
"""Determines the default format.
|
|
|
|
Returns:
|
|
format: The format string, '' if there is an explicit Display().
|
|
"""
|
|
if hasattr(self._command, 'Display'):
|
|
return ''
|
|
return self._format
|
|
|
|
def _GetFilter(self):
|
|
flag_filter = self._GetFlag('filter')
|
|
if flag_filter is None:
|
|
if self._filter:
|
|
log.info('Display filter: "%s"', six.text_type(self._filter))
|
|
return self._filter
|
|
else:
|
|
return flag_filter
|
|
|
|
def _GetFlatten(self):
|
|
flag_flatten = self._GetFlag('flatten')
|
|
if flag_flatten is None:
|
|
return self._flatten
|
|
else:
|
|
return flag_flatten
|
|
|
|
def GetFormat(self):
|
|
"""Determines the display format.
|
|
|
|
Returns:
|
|
format: The display format string.
|
|
"""
|
|
default_fmt = self._GetDefaultFormat()
|
|
fmt = self._GetExplicitFormat()
|
|
|
|
if not fmt:
|
|
# No explicit format.
|
|
# Explicit bool comparison to distinguish between the standard --uri flag
|
|
# vs. a potential custom --uri=URI flag that takes a value.
|
|
if self._GetFlag('uri') is True: # pylint: disable=g-bool-id-comparison
|
|
return 'value(.)'
|
|
# Use the default format.
|
|
self._default_format_used = True
|
|
fmt = default_fmt
|
|
elif default_fmt:
|
|
# Compose the default and explicit formats.
|
|
#
|
|
# The rightmost format in fmt takes precedence. Appending gives higher
|
|
# precendence to the explicit format over the default format. Appending
|
|
# also makes projection attributes from preceding projections available
|
|
# to subsequent projections. For example, a user specified explicit
|
|
# --format expression can use the column heading names instead of resource
|
|
# key names:
|
|
#
|
|
# table(foo.bar:label=NICE, state:STATUS) table(NICE, STATUS)
|
|
#
|
|
# or a --filter='s:DONE' expression can use alias named defined by the
|
|
# defaults:
|
|
#
|
|
# table(foo.bar:alias=b, state:alias=s)
|
|
#
|
|
fmt = default_fmt + ' ' + fmt
|
|
|
|
if not fmt:
|
|
return fmt
|
|
sort_keys = self._GetSortKeys()
|
|
if not sort_keys:
|
|
return fmt
|
|
|
|
# :(...) adds key only attributes that don't affect the projection.
|
|
orders = []
|
|
for order, (key, reverse) in enumerate(sort_keys, start=1):
|
|
attr = ':reverse' if reverse else ''
|
|
orders.append(
|
|
'{name}:sort={order}{attr}'.format(
|
|
name=resource_lex.GetKeyName(key), order=order, attr=attr
|
|
)
|
|
)
|
|
fmt += ':({orders})'.format(orders=','.join(orders))
|
|
return fmt
|
|
|
|
def _InitPrinter(self):
|
|
"""Initializes the printer and associated attributes."""
|
|
|
|
if self._printer_is_initialized:
|
|
return
|
|
self._printer_is_initialized = True
|
|
|
|
# Determine the format.
|
|
self._format = self.GetFormat()
|
|
|
|
# Initialize the default symbols.
|
|
self._defaults = self._GetResourceInfoDefaults()
|
|
|
|
# Instantiate a printer if output is enabled.
|
|
if self._format:
|
|
self._printer = resource_printer.Printer(
|
|
self._format, defaults=self._defaults, out=log.out
|
|
)
|
|
if self._printer:
|
|
self._defaults = self._printer.column_attributes
|
|
|
|
def GetReferencedKeyNames(self):
|
|
"""Returns the set of key names referenced by the command."""
|
|
self._InitPrinter()
|
|
return resource_reference.GetReferencedKeyNames(
|
|
filter_string=self._GetFilter(),
|
|
printer=self._printer,
|
|
defaults=self._defaults,
|
|
)
|
|
|
|
def _AddDisplayTaps(self):
|
|
"""Adds each of the standard display taps, if needed.
|
|
|
|
The taps must be included in this order in order to generate the correct
|
|
results. For example, limiting should not happen until after filtering is
|
|
complete, and pagination should only happen on the fully trimmed results.
|
|
"""
|
|
self._AddUriCacheTap()
|
|
self._AddFlattenTap()
|
|
self._AddFilterTap()
|
|
self._AddSortByTap()
|
|
self._AddLimitTap()
|
|
self._AddPageTap()
|
|
self._AddUriReplaceTap()
|
|
|
|
def Display(self):
|
|
"""The default display method."""
|
|
|
|
if not log.IsUserOutputEnabled():
|
|
log.info('Display disabled.')
|
|
# NOTICE: Do not consume resources here. Some commands use this case to
|
|
# access the results of Run() via the return value of Execute(). However,
|
|
# to satisfy callers who are only interetsted in silent side effects,
|
|
# generators/iterators must be converted to a list here.
|
|
if resource_property.IsListLike(self._resources):
|
|
return list(self._resources)
|
|
return self._resources
|
|
|
|
# Initialize the printer.
|
|
self._InitPrinter()
|
|
|
|
self._AddDisplayTaps()
|
|
|
|
resources_were_displayed = True
|
|
if self._printer:
|
|
# Most command output will end up here.
|
|
log.info('Display format: "%s"', self._format)
|
|
self._printer.Print(self._resources)
|
|
resources_were_displayed = self._printer.ResourcesWerePrinted()
|
|
elif hasattr(self._command, 'Display'):
|
|
# This will eventually be rare.
|
|
log.info('Explicit Display.')
|
|
self._command.Display(self._args, self._resources)
|
|
|
|
# Resource display is done.
|
|
log.out.flush()
|
|
|
|
# If the default format was used then display the epilog.
|
|
if not self._args.IsSpecified('format'):
|
|
self._command.Epilog(resources_were_displayed)
|
|
|
|
return self._resources
|