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.

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 <satoru.satoh@gmail.com>
Co-authored-by: Adrien Vergé
pull/315/head
Satoru SATOH 4 years ago
parent 85ccd625a3
commit b21eb6c0e5

@ -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.

@ -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 <http://www.gnu.org/licenses/>.
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))

@ -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 <https://yamllint.readthedocs.io/en/stable/configuration.html>`_:
.. 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
<https://packaging.python.org/>`_ ) 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. (``<plugin_name>`` is that plugin name and
``<plugin_src_dir>`` is a dir where the rule modules exist.)
::
[options.entry_points]
yamllint.plugins.rules =
<plugin_name> = <plugin_src_dir>
#. 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),
)

@ -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 <http://www.gnu.org/licenses/>.
"""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)
)

@ -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 <http://www.gnu.org/licenses/>.
#
"""
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')

@ -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 <http://www.gnu.org/licenses/>.
#
"""
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')

@ -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 <http://www.gnu.org/licenses/>.
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')

@ -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

@ -0,0 +1,2 @@
import setuptools
setuptools.setup()

@ -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 <http://www.gnu.org/licenses/>.
"""
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())

@ -14,6 +14,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
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)

Loading…
Cancel
Save