From 1c15ad1adc0dbc2d05f0da4b2c58668dbc5020b9 Mon Sep 17 00:00:00 2001 From: Satoru SATOH Date: Fri, 2 Oct 2020 05:00:51 +0900 Subject: [PATCH] enhancement: add lint rules plugin support Add plugin support using setuptools (pkg_resources) plugin mechanism to yamllint to allow users to add their own custom lint rule plugins, together with an example plugin implementation and test cases. Signed-off-by: Satoru SATOH --- tests/plugins/__init__.py | 0 tests/plugins/example/__init__.py | 26 +++++++ tests/plugins/example/override_comments.py | 63 +++++++++++++++++ tests/test_plugins.py | 80 ++++++++++++++++++++++ yamllint/plugins.py | 61 +++++++++++++++++ 5 files changed, 230 insertions(+) create mode 100644 tests/plugins/__init__.py create mode 100644 tests/plugins/example/__init__.py create mode 100644 tests/plugins/example/override_comments.py create mode 100644 tests/test_plugins.py create mode 100644 yamllint/plugins.py diff --git a/tests/plugins/__init__.py b/tests/plugins/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/plugins/example/__init__.py b/tests/plugins/example/__init__.py new file mode 100644 index 0000000..1ac13cc --- /dev/null +++ b/tests/plugins/example/__init__.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2020 Satoru SATOH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""yamllint plugin entry point +""" +from __future__ import absolute_import + +from . import override_comments + + +RULES_MAP = { + override_comments.ID: override_comments +} diff --git a/tests/plugins/example/override_comments.py b/tests/plugins/example/override_comments.py new file mode 100644 index 0000000..64350eb --- /dev/null +++ b/tests/plugins/example/override_comments.py @@ -0,0 +1,63 @@ +# +# Copyright (C) 2020 Satoru SATOH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +""" +Use this rule to override some comments' rules. + +.. rubric:: Options + +* Use ``forbid`` to control comments. Set to ``true`` to forbid comments + completely. + +.. rubric:: Examples + +#. With ``override-comments: {forbid: true}`` + + the following code snippet would **PASS**: + :: + + foo: 1 + + the following code snippet would **FAIL**: + :: + + # baz + foo: 1 + +.. rubric:: Default values (when enabled) + +.. code-block:: yaml + +rules: + override-comments: + forbid: False + +""" +from yamllint.linter import LintProblem + + +ID = 'override-comments' +TYPE = 'comment' +CONF = {'forbid': bool} +DEFAULT = {'forbid': False} + + +def check(conf, comment): + """Check if comments are found. + """ + if conf['forbid']: + yield LintProblem(comment.line_no, comment.column_no, + 'forbidden comment') diff --git a/tests/test_plugins.py b/tests/test_plugins.py new file mode 100644 index 0000000..fe59aed --- /dev/null +++ b/tests/test_plugins.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2020 Satoru SATOH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +import unittest +import warnings + +try: + from unittest import mock +except ImportError: # for python 2.7 + mock = False + +from tests.plugins import example + +import yamllint.plugins + + +class FakeEntryPoint(object): + """Fake object to mimic pkg_resources.EntryPoint. + """ + RULES_MAP = example.RULES_MAP + + def load(self): + """Fake method to return self. + """ + return self + + +class BrokenEntryPoint(FakeEntryPoint): + """Fake object to mimic load failure of pkg_resources.EntryPoint. + """ + def load(self): + raise ImportError("This entry point should fail always!") + + +class PluginFunctionsTestCase(unittest.TestCase): + + def test_validate_rule_module(self): + fun = yamllint.plugins.validate_rule_module + rule_mod = example.override_comments + + self.assertFalse(fun(object())) + self.assertTrue(fun(rule_mod)) + + @unittest.skipIf(not mock, "unittest.mock is not available") + def test_validate_rule_module_using_mock(self): + fun = yamllint.plugins.validate_rule_module + rule_mod = example.override_comments + + with mock.patch.object(rule_mod, "ID", False): + self.assertFalse(fun(rule_mod)) + + with mock.patch.object(rule_mod, "TYPE", False): + self.assertFalse(fun(rule_mod)) + + with mock.patch.object(rule_mod, "check", True): + self.assertFalse(fun(rule_mod)) + + def test_load_plugin_rules_itr(self): + fun = yamllint.plugins.load_plugin_rules_itr + + self.assertEqual(list(fun([])), []) + self.assertEqual(sorted(fun([FakeEntryPoint(), + FakeEntryPoint()])), + sorted(FakeEntryPoint.RULES_MAP.items())) + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + self.assertEqual(list(fun([BrokenEntryPoint()])), []) diff --git a/yamllint/plugins.py b/yamllint/plugins.py new file mode 100644 index 0000000..fa24218 --- /dev/null +++ b/yamllint/plugins.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2020 Satoru SATOH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +""" +Plugin module utilizing setuptools (pkg_resources) to allow users to add their +own custom lint rules. +""" +import warnings + +import pkg_resources + + +PACKAGE_GROUP = "yamllint.plugins.rules" + + +def validate_rule_module(rule_mod): + """Test if given rule module is valid. + """ + return (getattr(rule_mod, "ID", False) and + getattr(rule_mod, "TYPE", False) + ) and callable(getattr(rule_mod, "check", False)) + + +def load_plugin_rules_itr(entry_points=None, group=PACKAGE_GROUP): + """Load custom lint rule plugins.""" + if not entry_points: + entry_points = pkg_resources.iter_entry_points(group) + + rule_ids = set() + for entry in entry_points: + try: + rules = entry.load() + for rule_id, rule_mod in rules.RULES_MAP.items(): + if rule_id in rule_ids or not validate_rule_module(rule_mod): + continue + + yield (rule_id, rule_mod) + rule_ids.add(rule_id) + + # pkg_resources.EntryPoint.resolve may throw ImportError. + except (AttributeError, ImportError): + warnings.warn("Could not load the plugin: {}".format(entry), + RuntimeWarning) + + +def get_plugin_rules_map(): + """Get a mappings of plugin rule's IDs and rules.""" + return dict((rule_id, rule_mod) + for rule_id, rule_mod in load_plugin_rules_itr())