summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/user_defined_rules.md44
-rw-r--r--examples/gitlint3
-rw-r--r--gitlint/files/gitlint6
-rw-r--r--gitlint/options.py3
-rw-r--r--gitlint/tests/samples/user_rules/bogus-file.txt2
-rw-r--r--gitlint/tests/samples/user_rules/import_exception/invalid_python.py3
-rw-r--r--gitlint/tests/samples/user_rules/my_commit_rules.foo16
-rw-r--r--gitlint/tests/samples/user_rules/my_commit_rules.py9
-rw-r--r--gitlint/tests/test_options.py11
-rw-r--r--gitlint/tests/test_user_rules.py83
-rw-r--r--gitlint/user_rules.py19
-rw-r--r--mkdocs.yml3
-rwxr-xr-xrun_tests.sh2
13 files changed, 194 insertions, 10 deletions
diff --git a/docs/user_defined_rules.md b/docs/user_defined_rules.md
new file mode 100644
index 0000000..7b6d499
--- /dev/null
+++ b/docs/user_defined_rules.md
@@ -0,0 +1,44 @@
+# User Defined Rules
+Gitlint versions 0.8.0 and above support the concept of User Defined rules: the ability for you
+to write your own custom rules that are executed when gitlint is.
+
+This can be done using the ```extra-path``` general option, which can be set using the ```--extra-path```
+commandline flag, by adding it under the ```[general]``` section in your ```.gitlint``` file or using
+one of the other ways to configure gitlint. For more details, please refer to the
+[Configuration](configuration.md) page.
+
+If you want to check whether your rules are discovered, you can use the ```--debug``` flag:
+
+```bash
+$ gitlint --debug
+TODO: CONTINUE
+```
+
+
+## TODO:
+- Document extra_config parameter in the configuration section
+
+## Rule requirements
+
+As long as you stick with simple scenarios that are similar to the sample User Defined rules (see the ```examples``` directory), gitlint
+should be able to discover and execute your custom rules. If you want to do something more exotic however, you might run into some issues.
+
+While the [rule finding source-code](https://github.com/jorisroovers/gitlint/blob/master/gitlint/user_rules.py) is the
+ultimate source of truth, here are some of the requirements that gitlint enforces:
+
+### Extra path requirements ###
+- The ```extra-path``` option must point to a **directory**, not a file
+- The ```extra-path``` directory does **not** need to be a proper python package, i.e. it doesn't require an ```__init__.py``` file.
+- Python files containing user rules must have a ```.py``` extension. Files with a different extension will be ignored.
+- The ```extra-path``` will be searched non-recursively, i.e. all rule classes must be present at the top level ```extra-path``` directory.
+- User rule classes must be defined in the modules that are part of ```extra-path```, rules that are imported from outside the ```extra-path``` will be ignored.
+
+### Rule class requirements ###
+
+- Rules *must* extend from ```LineRule``` or ```CommitRule```
+- Rule classes *must* have ```id``` and ```name``` string attributes. The ```options_spec``` is optional, but if set, it *must* be a list.
+- Rule classes *must* have a ```validate``` method. In case of a ```CommitRule```, ```validate``` *must* take a single ```commit``` parameter.
+ In case of ```LineRule```, ```validate``` must take ```line``` and ```commit``` as first and second parameters.
+- User Rule id's *cannot* be of the form ```R[0-9]+```, ```T[0-9]+```, ```B[0-9]+``` or ```M[0-9]+``` as these rule ids are reserved for gitlint itself.
+- Rule *should* have a unique id as only one rule can exist with a given id. While gitlint does not enforce this, the rule that will
+ actually be chosen will be system specific.
diff --git a/examples/gitlint b/examples/gitlint
index aac5c0b..612b125 100644
--- a/examples/gitlint
+++ b/examples/gitlint
@@ -8,6 +8,9 @@ ignore-merge-commits=true
# Enable debug mode (prints more output). Disabled by default
debug = true
+# Set the extra path where gitlint will search for user defined rules (uncomment to test)
+# extra-path=examples/
+
[title-max-length]
line-length=20
diff --git a/gitlint/files/gitlint b/gitlint/files/gitlint
index 1a27092..776418c 100644
--- a/gitlint/files/gitlint
+++ b/gitlint/files/gitlint
@@ -1,14 +1,16 @@
# All these sections are optional, edit this file as you like.
# [general]
# ignore=title-trailing-punctuation, T3
-# verbosity should be a value between 1 and 3, the commandline -v flags take precedence over
-# this
+# verbosity should be a value between 1 and 3, the commandline -v flags take precedence over this
# verbosity = 2
# By default gitlint will ignore merge commits. Set to 'false' to disable.
# ignore-merge-commits=true
# Enable debug mode (prints more output). Disabled by default.
# debug=true
+# Set the extra path where gitlint will search for user defined rules
+# extra-path=examples/
+
# [title-max-length]
# line-length=80
diff --git a/gitlint/options.py b/gitlint/options.py
index 93e1042..194fc59 100644
--- a/gitlint/options.py
+++ b/gitlint/options.py
@@ -24,6 +24,9 @@ class RuleOption(object):
def __repr__(self):
return self.__str__() # pragma: no cover
+ def __eq__(self, other):
+ return self.name == other.name and self.description == other.description and self.value == other.value
+
class StrOption(RuleOption):
def set(self, value):
diff --git a/gitlint/tests/samples/user_rules/bogus-file.txt b/gitlint/tests/samples/user_rules/bogus-file.txt
new file mode 100644
index 0000000..2a56650
--- /dev/null
+++ b/gitlint/tests/samples/user_rules/bogus-file.txt
@@ -0,0 +1,2 @@
+This is just a bogus file.
+This file being here is part of the test: gitlint should ignore it. \ No newline at end of file
diff --git a/gitlint/tests/samples/user_rules/import_exception/invalid_python.py b/gitlint/tests/samples/user_rules/import_exception/invalid_python.py
new file mode 100644
index 0000000..e75fed3
--- /dev/null
+++ b/gitlint/tests/samples/user_rules/import_exception/invalid_python.py
@@ -0,0 +1,3 @@
+# flake8: noqa
+# This is invalid python code which will cause an import exception
+class MyObject:
diff --git a/gitlint/tests/samples/user_rules/my_commit_rules.foo b/gitlint/tests/samples/user_rules/my_commit_rules.foo
new file mode 100644
index 0000000..605d704
--- /dev/null
+++ b/gitlint/tests/samples/user_rules/my_commit_rules.foo
@@ -0,0 +1,16 @@
+# This rule is ignored because it doesn't have a .py extension
+from gitlint.rules import CommitRule, RuleViolation
+from gitlint.options import IntOption
+
+
+class MyUserCommitRule2(CommitRule):
+ name = "my-user-commit-rule2"
+ id = "TUC2"
+ options_spec = [IntOption('violation-count', 0, "Number of violations to return")]
+
+ def validate(self, _commit):
+ violations = []
+ for i in range(1, self.options['violation-count'].value + 1):
+ violations.append(RuleViolation(self.id, "Commit violation %d" % i, "Content %d" % i, i))
+
+ return violations
diff --git a/gitlint/tests/samples/user_rules/my_commit_rules.py b/gitlint/tests/samples/user_rules/my_commit_rules.py
index 3752dd5..6d6509e 100644
--- a/gitlint/tests/samples/user_rules/my_commit_rules.py
+++ b/gitlint/tests/samples/user_rules/my_commit_rules.py
@@ -13,3 +13,12 @@ class MyUserCommitRule(CommitRule):
violations.append(RuleViolation(self.id, "Commit violation %d" % i, "Content %d" % i, i))
return violations
+
+
+# The below code is present so that we can test that we actually ignore it
+
+def func_should_be_ignored():
+ pass
+
+
+global_variable_should_be_ignored = True
diff --git a/gitlint/tests/test_options.py b/gitlint/tests/test_options.py
index 4f9f59e..5b3fc9a 100644
--- a/gitlint/tests/test_options.py
+++ b/gitlint/tests/test_options.py
@@ -6,6 +6,17 @@ from gitlint.options import IntOption, BoolOption, StrOption, ListOption, Direct
class RuleOptionTests(BaseTestCase):
+ def test_option_equals(self):
+ # 2 options are equal if their name, value and description match
+ option1 = IntOption("test-option", 123, "Test Description")
+ option2 = IntOption("test-option", 123, "Test Description")
+ self.assertEqual(option1, option2)
+
+ # Not equal: name, description, value are different
+ self.assertNotEqual(option1, IntOption("test-option1", 123, "Test Description"))
+ self.assertNotEqual(option1, IntOption("test-option", 1234, "Test Description"))
+ self.assertNotEqual(option1, IntOption("test-option", 123, "Test Description2"))
+
def test_int_option(self):
# normal behavior
option = IntOption("test-name", 123, "Test Description")
diff --git a/gitlint/tests/test_user_rules.py b/gitlint/tests/test_user_rules.py
index e69de29..756b72b 100644
--- a/gitlint/tests/test_user_rules.py
+++ b/gitlint/tests/test_user_rules.py
@@ -0,0 +1,83 @@
+import sys
+
+from gitlint.tests.base import BaseTestCase
+from gitlint.user_rules import find_rule_classes, assert_valid_rule_class, UserRuleError
+
+from gitlint import options, rules
+
+
+class UserRuleTests(BaseTestCase):
+ def test_find_rule_classes(self):
+ # Let's find some user classes!
+ user_rule_path = self.get_sample_path("user_rules")
+ classes = find_rule_classes(user_rule_path)
+
+ # Compare string representations because we can't import MyUserCommitRule here since samples/user_rules is not
+ # a proper python package
+ # Note that the following check effectively asserts that:
+ # - There is only 1 rule recognized and it is MyUserCommitRule
+ # - Other non-python files in the directory are ignored
+ # - Other members of the my_commit_rules module are ignored
+ # (such as func_should_be_ignored, global_variable_should_be_ignored)
+ # - Rules are loaded non-recursively (user_rules/import_exception directory is ignored)
+ self.assertEqual("[<class 'my_commit_rules.MyUserCommitRule'>]", str(classes))
+
+ # Assert that we added the new user_rules directory to the system path and modules
+ self.assertIn(user_rule_path, sys.path)
+ self.assertIn("my_commit_rules", sys.modules)
+
+ # Do some basic asserts on our user rule
+ self.assertEqual(classes[0].id, "TUC1")
+ self.assertEqual(classes[0].name, "my-user-commit-rule")
+ expected_option = options.IntOption('violation-count', 0, "Number of violations to return")
+ self.assertListEqual(classes[0].options_spec, [expected_option])
+ self.assertTrue(hasattr(classes[0], "validate"))
+
+ # Test that we can instantiate the class and can execute run the validate method and that it returns the
+ # expected result
+ rule_class = classes[0]()
+ violations = rule_class.validate("false-commit-object (ignored)")
+ self.assertListEqual(violations, [])
+
+ # Have it return a violation
+ rule_class.options['violation-count'].value = 1
+ violations = rule_class.validate("false-commit-object (ignored)")
+ self.assertListEqual(violations, [rules.RuleViolation("TUC1", "Commit violation 1", "Content 1", 1)])
+
+ def test_empty_user_classes(self):
+ # Test that we don't find rules if we scan a different directory
+ user_rule_path = self.get_sample_path("config")
+ classes = find_rule_classes(user_rule_path)
+ self.assertListEqual(classes, [])
+
+ # Importantly, ensure that the directory is not added to the syspath as this happens only when we actually
+ # find modules
+ self.assertNotIn(user_rule_path, sys.path)
+
+ def test_failed_module_import(self):
+ # test importing a bogus module
+ user_rule_path = self.get_sample_path("user_rules/import_exception")
+ # We don't check the entire error message because that is different based on the python version and underlying
+ # operating system
+ expected_msg = "Error while importing extra-path module 'invalid_python'"
+ with self.assertRaisesRegexp(UserRuleError, expected_msg):
+ find_rule_classes(user_rule_path)
+
+ def test_find_rule_classes_nonexisting_path(self):
+ # When searching an non-existing path, we expect an OSError. That's fine because this case will be caught by
+ # the CLI (you cannot specify a non-existing directory). What we do here is just assert that we indeed
+ # get an OSError (so we guard against regressions).
+ with self.assertRaisesRegexp(OSError, "No such file or directory"):
+ find_rule_classes("foo/bar")
+
+ def test_assert_valid_rule_class(self):
+ class MyRuleClass(rules.Rule):
+ pass
+
+ self.assertTrue(assert_valid_rule_class(MyRuleClass))
+
+ def test_assert_valid_rule_class_negative(self):
+ class MyNormalClass(object):
+ pass
+
+ self.assertFalse(assert_valid_rule_class(MyNormalClass))
diff --git a/gitlint/user_rules.py b/gitlint/user_rules.py
index 2d7865b..85065a2 100644
--- a/gitlint/user_rules.py
+++ b/gitlint/user_rules.py
@@ -7,6 +7,11 @@ import sys
from gitlint import rules
+class UserRuleError(Exception):
+ """ Error used to indicate that an error occurred while trying to load a user rule """
+ pass
+
+
def find_rule_classes(extra_path):
"""
Searches a given directory for rule classes. This is done by finding all python modules in the given directory,
@@ -33,7 +38,10 @@ def find_rule_classes(extra_path):
rule_classes = []
for module in modules:
# Import the module
- importlib.import_module(module)
+ try:
+ importlib.import_module(module)
+ except Exception as e:
+ raise UserRuleError("Error while importing extra-path module '{0}': {1}".format(module, str(e)))
# Find all rule classes in the module. We do this my inspecting all members of the module and checking
# 1) is it a class, if not, skip
@@ -41,15 +49,14 @@ def find_rule_classes(extra_path):
# 3) is it a subclass of rule
rule_classes.extend([clazz for _, clazz in inspect.getmembers(sys.modules[module])
if
- inspect.isclass(clazz) and
- clazz.__module__ == module and
- issubclass(clazz, rules.Rule) and
+ inspect.isclass(clazz) and # check isclass to ensure clazz.__module__ exists
+ clazz.__module__ == module and # ignore imported classes
assert_valid_rule_class(clazz)])
return rule_classes
-def assert_valid_rule_class(_clazz):
+def assert_valid_rule_class(clazz):
# TODO (joris.roovers): checks whether the rule class is valid
# e.g.: it has an id and name and are of type string, default constructor, etc
- return True
+ return issubclass(clazz, rules.Rule)
diff --git a/mkdocs.yml b/mkdocs.yml
index ad873e4..cff7ad1 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -4,8 +4,9 @@ site_url: http://jorisroovers.github.io/gitlint/
repo_url: https://github.com/jorisroovers/gitlint
pages:
- Home: index.md
- - Rules: rules.md
- Configuration: configuration.md
+ - Rules: rules.md
+ - User Defined Rules: user_defined_rules.md
- Contributing: contributing.md
markdown_extensions: [admonition]
diff --git a/run_tests.sh b/run_tests.sh
index 38b2908..7a8f306 100755
--- a/run_tests.sh
+++ b/run_tests.sh
@@ -47,7 +47,7 @@ run_pep8_check(){
# exclude settings files and virtualenvs
FLAKE8_EXCLUDE="*settings.py,*.venv/*.py"
echo "Running flake8..."
- flake8 --ignore=$FLAKE8_IGNORE --max-line-length=120 --exclude=$FLAKE8_EXCLUDE gitlint qa
+ flake8 --ignore=$FLAKE8_IGNORE --max-line-length=120 --exclude=$FLAKE8_EXCLUDE gitlint qa examples
}
run_unit_tests(){