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)