summaryrefslogtreecommitdiffstats
path: root/gitlint/rule_finder.py
blob: 2b8b2937cc1b6a83d24136ee2515d91f69ede0a0 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
import fnmatch
import inspect
import os
import sys
import importlib

from gitlint import rules, options
from gitlint.utils import ustr


def find_rule_classes(extra_path):
    """
    Searches a given directory or python module for rule classes. This is done by
    adding the directory path to the python path, importing the modules and then finding
    any Rule class in those modules.

    :param extra_path: absolute directory or file path to search for rule classes
    :return: The list of rule classes that are found in the given directory or module
    """

    files = []
    modules = []

    if os.path.isfile(extra_path):
        files = [os.path.basename(extra_path)]
        directory = os.path.dirname(extra_path)
    elif os.path.isdir(extra_path):
        files = os.listdir(extra_path)
        directory = extra_path
    else:
        raise rules.UserRuleError(u"Invalid extra-path: {0}".format(extra_path))

    # Filter out files that are not python modules
    for filename in files:
        if fnmatch.fnmatch(filename, '*.py'):
            # We have to treat __init__ files a bit special: add the parent dir instead of the filename, and also
            # add their parent dir to the sys.path (this fixes import issues with pypy2).
            if filename == "__init__.py":
                modules.append(os.path.basename(directory))
                sys.path.append(os.path.dirname(directory))
            else:
                modules.append(os.path.splitext(filename)[0])

    # No need to continue if there are no modules specified
    if not modules:
        return []

    # Append the extra rules path to python path so that we can import them
    sys.path.append(directory)

    # Find all the rule classes in the found python files
    rule_classes = []
    for module in modules:
        # Import the module
        try:
            importlib.import_module(module)

        except Exception as e:
            raise rules.UserRuleError(u"Error while importing extra-path module '{0}': {1}".format(module, ustr(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
        # 2) is the parent path the current module. If not, we are dealing with an imported class, skip
        # 3) is it a subclass of rule
        rule_classes.extend([clazz for _, clazz in inspect.getmembers(sys.modules[module])
                             if
                             inspect.isclass(clazz) and  # check isclass to ensure clazz.__module__ exists
                             clazz.__module__ == module and  # ignore imported classes
                             (issubclass(clazz, rules.LineRule) or issubclass(clazz, rules.CommitRule))])

        # validate that the rule classes are valid user-defined rules
        for rule_class in rule_classes:
            assert_valid_rule_class(rule_class)

    return rule_classes


def assert_valid_rule_class(clazz, rule_type="User-defined"):
    """
    Asserts that a given rule clazz is valid by checking a number of its properties:
     - 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 of gitlint Options.
     - 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.
     - LineRule classes must have a target class attributes that is set to either
       CommitMessageTitle or CommitMessageBody.
     - Rule id's cannot start with R, T, B or M as these rule ids are reserved for gitlint itself.
    """

    # Rules must extend from LineRule or CommitRule
    if not (issubclass(clazz, rules.LineRule) or issubclass(clazz, rules.CommitRule)):
        msg = u"{0} rule class '{1}' must extend from {2}.{3} or {2}.{4}"
        raise rules.UserRuleError(msg.format(rule_type, clazz.__name__, rules.CommitRule.__module__,
                                             rules.LineRule.__name__, rules.CommitRule.__name__))

    # Rules must have an id attribute
    if not hasattr(clazz, 'id') or clazz.id is None or not clazz.id:
        msg = u"{0} rule class '{1}' must have an 'id' attribute"
        raise rules.UserRuleError(msg.format(rule_type, clazz.__name__))

    # Rule id's cannot start with gitlint reserved letters
    if clazz.id[0].upper() in ['R', 'T', 'B', 'M']:
        msg = u"The id '{1}' of '{0}' is invalid. Gitlint reserves ids starting with R,T,B,M"
        raise rules.UserRuleError(msg.format(clazz.__name__, clazz.id[0]))

    # Rules must have a name attribute
    if not hasattr(clazz, 'name') or clazz.name is None or not clazz.name:
        msg = u"{0} rule class '{1}' must have a 'name' attribute"
        raise rules.UserRuleError(msg.format(rule_type, clazz.__name__))

    # if set, options_spec must be a list of RuleOption
    if not isinstance(clazz.options_spec, list):
        msg = u"The options_spec attribute of {0} rule class '{1}' must be a list of {2}.{3}"
        raise rules.UserRuleError(msg.format(rule_type.lower(), clazz.__name__,
                                             options.RuleOption.__module__, options.RuleOption.__name__))

    # check that all items in options_spec are actual gitlint options
    for option in clazz.options_spec:
        if not isinstance(option, options.RuleOption):
            msg = u"The options_spec attribute of {0} rule class '{1}' must be a list of {2}.{3}"
            raise rules.UserRuleError(msg.format(rule_type.lower(), clazz.__name__,
                                                 options.RuleOption.__module__, options.RuleOption.__name__))

    # Rules must have a validate method. We use isroutine() as it's both python 2 and 3 compatible.
    # For more info see http://stackoverflow.com/a/17019998/381010
    if not hasattr(clazz, 'validate') or not inspect.isroutine(clazz.validate):
        msg = u"{0} rule class '{1}' must have a 'validate' method"
        raise rules.UserRuleError(msg.format(rule_type, clazz.__name__))

    # LineRules must have a valid target: rules.CommitMessageTitle or rules.CommitMessageBody
    if issubclass(clazz, rules.LineRule):
        if clazz.target not in [rules.CommitMessageTitle, rules.CommitMessageBody]:
            msg = u"The target attribute of the {0} LineRule class '{1}' must be either {2}.{3} or {2}.{4}"
            msg = msg.format(rule_type.lower(), clazz.__name__, rules.CommitMessageTitle.__module__,
                             rules.CommitMessageTitle.__name__, rules.CommitMessageBody.__name__)
            raise rules.UserRuleError(msg)