From df26cc0438f996c03a9b61aca3a8b35166b09a99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrien=20Verg=C3=A9?= Date: Tue, 6 Jun 2017 21:52:59 +0200 Subject: [PATCH] feat(config): Add support to ignore paths on per-rule basis Example of configuration to use this feature: # For all rules ignore: | *.dont-lint-me.yaml /bin/ !/bin/*.lint-me-anyway.yaml rules: key-duplicates: ignore: | generated *.template.yaml trailing-spaces: ignore: | *.ignore-trailing-spaces.yaml /ascii-art/* Closes #43. --- README.rst | 21 ++++++++ docs/configuration.rst | 54 ++++++++++++++++++++ setup.py | 2 +- tests/test_config.py | 110 +++++++++++++++++++++++++++++++++++++++-- yamllint/cli.py | 3 +- yamllint/config.py | 32 ++++++++++-- yamllint/linter.py | 17 ++++--- 7 files changed, 222 insertions(+), 17 deletions(-) diff --git a/README.rst b/README.rst index 5393adf..1f5dac1 100644 --- a/README.rst +++ b/README.rst @@ -119,6 +119,27 @@ or for a whole block: consectetur : adipiscing elit # yamllint enable +Specific files can be ignored (totally or for some rules only) using a +``.gitignore``-style pattern: + +.. code:: yaml + + # For all rules + ignore: | + *.dont-lint-me.yaml + /bin/ + !/bin/*.lint-me-anyway.yaml + + rules: + key-duplicates: + ignore: | + generated + *.template.yaml + trailing-spaces: + ignore: | + *.ignore-trailing-spaces.yaml + /ascii-art/* + `Read more in the complete documentation! `_ License diff --git a/docs/configuration.rst b/docs/configuration.rst index 44aad82..0815789 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -114,3 +114,57 @@ return code will be: * ``0`` if no errors or warnings occur * ``1`` if one or more errors occur * ``2`` if no errors occur, but one or more warnings occur + +Ignoring paths +-------------- + +It is possible to exclude specific files or directories, so that the linter +doesn't process them. + +You can either totally ignore files (they won't be looked at): + +.. code-block:: yaml + + extends: default + + ignore: | + /this/specific/file.yaml + /all/this/directory/ + *.template.yaml + +or ignore paths only for specific rules: + +.. code-block:: yaml + + extends: default + + rules: + trailing-spaces: + ignore: | + /this-file-has-trailing-spaces-but-it-is-OK.yaml + /generated/*.yaml + +Note that this ``.gitignore``-style path pattern allows complex path +exclusion/inclusion, see the `pathspec README file +`_ for more details. +Here is a more complex example: + +.. code-block:: yaml + + # For all rules + ignore: | + *.dont-lint-me.yaml + /bin/ + !/bin/*.lint-me-anyway.yaml + + extends: default + + rules: + key-duplicates: + ignore: | + generated + *.template.yaml + trailing-spaces: + ignore: | + *.ignore-trailing-spaces.yaml + /ascii-art/* diff --git a/setup.py b/setup.py index 8a865f4..9c54fcd 100644 --- a/setup.py +++ b/setup.py @@ -46,7 +46,7 @@ setup( entry_points={'console_scripts': ['yamllint=yamllint.cli:run']}, package_data={'yamllint': ['conf/*.yaml'], 'tests': ['yaml-1.2-spec-examples/*']}, - install_requires=['pyyaml'], + install_requires=['pathspec', 'pyyaml'], tests_require=['nose'], test_suite='nose.collector', ) diff --git a/tests/test_config.py b/tests/test_config.py index 5f06ef1..d6485cc 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -14,10 +14,20 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +try: + from cStringIO import StringIO +except ImportError: + from io import StringIO +import os +import shutil +import sys import unittest +from yamllint import cli from yamllint import config +from tests.common import build_temp_workspace + class SimpleConfigTestCase(unittest.TestCase): def test_parse_config(self): @@ -30,7 +40,7 @@ class SimpleConfigTestCase(unittest.TestCase): self.assertEqual(new.rules['colons']['max-spaces-before'], 0) self.assertEqual(new.rules['colons']['max-spaces-after'], 1) - self.assertEqual(len(new.enabled_rules()), 1) + self.assertEqual(len(new.enabled_rules(None)), 1) def test_invalid_conf(self): with self.assertRaises(config.YamlLintConfigError): @@ -170,7 +180,7 @@ class ExtendedConfigTestCase(unittest.TestCase): self.assertEqual(new.rules['colons']['max-spaces-after'], 1) self.assertEqual(new.rules['hyphens']['max-spaces-after'], 2) - self.assertEqual(len(new.enabled_rules()), 2) + self.assertEqual(len(new.enabled_rules(None)), 2) def test_extend_remove_rule(self): old = config.YamlLintConfig('rules:\n' @@ -187,7 +197,7 @@ class ExtendedConfigTestCase(unittest.TestCase): self.assertEqual(new.rules['colons'], False) self.assertEqual(new.rules['hyphens']['max-spaces-after'], 2) - self.assertEqual(len(new.enabled_rules()), 1) + self.assertEqual(len(new.enabled_rules(None)), 1) def test_extend_edit_rule(self): old = config.YamlLintConfig('rules:\n' @@ -207,7 +217,7 @@ class ExtendedConfigTestCase(unittest.TestCase): self.assertEqual(new.rules['colons']['max-spaces-after'], 4) self.assertEqual(new.rules['hyphens']['max-spaces-after'], 2) - self.assertEqual(len(new.enabled_rules()), 2) + self.assertEqual(len(new.enabled_rules(None)), 2) def test_extend_reenable_rule(self): old = config.YamlLintConfig('rules:\n' @@ -225,7 +235,7 @@ class ExtendedConfigTestCase(unittest.TestCase): self.assertEqual(new.rules['colons']['max-spaces-after'], 1) self.assertEqual(new.rules['hyphens']['max-spaces-after'], 2) - self.assertEqual(len(new.enabled_rules()), 2) + self.assertEqual(len(new.enabled_rules(None)), 2) class ExtendedLibraryConfigTestCase(unittest.TestCase): @@ -270,3 +280,93 @@ class ExtendedLibraryConfigTestCase(unittest.TestCase): self.assertEqual(sorted(new.rules.keys()), sorted(old.rules.keys())) for rule in new.rules: self.assertEqual(new.rules[rule], old.rules[rule]) + + +class IgnorePathConfigTestCase(unittest.TestCase): + @classmethod + def setUpClass(cls): + super(IgnorePathConfigTestCase, cls).setUpClass() + + bad_yaml = ('---\n' + '- key: val1\n' + ' key: val2\n' + '- trailing space \n' + '- lonely hyphen\n') + + cls.wd = build_temp_workspace({ + 'bin/file.lint-me-anyway.yaml': bad_yaml, + 'bin/file.yaml': bad_yaml, + 'file-at-root.yaml': bad_yaml, + 'file.dont-lint-me.yaml': bad_yaml, + 'ign-dup/file.yaml': bad_yaml, + 'ign-dup/sub/dir/file.yaml': bad_yaml, + 'ign-trail/file.yaml': bad_yaml, + 'include/ign-dup/sub/dir/file.yaml': bad_yaml, + 's/s/ign-trail/file.yaml': bad_yaml, + 's/s/ign-trail/s/s/file.yaml': bad_yaml, + 's/s/ign-trail/s/s/file2.lint-me-anyway.yaml': bad_yaml, + + '.yamllint': 'ignore: |\n' + ' *.dont-lint-me.yaml\n' + ' /bin/\n' + ' !/bin/*.lint-me-anyway.yaml\n' + '\n' + 'extends: default\n' + '\n' + 'rules:\n' + ' key-duplicates:\n' + ' ignore: |\n' + ' /ign-dup\n' + ' trailing-spaces:\n' + ' ignore: |\n' + ' ign-trail\n' + ' !*.lint-me-anyway.yaml\n', + }) + + cls.backup_wd = os.getcwd() + os.chdir(cls.wd) + + @classmethod + def tearDownClass(cls): + super(IgnorePathConfigTestCase, cls).tearDownClass() + + os.chdir(cls.backup_wd) + + shutil.rmtree(cls.wd) + + def test_run_with_ignored_path(self): + sys.stdout = StringIO() + with self.assertRaises(SystemExit): + cli.run(('-f', 'parsable', '.')) + + out = sys.stdout.getvalue() + out = '\n'.join(sorted(out.splitlines())) + + keydup = '[error] duplication of key "key" in mapping (key-duplicates)' + trailing = '[error] trailing spaces (trailing-spaces)' + hyphen = '[error] too many spaces after hyphen (hyphens)' + + self.assertEqual(out, '\n'.join(( + './bin/file.lint-me-anyway.yaml:3:3: ' + keydup, + './bin/file.lint-me-anyway.yaml:4:17: ' + trailing, + './bin/file.lint-me-anyway.yaml:5:5: ' + hyphen, + './file-at-root.yaml:3:3: ' + keydup, + './file-at-root.yaml:4:17: ' + trailing, + './file-at-root.yaml:5:5: ' + hyphen, + './ign-dup/file.yaml:4:17: ' + trailing, + './ign-dup/file.yaml:5:5: ' + hyphen, + './ign-dup/sub/dir/file.yaml:4:17: ' + trailing, + './ign-dup/sub/dir/file.yaml:5:5: ' + hyphen, + './ign-trail/file.yaml:3:3: ' + keydup, + './ign-trail/file.yaml:5:5: ' + hyphen, + './include/ign-dup/sub/dir/file.yaml:3:3: ' + keydup, + './include/ign-dup/sub/dir/file.yaml:4:17: ' + trailing, + './include/ign-dup/sub/dir/file.yaml:5:5: ' + hyphen, + './s/s/ign-trail/file.yaml:3:3: ' + keydup, + './s/s/ign-trail/file.yaml:5:5: ' + hyphen, + './s/s/ign-trail/s/s/file.yaml:3:3: ' + keydup, + './s/s/ign-trail/s/s/file.yaml:5:5: ' + hyphen, + './s/s/ign-trail/s/s/file2.lint-me-anyway.yaml:3:3: ' + keydup, + './s/s/ign-trail/s/s/file2.lint-me-anyway.yaml:4:17: ' + trailing, + './s/s/ign-trail/s/s/file2.lint-me-anyway.yaml:5:5: ' + hyphen, + ))) diff --git a/yamllint/cli.py b/yamllint/cli.py index f207b88..418cf35 100644 --- a/yamllint/cli.py +++ b/yamllint/cli.py @@ -126,10 +126,11 @@ def run(argv=None): max_level = 0 for file in find_files_recursively(args.files): + filepath = file[2:] if file.startswith('./') else file try: first = True with open(file) as f: - for problem in linter.run(f, conf): + for problem in linter.run(f, conf, filepath): if args.format == 'parsable': print(Format.parsable(problem, file)) elif sys.stdout.isatty(): diff --git a/yamllint/config.py b/yamllint/config.py index 48ea380..fb5a161 100644 --- a/yamllint/config.py +++ b/yamllint/config.py @@ -16,6 +16,7 @@ import os.path +import pathspec import yaml import yamllint.rules @@ -29,6 +30,8 @@ class YamlLintConfig(object): def __init__(self, content=None, file=None): assert (content is None) ^ (file is None) + self.ignore = None + if file is not None: with open(file) as f: content = f.read() @@ -36,9 +39,14 @@ class YamlLintConfig(object): self.parse(content) self.validate() - def enabled_rules(self): + def is_file_ignored(self, filepath): + return self.ignore and self.ignore.match_file(filepath) + + def enabled_rules(self, filepath): return [yamllint.rules.get(id) for id, val in self.rules.items() - if val is not False] + if val is not False and ( + filepath is None or 'ignore' not in val or + not val['ignore'].match_file(filepath))] def extend(self, base_config): assert isinstance(base_config, YamlLintConfig) @@ -53,6 +61,9 @@ class YamlLintConfig(object): self.rules = base_config.rules + if base_config.ignore is not None: + self.ignore = base_config.ignore + def parse(self, raw_content): try: conf = yaml.safe_load(raw_content) @@ -73,6 +84,13 @@ class YamlLintConfig(object): except Exception as e: raise YamlLintConfigError('invalid config: %s' % e) + if 'ignore' in conf: + if type(conf['ignore']) != str: + raise YamlLintConfigError( + 'invalid config: ignore should be a list of patterns') + self.ignore = pathspec.PathSpec.from_lines( + 'gitwildmatch', conf['ignore'].splitlines()) + def validate(self): for id in self.rules: try: @@ -90,6 +108,14 @@ def validate_rule_conf(rule, conf): conf = {} if type(conf) == dict: + if ('ignore' in conf and + type(conf['ignore']) != pathspec.pathspec.PathSpec): + if type(conf['ignore']) != str: + raise YamlLintConfigError( + 'invalid config: ignore should be a list of patterns') + conf['ignore'] = pathspec.PathSpec.from_lines( + 'gitwildmatch', conf['ignore'].splitlines()) + if 'level' not in conf: conf['level'] = 'error' elif conf['level'] not in ('error', 'warning'): @@ -98,7 +124,7 @@ def validate_rule_conf(rule, conf): options = getattr(rule, 'CONF', {}) for optkey in conf: - if optkey == 'level': + if optkey in ('ignore', 'level'): continue if optkey not in options: raise YamlLintConfigError( diff --git a/yamllint/linter.py b/yamllint/linter.py index 012bcbd..c8eff8d 100644 --- a/yamllint/linter.py +++ b/yamllint/linter.py @@ -63,8 +63,8 @@ class LintProblem(object): return '%d:%d: %s' % (self.line, self.column, self.message) -def get_cosmetic_problems(buffer, conf): - rules = conf.enabled_rules() +def get_cosmetic_problems(buffer, conf, filepath): + rules = conf.enabled_rules(filepath) # Split token rules from line rules token_rules = [r for r in rules if r.TYPE == 'token'] @@ -185,7 +185,7 @@ def get_syntax_error(buffer): return problem -def _run(buffer, conf): +def _run(buffer, conf, filepath): assert hasattr(buffer, '__getitem__'), \ '_run() argument must be a buffer, not a stream' @@ -193,7 +193,7 @@ def _run(buffer, conf): # right line syntax_error = get_syntax_error(buffer) - for problem in get_cosmetic_problems(buffer, conf): + for problem in get_cosmetic_problems(buffer, conf, filepath): # Insert the syntax error (if any) at the right place... if (syntax_error and syntax_error.line <= problem.line and syntax_error.column <= problem.column): @@ -215,7 +215,7 @@ def _run(buffer, conf): yield syntax_error -def run(input, conf): +def run(input, conf, filepath=None): """Lints a YAML source. Returns a generator of LintProblem objects. @@ -223,11 +223,14 @@ def run(input, conf): :param input: buffer, string or stream to read from :param conf: yamllint configuration object """ + if conf.is_file_ignored(filepath): + return () + if type(input) in (type(b''), type(u'')): # compat with Python 2 & 3 - return _run(input, conf) + return _run(input, conf, filepath) elif hasattr(input, 'read'): # Python 2's file or Python 3's io.IOBase # We need to have everything in memory to parse correctly content = input.read() - return _run(content, conf) + return _run(content, conf, filepath) else: raise TypeError('input should be a string or a stream')