From b21eb6c0e5cfca13ebf02edfc3d915f819e048f4 Mon Sep 17 00:00:00 2001 From: Satoru SATOH Date: Sun, 18 Oct 2020 11:04:59 +0900 Subject: [PATCH] enhancement: add lint rules plugin support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add plugin support using setuptools (pkg_resources) plugin mechanism to yamllint to allow users to add their own custom lint rule plugins. Also add some plugin support test cases, an example plugin as a reference, and doc section about how to develop rules' plugins. Signed-off-by: Satoru SATOH Co-authored-by: Adrien Vergé --- docs/development.rst | 12 ++ tests/test_plugins.py | 166 ++++++++++++++++++ tests/yamllint_plugin_example/README.rst | 61 +++++++ tests/yamllint_plugin_example/__init__.py | 0 .../yamllint_plugin_example/rules/__init__.py | 30 ++++ .../rules/forbid_comments.py | 61 +++++++ .../rules/no_forty_two.py | 49 ++++++ .../rules/random_failure.py | 29 +++ tests/yamllint_plugin_example/setup.cfg | 11 ++ tests/yamllint_plugin_example/setup.py | 2 + yamllint/plugins.py | 60 +++++++ yamllint/rules/__init__.py | 14 +- 12 files changed, 491 insertions(+), 4 deletions(-) create mode 100644 tests/test_plugins.py create mode 100644 tests/yamllint_plugin_example/README.rst create mode 100644 tests/yamllint_plugin_example/__init__.py create mode 100644 tests/yamllint_plugin_example/rules/__init__.py create mode 100644 tests/yamllint_plugin_example/rules/forbid_comments.py create mode 100644 tests/yamllint_plugin_example/rules/no_forty_two.py create mode 100644 tests/yamllint_plugin_example/rules/random_failure.py create mode 100644 tests/yamllint_plugin_example/setup.cfg create mode 100644 tests/yamllint_plugin_example/setup.py create mode 100644 yamllint/plugins.py diff --git a/docs/development.rst b/docs/development.rst index a706836..7ddda14 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -16,3 +16,15 @@ Basic example of running the linter from Python: .. automodule:: yamllint.linter :members: + +Develop rule plugins +--------------------- + +yamllint provides a plugin mechanism using setuptools (pkg_resources) to allow +adding custom rules. So, you can extend yamllint and add rules with your own +custom yamllint rule plugins if you developed them. + +yamllint plugins are Python packages installable using pip and distributed +under GPLv3+. To develop yamllint rules, it is recommended to copy the example +from ``tests/yamllint_plugin_example``, and follow its README file. Also, the +core rules themselves in ``yamllint/rules`` are good references. diff --git a/tests/test_plugins.py b/tests/test_plugins.py new file mode 100644 index 0000000..c91a8a8 --- /dev/null +++ b/tests/test_plugins.py @@ -0,0 +1,166 @@ +# -*- 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.common import RuleTestCase +from tests.yamllint_plugin_example import rules as example + +import yamllint.plugins +import yamllint.rules + + +class FakeEntryPoint(object): + """Fake object to mimic pkg_resources.EntryPoint. + """ + RULES = example.RULES + + 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.forbid_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.forbid_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)) + + @unittest.skipIf(not mock, "unittest.mock is not available") + def test_load_plugin_rules_itr(self): + fun = yamllint.plugins.load_plugin_rules_itr + entry_points = 'pkg_resources.iter_entry_points' + + with mock.patch(entry_points) as iter_entry_points: + iter_entry_points.return_value = [] + self.assertEqual(list(fun()), []) + + iter_entry_points.return_value = [FakeEntryPoint(), + FakeEntryPoint()] + self.assertEqual(sorted(fun()), sorted(FakeEntryPoint.RULES)) + + iter_entry_points.return_value = [BrokenEntryPoint()] + with warnings.catch_warnings(record=True) as warn: + warnings.simplefilter("always") + self.assertEqual(list(fun()), []) + + self.assertEqual(len(warn), 1) + self.assertTrue(issubclass(warn[-1].category, RuntimeWarning)) + self.assertTrue("Could not load the plugin:" + in str(warn[-1].message)) + + +@unittest.skipIf(not mock, "unittest.mock is not available") +class RulesTestCase(unittest.TestCase): + def test_get_default_rule(self): + self.assertEqual(yamllint.rules.get(yamllint.rules.braces.ID), + yamllint.rules.braces) + + def test_get_rule_does_not_exist(self): + with self.assertRaises(ValueError): + yamllint.rules.get('DOESNT_EXIST') + + def test_get_default_rule_with_plugins(self): + with mock.patch.dict(yamllint.rules._EXTERNAL_RULES, example.RULES): + self.assertEqual(yamllint.rules.get(yamllint.rules.braces.ID), + yamllint.rules.braces) + + def test_get_plugin_rules(self): + plugin_rule_id = example.forbid_comments.ID + plugin_rule_mod = example.forbid_comments + + with mock.patch.dict(yamllint.rules._EXTERNAL_RULES, example.RULES): + self.assertEqual(yamllint.rules.get(plugin_rule_id), + plugin_rule_mod) + + def test_get_rule_does_not_exist_with_plugins(self): + with mock.patch.dict(yamllint.rules._EXTERNAL_RULES, example.RULES): + with self.assertRaises(ValueError): + yamllint.rules.get('DOESNT_EXIST') + + +@unittest.skipIf(not mock, "unittest.mock is not available") +class PluginTestCase(RuleTestCase): + def check(self, source, conf, **kwargs): + with mock.patch.dict(yamllint.rules._EXTERNAL_RULES, example.RULES): + super(PluginTestCase, self).check(source, conf, **kwargs) + + +@unittest.skipIf(not mock, 'unittest.mock is not available') +class ForbidCommentPluginTestCase(PluginTestCase): + rule_id = 'forbid-comments' + + def test_plugin_disabled(self): + conf = 'forbid-comments: disable\n' + self.check('---\n' + '# comment\n', conf) + + def test_disabled(self): + conf = ('forbid-comments:\n' + ' forbid: false\n') + self.check('---\n' + '# comment\n', conf) + + def test_enabled(self): + conf = ('forbid-comments:\n' + ' forbid: true\n') + self.check('---\n' + '# comment\n', conf, problem=(2, 1)) + + +@unittest.skipIf(not mock, 'unittest.mock is not available') +class NoFortyTwoPluginTestCase(PluginTestCase): + rule_id = 'no-forty-two' + + def test_disabled(self): + conf = 'no-forty-two: disable' + self.check('---\n' + 'a: 42\n', conf) + + def test_enabled(self): + conf = 'no-forty-two: enable' + self.check('---\n' + 'a: 42\n', conf, problem=(2, 4)) diff --git a/tests/yamllint_plugin_example/README.rst b/tests/yamllint_plugin_example/README.rst new file mode 100644 index 0000000..b69ef87 --- /dev/null +++ b/tests/yamllint_plugin_example/README.rst @@ -0,0 +1,61 @@ +yamllint plugin example +======================= + +This is a yamllint plugin example as a reference, contains the following rules. + +- ``forbid-comments`` to forbid comments +- ``random-failure`` to fail randomly + +To enable thes rules in yamllint, you must add them to your `yamllint config +file `_: + +.. code-block:: yaml + + extends: default + + rules: + forbid-comments: enable + random-failure: enable + +How to develop rule plugins +--------------------------- + +yamllint rule plugins must satisfy the followings. + +#. It must be a Python package installable using pip and distributed under + GPLv3+ same as yamllint. + + How to make a Python package is beyond the scope of this README file. Please + refer to the official guide (`Python Packaging User Guide + `_ ) and related documents. + +#. It must contains the entry point configuration in ``setup.cfg`` or something + similar packaging configuration files, to make it installed and working as a + yamllint plugin like below. (```` is that plugin name and + ```` is a dir where the rule modules exist.) + :: + + [options.entry_points] + yamllint.plugins.rules = + = + +#. It must contain custom yamllint rule modules: + + - Each rule module must define a couple of global variables, ``ID`` and + ``TYPE``. ``ID`` must not conflicts with other rules' IDs. + - Each rule module must define a function named 'check' to test input data + complies with the rule. + - Each rule module may have other global variables. + - ``CONF`` to define its configuration parameters and those types. + - ``DEFAULT`` to provide default values for each configuration parameters. + +#. It must define a global variable ``RULES`` to provide an iterable object, a + tuple or a list for example, of tuples of rule ID and rule modules to + yamllint like this. + :: + + RULES = ( + # (rule module ID, rule module) + (a_custom_rule_module.ID, a_custom_rule_module), + (other_custom_rule_module.ID, other_custom_rule_module), + ) diff --git a/tests/yamllint_plugin_example/__init__.py b/tests/yamllint_plugin_example/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/yamllint_plugin_example/rules/__init__.py b/tests/yamllint_plugin_example/rules/__init__.py new file mode 100644 index 0000000..4441d1e --- /dev/null +++ b/tests/yamllint_plugin_example/rules/__init__.py @@ -0,0 +1,30 @@ +# -*- 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 ( + forbid_comments, no_forty_two, random_failure +) + + +RULES = ( + (forbid_comments.ID, forbid_comments), + (no_forty_two.ID, no_forty_two), + (random_failure.ID, random_failure) +) diff --git a/tests/yamllint_plugin_example/rules/forbid_comments.py b/tests/yamllint_plugin_example/rules/forbid_comments.py new file mode 100644 index 0000000..424dd56 --- /dev/null +++ b/tests/yamllint_plugin_example/rules/forbid_comments.py @@ -0,0 +1,61 @@ +# +# 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 forbid comments. + +.. rubric:: Options + +* Use ``forbid`` to control comments. Set to ``true`` to forbid comments + completely. + +.. rubric:: Examples + +#. With ``forbid-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: + forbid-comments: + forbid: False + +""" +from yamllint.linter import LintProblem + + +ID = 'forbid-comments' +TYPE = 'comment' +CONF = {'forbid': bool} +DEFAULT = {'forbid': False} + + +def check(conf, comment): + if conf['forbid']: + yield LintProblem(comment.line_no, comment.column_no, + 'forbidden comment') diff --git a/tests/yamllint_plugin_example/rules/no_forty_two.py b/tests/yamllint_plugin_example/rules/no_forty_two.py new file mode 100644 index 0000000..63b75ff --- /dev/null +++ b/tests/yamllint_plugin_example/rules/no_forty_two.py @@ -0,0 +1,49 @@ +# +# 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 forbid 42 in any values. + +.. rubric:: Examples + +#. With ``no-forty-two: {}`` + + the following code snippet would **PASS**: + :: + + the_answer: 1 + + the following code snippet would **FAIL**: + :: + + the_answer: 42 +""" +import yaml + +from yamllint.linter import LintProblem + + +ID = 'no-forty-two' +TYPE = 'token' + + +def check(conf, token, prev, next, nextnext, context): + if (isinstance(token, yaml.ScalarToken) and + isinstance(prev, yaml.ValueToken) and + token.value == '42'): + yield LintProblem(token.start_mark.line + 1, + token.start_mark.column + 1, + '42 is forbidden value') diff --git a/tests/yamllint_plugin_example/rules/random_failure.py b/tests/yamllint_plugin_example/rules/random_failure.py new file mode 100644 index 0000000..4032a80 --- /dev/null +++ b/tests/yamllint_plugin_example/rules/random_failure.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2020 Adrien Vergé +# +# 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 random + +from yamllint.linter import LintProblem + +ID = 'random-failure' +TYPE = 'token' + + +def check(conf, token, prev, next, nextnext, context): + if random.random() > 0.9: + yield LintProblem(token.start_mark.line + 1, + token.start_mark.column + 1, + 'random failure') diff --git a/tests/yamllint_plugin_example/setup.cfg b/tests/yamllint_plugin_example/setup.cfg new file mode 100644 index 0000000..1ad9e72 --- /dev/null +++ b/tests/yamllint_plugin_example/setup.cfg @@ -0,0 +1,11 @@ +[metadata] +name = yamllint_plugin_example +version = 1.0.0 + +[options] +packages = find: +install_requires = yamllint + +[options.entry_points] +yamllint.plugins.rules = + example = rules diff --git a/tests/yamllint_plugin_example/setup.py b/tests/yamllint_plugin_example/setup.py new file mode 100644 index 0000000..a4f49f9 --- /dev/null +++ b/tests/yamllint_plugin_example/setup.py @@ -0,0 +1,2 @@ +import setuptools +setuptools.setup() diff --git a/yamllint/plugins.py b/yamllint/plugins.py new file mode 100644 index 0000000..90dd147 --- /dev/null +++ b/yamllint/plugins.py @@ -0,0 +1,60 @@ +# -*- 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(): + """Load custom lint rule plugins.""" + rule_ids = set() + for entry in pkg_resources.iter_entry_points(PACKAGE_GROUP): + try: + rules = entry.load() + for rule_id, rule_mod in rules.RULES: + 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()) diff --git a/yamllint/rules/__init__.py b/yamllint/rules/__init__.py index a084d6e..84d07f5 100644 --- a/yamllint/rules/__init__.py +++ b/yamllint/rules/__init__.py @@ -14,6 +14,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +import yamllint.plugins from yamllint.rules import ( braces, brackets, @@ -62,9 +63,14 @@ _RULES = { truthy.ID: truthy, } +_EXTERNAL_RULES = yamllint.plugins.get_plugin_rules_map() -def get(id): - if id not in _RULES: - raise ValueError('no such rule: "%s"' % id) - return _RULES[id] +def get(rule_id): + if rule_id in _RULES: + return _RULES[rule_id] + + if rule_id in _EXTERNAL_RULES: + return _EXTERNAL_RULES[rule_id] + + raise ValueError('no such rule: "%s"' % rule_id)