diff --git a/README.md b/README.md index 691e1f5e..87ee642d 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ Repository to develop **experimental** [Conan](https://conan.io) hooks for Conan * [Member typo checker](#members-typo-checker) * [SPDX checker](#spdx-checker) * [Recipe linter](#recipe-linter) + * [Non ASCII](#non-ascii) + * [YAML linter](#yaml-linter) ## Hook setup @@ -176,6 +178,14 @@ Separate KB-H047 from Conan Center, which is no longer required due Python 2.7 d Validates if `conanfile.py` and `test_package/conanfile.py` contain a non-ascii present, when there is a character, it logs an error. +### [YAML linter](hooks/yaml_linter.py) + +This hook runs [yamllint](https://yamllint.readthedocs.io/) over the yaml files +in a recipe before exporting them (it runs in the `pre_export` hook), it can be +really useful to check for typos. + +This hook requires additional dependencies to work: `pip install yamllint`. + ## License [MIT License](LICENSE) diff --git a/hooks/yaml_linter.py b/hooks/yaml_linter.py new file mode 100644 index 00000000..efca1e43 --- /dev/null +++ b/hooks/yaml_linter.py @@ -0,0 +1,63 @@ +# coding=utf-8 + +import os +import platform +import subprocess + +from conans.errors import ConanException +from conans.tools import logger + + +CONAN_HOOK_YAMLLINT_WERR = "CONAN_YAMLLINT_WERR" + + +def pre_export(output, conanfile_path, *args, **kwargs): + try: + import yamllint + except ImportError as e: + output.error("Install yamllint to use 'yaml_linter' hook: 'pip install yamllint'") + return + output.info("Lint yaml '{}'".format(conanfile_path)) + conanfile_dirname = os.path.dirname(conanfile_path) + + rules = { + "document-start": "disable", + "line-length": "disable", + "new-lines": "{level: warning}", + "empty-lines": "{level: warning}", + "indentation": "{level: warning}", + "trailing-spaces": "{level: warning}", + } + + lint_args = ['-f', 'parsable', + '-d', '"{extends: default, rules: {%s}}"' % + ", ".join("%s: %s" % (r, rules[r]) for r in rules)] + lint_args.append('"%s"' % conanfile_dirname.replace('\\', '/')) + configfile = os.path.join(conanfile_dirname, "..", "config.yml") + if os.path.isfile(configfile): + lint_args.append('"%s"' % configfile.replace('\\', '/')) + + try: + command = ['yamllint'] + lint_args + command = " ".join(command) + shell = bool(platform.system() != "Windows") + p = subprocess.Popen(command, shell=shell, bufsize=10, + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + yamllint_stdout, yamllint_stderr = p.communicate() + yamllint_stdout = yamllint_stdout.decode('utf-8') + except Exception as exc: + output.error("Unexpected error running linter: {}".format(exc)) + return + errors = 0 + for line in yamllint_stdout.splitlines(): + output.info(line) + i = line.find(":") + line = line[i:] + i = line.find(":") + line = line[i:] + parts = line.split(' ') + errors += int(parts[1] == "[error]") + + output.info("YAML Linter detected '{}' errors".format(errors)) + if os.getenv(CONAN_HOOK_YAMLLINT_WERR) and errors: + raise ConanException("Package recipe has YAML linter errors. Please fix them.") diff --git a/tests/requirements_test.txt b/tests/requirements_test.txt index 22155c6f..5d3cff70 100644 --- a/tests/requirements_test.txt +++ b/tests/requirements_test.txt @@ -4,4 +4,5 @@ responses pluggy==0.11.0 pylint==2.10.2 astroid -spdx_lookup \ No newline at end of file +spdx_lookup +yamllint diff --git a/tests/test_hooks/test_yaml_linter.py b/tests/test_hooks/test_yaml_linter.py new file mode 100644 index 00000000..33892dad --- /dev/null +++ b/tests/test_hooks/test_yaml_linter.py @@ -0,0 +1,74 @@ +# coding=utf-8 + +import os +import textwrap +import unittest + +from parameterized import parameterized + +from conans import tools +from conans.client.command import ERROR_GENERAL, SUCCESS +from conans.tools import environment_append +from tests.utils.test_cases.conan_client import ConanClientTestCase + + +class YAMLLinterTests(ConanClientTestCase): + conanfile = textwrap.dedent(r""" + from conans import ConanFile, tools + + class TestConan(ConanFile): + name = "name" + version = "version" + """) + + def _get_environ(self, **kwargs): + kwargs = super(YAMLLinterTests, self)._get_environ(**kwargs) + kwargs.update({'CONAN_HOOKS': os.path.join(os.path.dirname( + __file__), '..', '..', 'hooks', 'yaml_linter')}) + return kwargs + + @parameterized.expand([(False, ), (True, )]) + def test_basic(self, yamllint_werr): + conandatafile = textwrap.dedent(r""" + sources: + "version": + url: "https://url.to/name/version.tar.xz" + sha256: "3a530d1b243b5dec00bc54937455471aaa3e56849d2593edb8ded07228202240" + patches: + "version": + - patch_file: "patches/abcdef.diff" + base_path: "source" + patches: + """) + tools.save('conanfile.py', content=self.conanfile) + tools.save('conandata.yml', content=conandatafile) + yamllint_werr_value = "1" if yamllint_werr else None + with environment_append({"CONAN_YAMLLINT_WERR": yamllint_werr_value}): + return_code = ERROR_GENERAL if yamllint_werr else SUCCESS + output = self.conan(['export', '.', 'name/version@'], expected_return_code=return_code) + + if yamllint_werr: + self.assertIn("pre_export(): Package recipe has YAML linter errors." + " Please fix them.", output) + + self.assertIn("conandata.yml:10:1:" + " [error] duplication of key \"patches\" in mapping (key-duplicates)", + output) + + def test_path_with_spaces(self): + conandatafile = textwrap.dedent(r""" + sources: + "version": + url: "https://url.to/name/version.tar.xz" + sha256: "3a530d1b243b5dec00bc54937455471aaa3e56849d2593edb8ded07228202240" + patches: + "version": + - patch_file: "patches/abcdef.diff" + base_path: "source" + """) + tools.save(os.path.join("path spaces", "conanfile.py"), content=self.conanfile) + tools.save(os.path.join("path spaces", "conandata.py"), content=conandatafile) + output = self.conan(['export', 'path spaces/conanfile.py', 'name/version@']) + recipe_path = os.path.join(os.getcwd(), "path spaces", "conanfile.py") + self.assertIn("pre_export(): Lint yaml '{}'".format(recipe_path), output) + self.assertIn("pre_export(): YAML Linter detected '0' errors", output)