# -*- 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.')