diff --git a/tools/custom_guidelines.py b/tools/custom_guidelines.py new file mode 100644 index 0000000000..8f435af6a4 --- /dev/null +++ b/tools/custom_guidelines.py @@ -0,0 +1,274 @@ +# +# 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. + +import argparse +import re + +import six +import sys + +from heat.common.i18n import _ +from heat.engine import constraints +from heat.engine import resources +from heat.engine import support + + +class HeatCustomGuidelines(object): + + _RULES = [] + + def __init__(self, exclude): + self.error_count = 0 + self.resources_classes = [] + global_env = resources.global_env() + for resource_type in global_env.get_types(): + cls = global_env.get_class(resource_type) + if (lambda module: True + if [path for path in exclude if path in module] + else False)(cls.__module__.replace('.', '/')): + continue + self.resources_classes.append(cls) + + def run_check(self): + print(_('Heat custom guidelines check started.')) + for rule in self._RULES: + getattr(self, 'check_%s' % rule)() + if self.error_count > 0: + print(_('Heat custom guidelines check failed - ' + 'found %s errors.') % self.error_count) + sys.exit(1) + else: + print(_('Heat custom guidelines check succeeded.')) + + def check_resource_descriptions(self): + for cls in self.resources_classes: + # check resource's description + #self._check_resource_description(cls) + # check properties' descriptions + #self._check_resource_schemas(cls, cls.properties_schema, + # 'property') + # check attributes' descriptions + #self._check_resource_schemas(cls, cls.attributes_schema, + # 'attribute') + # check methods descriptions + #self._check_resource_methods(cls) + # TODO(prazumovsky): remove when at least one check will be + # available + pass + + def _check_resource_description(self, resource): + description = resource.__doc__ + if resource.support_status.status not in (support.SUPPORTED, + support.UNSUPPORTED): + return + kwargs = {'path': resource.__module__, 'details': resource.__name__} + if not description: + kwargs.update({'message': _("Resource description missing, " + "should add resource description " + "about resource's purpose")}) + self.print_guideline_error(**kwargs) + return + + doclines = [key.strip() for key in description.split('\n')] + if len(doclines) == 1 or (len(doclines) == 2 and doclines[-1] == ''): + kwargs.update({'message': _("Resource description missing, " + "should add resource description " + "about resource's purpose")}) + self.print_guideline_error(**kwargs) + return + + self._check_description_summary(doclines[0], kwargs, 'resource') + self._check_description_details(doclines, kwargs, 'resource') + + def _check_resource_schemas(self, resource, schema, schema_name, + error_path=None): + for key, value in six.iteritems(schema): + if error_path is None: + error_path = [resource.__name__, key] + else: + error_path.append(key) + # need to check sub-schema of current schema, if exists + if (hasattr(value, 'schema') and + getattr(value, 'schema') is not None): + self._check_resource_schemas(resource, value.schema, + schema_name, error_path) + description = value.description + kwargs = {'path': resource.__module__, 'details': error_path} + if description is None: + if (value.support_status.status == support.SUPPORTED and + not isinstance(value.schema, + constraints.AnyIndexDict) and + not isinstance(schema, constraints.AnyIndexDict)): + kwargs.update({'message': _("%s description " + "missing, need to add " + "description about property's " + "purpose") % schema_name}) + self.print_guideline_error(**kwargs) + error_path.pop() + continue + self._check_description_summary(description, kwargs, schema_name) + error_path.pop() + + def _check_resource_methods(self, resource): + for method in six.itervalues(resource.__dict__): + # need to skip non-functions attributes + if not callable(method): + continue + description = method.__doc__ + if not description: + continue + if method.__name__.startswith('__'): + continue + doclines = [key.strip() for key in description.split('\n')] + kwargs = {'path': resource.__module__, + 'details': [resource.__name__, method.__name__]} + + self._check_description_summary(doclines[0], kwargs, 'method') + + if len(doclines) == 2: + kwargs.update({'message': _('Method description summary ' + 'should be in one line')}) + self.print_guideline_error(**kwargs) + continue + + if len(doclines) > 1: + self._check_description_details(doclines, kwargs, 'method') + + def check_trailing_spaces(self): + for cls in self.resources_classes: + cls_file = open(cls.__module__.replace('.', '/') + '.py') + lines = [line.strip() for line in cls_file.readlines()] + idx = 0 + kwargs = {'path': cls.__module__} + while idx < len(lines): + if ('properties_schema' in lines[idx] or + 'attributes_schema' in lines[idx]): + level = len(re.findall('(\{|\()', lines[idx])) + level -= len(re.findall('(\}|\))', lines[idx])) + idx += 1 + while level != 0: + level += len(re.findall('(\{|\()', lines[idx])) + level -= len(re.findall('(\}|\))', lines[idx])) + if re.search("^((\'|\") )", lines[idx]): + kwargs.update( + {'details': 'line %s' % idx, + 'message': _('Trailing whitespace should ' + 'be on previous line'), + 'snippet': lines[idx]}) + self.print_guideline_error(**kwargs) + elif (re.search("(\S(\'|\"))$", lines[idx - 1]) and + re.search("^((\'|\")\S)", lines[idx])): + kwargs.update( + {'details': 'line %s' % (idx - 1), + 'message': _('Omitted whitespace at the ' + 'end of the line'), + 'snippet': lines[idx - 1]}) + self.print_guideline_error(**kwargs) + idx += 1 + idx += 1 + + def _check_description_summary(self, description, error_kwargs, + error_key): + if re.search("^[a-z]", description): + error_kwargs.update( + {'message': _('%s description summary should start ' + 'with uppercase letter') % error_key.title(), + 'snippet': description}) + self.print_guideline_error(**error_kwargs) + if not description.endswith('.'): + error_kwargs.update( + {'message': _('%s description summary omitted ' + 'terminator at the end') % error_key.title(), + 'snippet': description}) + self.print_guideline_error(**error_kwargs) + if re.search("\s{2,}", description): + error_kwargs.update( + {'message': _('%s description contains double or more ' + 'whitespaces') % error_key.title(), + 'snippet': description}) + self.print_guideline_error(**error_kwargs) + + def _check_description_details(self, doclines, error_kwargs, + error_key): + if re.search("\S", doclines[1]): + error_kwargs.update( + {'message': _('%s description summary and ' + 'main resource description should be ' + 'separated by blank line') % error_key.title(), + 'snippet': doclines[0]}) + self.print_guideline_error(**error_kwargs) + + if re.search("^[a-z]", doclines[2]): + error_kwargs.update( + {'message': _('%s description should start ' + 'with with uppercase ' + 'letter') % error_key.title(), + 'snippet': doclines[2]}) + self.print_guideline_error(**error_kwargs) + + if doclines[-1] != '': + error_kwargs.update( + {'message': _('%s description multistring ' + 'should have singly closing quotes at ' + 'the next line') % error_key.title(), + 'snippet': doclines[-1]}) + self.print_guideline_error(**error_kwargs) + + params = False + for line in doclines[1:]: + if re.search("\s{2,}", line): + error_kwargs.update( + {'message': _('%s description ' + 'contains double or more ' + 'whitespaces') % error_key.title(), + 'snippet': line}) + self.print_guideline_error(**error_kwargs) + if re.search("^(:param|:type|:returns|:rtype|:raises)", + line): + params = True + if not params and not doclines[-2].endswith('.'): + error_kwargs.update( + {'message': _('%s description omitted ' + 'terminator at the end') % error_key.title(), + 'snippet': doclines[-2]}) + self.print_guideline_error(**error_kwargs) + + def print_guideline_error(self, path, details, message, snippet=None): + if isinstance(details, list): + details = '.'.join(details) + msg = _('ERROR (in %(path)s: %(details)s): %(message)s') % { + 'message': message, + 'path': path.replace('.', '/'), + 'details': details + } + if snippet is not None: + msg = _('%(msg)s\n (Error snippet): %(snippet)s') % { + 'msg': msg, + 'snippet': '%s...' % snippet[:79] + } + print(msg) + self.error_count += 1 + + +def parse_args(): + parser = argparse.ArgumentParser() + parser.add_argument('--exclude', '-e', metavar='', + nargs='+', + help=_('Exclude specified paths from checking.')) + return parser.parse_args() + + +if __name__ == '__main__': + args = parse_args() + guidelines = HeatCustomGuidelines(args.exclude or []) + guidelines.run_check() diff --git a/tox.ini b/tox.ini index 91dfec5517..53c657b640 100644 --- a/tox.ini +++ b/tox.ini @@ -37,6 +37,7 @@ commands = flake8 heat bin/heat-api bin/heat-api-cfn bin/heat-api-cloudwatch bin/heat-engine bin/heat-manage contrib heat_integrationtests doc/source # Check that .po and .pot files are valid: bash -c "find heat -type f -regex '.*\.pot?' -print0|xargs -0 -n 1 msgfmt --check-format -o /dev/null" + python tools/custom_guidelines.py --exclude heat/engine/resources/aws [testenv:venv] commands = {posargs}