Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

enhancement: add rules plugin support #315

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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