Skip to content

Commit

Permalink
enhancement: add lint rules plugin support
Browse files Browse the repository at this point in the history
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é
  • Loading branch information
ssato committed Apr 29, 2021
1 parent 85ccd62 commit b21eb6c
Show file tree
Hide file tree
Showing 12 changed files with 491 additions and 4 deletions.
12 changes: 12 additions & 0 deletions docs/development.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
166 changes: 166 additions & 0 deletions tests/test_plugins.py
Original file line number Diff line number Diff line change
@@ -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))
61 changes: 61 additions & 0 deletions tests/yamllint_plugin_example/README.rst
Original file line number Diff line number Diff line change
@@ -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),
)
Empty file.
30 changes: 30 additions & 0 deletions tests/yamllint_plugin_example/rules/__init__.py
Original file line number Diff line number Diff line change
@@ -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)
)
61 changes: 61 additions & 0 deletions tests/yamllint_plugin_example/rules/forbid_comments.py
Original file line number Diff line number Diff line change
@@ -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')
49 changes: 49 additions & 0 deletions tests/yamllint_plugin_example/rules/no_forty_two.py
Original file line number Diff line number Diff line change
@@ -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')
Loading

0 comments on commit b21eb6c

Please sign in to comment.