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
parent
85ccd625a3
commit
b21eb6c0e5
@ -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())
|
Loading…
Reference in New Issue