File: //snap/google-cloud-cli/current/lib/surface/meta/lint.py
# -*- coding: utf-8 -*- #
# Copyright 2015 Google LLC. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#    http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""A command that validates gcloud flags according to Cloud SDK CLI Style."""
from __future__ import absolute_import
from __future__ import division
from __future__ import unicode_literals
import io
import os
from googlecloudsdk.calliope import arg_parsers
from googlecloudsdk.calliope import base
from googlecloudsdk.calliope import usage_text
from googlecloudsdk.core import exceptions
from googlecloudsdk.core import log
from googlecloudsdk.core.util import files
import six
class UnknownCheckException(Exception):
  """An exception when unknown lint check is requested."""
class LintException(exceptions.Error):
  """One or more lint errors found."""
class LintError(object):
  """Validation failure.
  Attributes:
    name: str, The name of the validation that produced this failure.
    command: calliope.backend.CommandCommon, The offending command.
    msg: str, A message indicating what the problem was.
  """
  def __init__(self, name, command, error_message):
    self.name = name
    self.command = command
    self.msg = '[{cmd}]: {msg}'.format(
        cmd='.'.join(command.GetPath()), msg=error_message)
class Checker(object):
  """The abstract base class for all the checks.
  Attributes:
    name: A string, the name of this Checker.
    description: string, command line description of this check.
  """
  def ForEveryGroup(self, group):
    pass
  def ForEveryCommand(self, command):
    pass
  def End(self):
    return []
class NameChecker(Checker):
  """Checks if group,command and flags names have underscores or mixed case."""
  name = 'NameCheck'
  description = 'Verifies all existing flags not to have underscores.'
  def __init__(self):
    super(NameChecker, self).__init__()
    self._issues = []
  def _ForEvery(self, cmd_or_group):
    """Run name check for given command or group."""
    if '_' in cmd_or_group.cli_name:
      self._issues.append(LintError(
          name=NameChecker.name,
          command=cmd_or_group,
          error_message='command name [{0}] has underscores'.format(
              cmd_or_group.cli_name)))
    if not (cmd_or_group.cli_name.islower() or cmd_or_group.cli_name.isupper()):
      self._issues.append(LintError(
          name=NameChecker.name,
          command=cmd_or_group,
          error_message='command name [{0}] mixed case'.format(
              cmd_or_group.cli_name)))
    for flag in cmd_or_group.GetSpecificFlags():
      if not any(f.startswith('--') for f in flag.option_strings):
        if len(flag.option_strings) != 1 or flag.option_strings[0] != '-h':
          self._issues.append(LintError(
              name=NameChecker.name,
              command=cmd_or_group,
              error_message='flag [{0}] has no long form'.format(
                  ','.join(flag.option_strings))))
      for flag_option_string in flag.option_strings:
        msg = None
        if '_' in flag_option_string:
          msg = 'flag [%s] has underscores' % flag_option_string
        if (flag_option_string.startswith('--')
            and not flag_option_string.islower()):
          msg = 'long flag [%s] has upper case characters' % flag_option_string
        if msg:
          self._issues.append(LintError(
              name=NameChecker.name, command=cmd_or_group, error_message=msg))
  def ForEveryGroup(self, group):
    self._ForEvery(group)
  def ForEveryCommand(self, command):
    self._ForEvery(command)
  def End(self):
    return self._issues
class BadListsChecker(Checker):
  """Checks command flags that take lists."""
  name = 'BadLists'
  description = 'Verifies all flags implement lists properly.'
  def __init__(self):
    super(BadListsChecker, self).__init__()
    self._issues = []
  def _ForEvery(self, cmd_or_group):
    for flag in cmd_or_group.GetSpecificFlags():
      if flag.nargs not in [None, 0, 1]:
        self._issues.append(LintError(
            name=BadListsChecker.name,
            command=cmd_or_group,
            error_message=(
                'flag [{flg}] has nargs={nargs}'.format(
                    flg=flag.option_strings[0],
                    nargs="'{}'".format(six.text_type(flag.nargs))))))
      if isinstance(flag.type, arg_parsers.ArgObject):
        # No metavar requirements for ArgObject.
        return
      if isinstance(flag.type, arg_parsers.ArgDict):
        if not (flag.metavar or flag.type.spec):
          self._issues.append(
              LintError(
                  name=BadListsChecker.name,
                  command=cmd_or_group,
                  error_message=(
                      ('dict flag [{flg}] has no metavar and type.spec'
                       ' (at least one needed)'
                      ).format(flg=flag.option_strings[0]))))
      elif isinstance(flag.type, arg_parsers.ArgList):
        if not flag.metavar:
          self._issues.append(LintError(
              name=BadListsChecker.name,
              command=cmd_or_group,
              error_message=(
                  'list flag [{flg}] has no metavar'.format(
                      flg=flag.option_strings[0]))))
  def ForEveryGroup(self, group):
    self._ForEvery(group)
  def ForEveryCommand(self, command):
    self._ForEvery(command)
  def End(self):
    return self._issues
def _GetAllowlistedCommandVocabulary():
  """Returns allowlisted set of gcloud commands."""
  vocabulary_file = os.path.join(os.path.dirname(__file__),
                                 'gcloud_command_vocabulary.txt')
  return set(
      line for line in files.ReadFileContents(vocabulary_file).split('\n')
      if not line.startswith('#'))
class VocabularyChecker(Checker):
  """Checks that command is the list of allowlisted names."""
  name = 'AllowlistedNameCheck'
  description = 'Verifies that every command is allowlisted.'
  def __init__(self):
    super(VocabularyChecker, self).__init__()
    self._allowlist = _GetAllowlistedCommandVocabulary()
    self._issues = []
  def ForEveryGroup(self, group):
    pass
  def ForEveryCommand(self, command):
    if command.cli_name not in self._allowlist:
      self._issues.append(LintError(
          name=self.name,
          command=command,
          error_message='command name [{0}] is not allowlisted'.format(
              command.cli_name)))
  def End(self):
    return self._issues
def _WalkGroupTree(group):
  """Visits each group in the CLI group tree.
  Args:
    group: backend.CommandGroup, root CLI subgroup node.
  Yields:
    group instance.
  """
  yield group
  for sub_group in six.itervalues(group.groups):
    for value in _WalkGroupTree(sub_group):
      yield value
class Linter(object):
  """Lints gcloud commands."""
  def __init__(self):
    self._checks = []
  def AddCheck(self, check):
    self._checks.append(check())
  def Run(self, group_root):
    """Runs registered checks on all groups and commands."""
    for group in _WalkGroupTree(group_root):
      for check in self._checks:
        check.ForEveryGroup(group)
      for command in six.itervalues(group.commands):
        for check in self._checks:
          check.ForEveryCommand(command)
    return [issue for check in self._checks for issue in check.End()]
# List of registered checks, all are run by default.
_DEFAULT_LINT_CHECKS = [
    NameChecker,
]
_LINT_CHECKS = [
    BadListsChecker,
    VocabularyChecker,
]
def _FormatCheckList(check_list):
  buf = io.StringIO()
  for check in check_list:
    usage_text.WrapWithPrefix(
        check.name, check.description, 20, 78, '  ', writer=buf)
  return buf.getvalue()
class Lint(base.Command):
  """Validate gcloud flags according to Cloud SDK CLI Style."""
  @staticmethod
  def Args(parser):
    parser.add_argument(
        'checks',
        metavar='CHECKS',
        nargs='*',
        default=[],
        help="""\
A list of checks to apply to gcloud groups and commands.
If omitted will run all available checks.
Available Checks:
""" + _FormatCheckList(_LINT_CHECKS))
  def Run(self, args):
    # pylint: disable=protected-access
    group = self._cli_power_users_only._TopElement()
    group.LoadAllSubElements(recursive=True)
    return Lint._SetupAndRun(group, args.checks)
  @staticmethod
  def _SetupAndRun(group, check_list):
    """Builds up linter and executes it for given set of checks."""
    linter = Linter()
    unknown_checks = []
    if not check_list:
      for check in _DEFAULT_LINT_CHECKS:
        linter.AddCheck(check)
    else:
      available_checkers = dict(
          (checker.name, checker)
          for checker in _DEFAULT_LINT_CHECKS + _LINT_CHECKS)
      for check in check_list:
        if check in available_checkers:
          linter.AddCheck(available_checkers[check])
        else:
          unknown_checks.append(check)
    if unknown_checks:
      raise UnknownCheckException(
          'Unknown lint checks: %s' % ','.join(unknown_checks))
    return linter.Run(group)
  def Display(self, args, result):
    writer = log.out
    for issue in result:
      writer.Print(issue.msg)
    if result:
      raise LintException('there were some lint errors.')