diff options
Diffstat (limited to 'gitlint-core/gitlint/config.py')
-rw-r--r-- | gitlint-core/gitlint/config.py | 561 |
1 files changed, 561 insertions, 0 deletions
diff --git a/gitlint-core/gitlint/config.py b/gitlint-core/gitlint/config.py new file mode 100644 index 0000000..4b38d90 --- /dev/null +++ b/gitlint-core/gitlint/config.py @@ -0,0 +1,561 @@ +import copy +import os +import re +import shutil +from collections import OrderedDict +from configparser import ConfigParser +from configparser import Error as ConfigParserError + +from gitlint import ( + options, + rule_finder, + rules, +) +from gitlint.contrib import rules as contrib_rules +from gitlint.exception import GitlintError +from gitlint.utils import FILE_ENCODING + + +def handle_option_error(func): + """Decorator that calls given method/function and handles any RuleOptionError gracefully by converting it to a + LintConfigError.""" + + def wrapped(*args): + try: + return func(*args) + except options.RuleOptionError as e: + raise LintConfigError(str(e)) from e + + return wrapped + + +class LintConfigError(GitlintError): + pass + + +class LintConfig: + """Class representing gitlint configuration. + Contains active config as well as number of methods to easily get/set the config. + """ + + # Default tuple of rule classes (tuple because immutable). + default_rule_classes = ( + rules.IgnoreByTitle, + rules.IgnoreByBody, + rules.IgnoreBodyLines, + rules.IgnoreByAuthorName, + rules.TitleMaxLength, + rules.TitleTrailingWhitespace, + rules.TitleLeadingWhitespace, + rules.TitleTrailingPunctuation, + rules.TitleHardTab, + rules.TitleMustNotContainWord, + rules.TitleRegexMatches, + rules.TitleMinLength, + rules.BodyMaxLineLength, + rules.BodyMinLength, + rules.BodyMissing, + rules.BodyTrailingWhitespace, + rules.BodyHardTab, + rules.BodyFirstLineEmpty, + rules.BodyChangedFileMention, + rules.BodyRegexMatches, + rules.AuthorValidEmail, + ) + + def __init__(self): + self.rules = RuleCollection(self.default_rule_classes) + self._verbosity = options.IntOption("verbosity", 3, "Verbosity") + self._ignore_merge_commits = options.BoolOption("ignore-merge-commits", True, "Ignore merge commits") + self._ignore_fixup_commits = options.BoolOption("ignore-fixup-commits", True, "Ignore fixup commits") + self._ignore_fixup_amend_commits = options.BoolOption( + "ignore-fixup-amend-commits", True, "Ignore fixup amend commits" + ) + self._ignore_squash_commits = options.BoolOption("ignore-squash-commits", True, "Ignore squash commits") + self._ignore_revert_commits = options.BoolOption("ignore-revert-commits", True, "Ignore revert commits") + self._debug = options.BoolOption("debug", False, "Enable debug mode") + self._extra_path = None + target_description = "Path of the target git repository (default=current working directory)" + self._target = options.PathOption("target", os.path.realpath(os.getcwd()), target_description) + self._ignore = options.ListOption("ignore", [], "List of rule-ids to ignore") + self._contrib = options.ListOption("contrib", [], "List of contrib-rules to enable") + self._config_path = None + ignore_stdin_description = "Ignore any stdin data. Useful for running in CI server." + self._ignore_stdin = options.BoolOption("ignore-stdin", False, ignore_stdin_description) + self._staged = options.BoolOption("staged", False, "Read staged commit meta-info from the local repository.") + self._fail_without_commits = options.BoolOption( + "fail-without-commits", False, "Hard fail when the target commit range is empty" + ) + self._regex_style_search = options.BoolOption( + "regex-style-search", False, "Use `search` instead of `match` semantics for regex rules" + ) + + @property + def target(self): + return self._target.value if self._target else None + + @target.setter + @handle_option_error + def target(self, value): + return self._target.set(value) + + @property + def verbosity(self): + return self._verbosity.value + + @verbosity.setter + @handle_option_error + def verbosity(self, value): + self._verbosity.set(value) + if self.verbosity < 0 or self.verbosity > 3: # noqa: PLR2004 (Magic value used in comparison) + raise LintConfigError("Option 'verbosity' must be set between 0 and 3") + + @property + def ignore_merge_commits(self): + return self._ignore_merge_commits.value + + @ignore_merge_commits.setter + @handle_option_error + def ignore_merge_commits(self, value): + return self._ignore_merge_commits.set(value) + + @property + def ignore_fixup_commits(self): + return self._ignore_fixup_commits.value + + @ignore_fixup_commits.setter + @handle_option_error + def ignore_fixup_commits(self, value): + return self._ignore_fixup_commits.set(value) + + @property + def ignore_fixup_amend_commits(self): + return self._ignore_fixup_amend_commits.value + + @ignore_fixup_amend_commits.setter + @handle_option_error + def ignore_fixup_amend_commits(self, value): + return self._ignore_fixup_amend_commits.set(value) + + @property + def ignore_squash_commits(self): + return self._ignore_squash_commits.value + + @ignore_squash_commits.setter + @handle_option_error + def ignore_squash_commits(self, value): + return self._ignore_squash_commits.set(value) + + @property + def ignore_revert_commits(self): + return self._ignore_revert_commits.value + + @ignore_revert_commits.setter + @handle_option_error + def ignore_revert_commits(self, value): + return self._ignore_revert_commits.set(value) + + @property + def debug(self): + return self._debug.value + + @debug.setter + @handle_option_error + def debug(self, value): + return self._debug.set(value) + + @property + def ignore(self): + return self._ignore.value + + @ignore.setter + def ignore(self, value): + if value == "all": + value = [rule.id for rule in self.rules] + return self._ignore.set(value) + + @property + def ignore_stdin(self): + return self._ignore_stdin.value + + @ignore_stdin.setter + @handle_option_error + def ignore_stdin(self, value): + return self._ignore_stdin.set(value) + + @property + def staged(self): + return self._staged.value + + @staged.setter + @handle_option_error + def staged(self, value): + return self._staged.set(value) + + @property + def fail_without_commits(self): + return self._fail_without_commits.value + + @fail_without_commits.setter + @handle_option_error + def fail_without_commits(self, value): + return self._fail_without_commits.set(value) + + @property + def regex_style_search(self): + return self._regex_style_search.value + + @regex_style_search.setter + @handle_option_error + def regex_style_search(self, value): + return self._regex_style_search.set(value) + + @property + def extra_path(self): + return self._extra_path.value if self._extra_path else None + + @extra_path.setter + def extra_path(self, value): + try: + if self.extra_path: + self._extra_path.set(value) + else: + self._extra_path = options.PathOption( + "extra-path", value, "Path to a directory or module with extra user-defined rules", type="both" + ) + + # Make sure we unload any previously loaded extra-path rules + self.rules.delete_rules_by_attr("is_user_defined", True) + + # Find rules in the new extra-path and add them to the existing rules + rule_classes = rule_finder.find_rule_classes(self.extra_path) + self.rules.add_rules(rule_classes, {"is_user_defined": True}) + + except (options.RuleOptionError, rules.UserRuleError) as e: + raise LintConfigError(str(e)) from e + + @property + def contrib(self): + return self._contrib.value + + @contrib.setter + def contrib(self, value): + try: + self._contrib.set(value) + + # Make sure we unload any previously loaded contrib rules when re-setting the value + self.rules.delete_rules_by_attr("is_contrib", True) + + # Load all classes from the contrib directory + contrib_dir_path = os.path.dirname(os.path.realpath(contrib_rules.__file__)) + rule_classes = rule_finder.find_rule_classes(contrib_dir_path) + + # For each specified contrib rule, check whether it exists among the contrib classes + for rule_id_or_name in self.contrib: + rule_class = next((rc for rc in rule_classes if rule_id_or_name in (rc.id, rc.name)), False) + + # If contrib rule exists, instantiate it and add it to the rules list + if rule_class: + self.rules.add_rule(rule_class, rule_class.id, {"is_contrib": True}) + else: + raise LintConfigError(f"No contrib rule with id or name '{rule_id_or_name}' found.") + + except (options.RuleOptionError, rules.UserRuleError) as e: + raise LintConfigError(str(e)) from e + + def _get_option(self, rule_name_or_id, option_name): + rule = self.rules.find_rule(rule_name_or_id) + if not rule: + raise LintConfigError(f"No such rule '{rule_name_or_id}'") + + option = rule.options.get(option_name) + if not option: + raise LintConfigError(f"Rule '{rule_name_or_id}' has no option '{option_name}'") + + return option + + def get_rule_option(self, rule_name_or_id, option_name): + """Returns the value of a given option for a given rule. LintConfigErrors will be raised if the + rule or option don't exist.""" + option = self._get_option(rule_name_or_id, option_name) + return option.value + + def set_rule_option(self, rule_name_or_id, option_name, option_value): + """Attempts to set a given value for a given option for a given rule. + LintConfigErrors will be raised if the rule or option don't exist or if the value is invalid.""" + option = self._get_option(rule_name_or_id, option_name) + try: + option.set(option_value) + except options.RuleOptionError as e: + msg = f"'{option_value}' is not a valid value for option '{rule_name_or_id}.{option_name}'. {e}." + raise LintConfigError(msg) from e + + def set_general_option(self, option_name, option_value): + attr_name = option_name.replace("-", "_") + # only allow setting general options that exist and don't start with an underscore + if not hasattr(self, attr_name) or attr_name[0] == "_": + raise LintConfigError(f"'{option_name}' is not a valid gitlint option") + + # else + setattr(self, attr_name, option_value) + + def __eq__(self, other): + return ( + isinstance(other, LintConfig) + and self.rules == other.rules + and self.verbosity == other.verbosity + and self.target == other.target + and self.extra_path == other.extra_path + and self.contrib == other.contrib + and self.ignore_merge_commits == other.ignore_merge_commits + and self.ignore_fixup_commits == other.ignore_fixup_commits + and self.ignore_fixup_amend_commits == other.ignore_fixup_amend_commits + and self.ignore_squash_commits == other.ignore_squash_commits + and self.ignore_revert_commits == other.ignore_revert_commits + and self.ignore_stdin == other.ignore_stdin + and self.staged == other.staged + and self.fail_without_commits == other.fail_without_commits + and self.regex_style_search == other.regex_style_search + and self.debug == other.debug + and self.ignore == other.ignore + and self._config_path == other._config_path + ) + + def __str__(self): + # config-path is not a user exposed variable, so don't print it under the general section + return ( + f"config-path: {self._config_path}\n" + "[GENERAL]\n" + f"extra-path: {self.extra_path}\n" + f"contrib: {self.contrib}\n" + f"ignore: {','.join(self.ignore)}\n" + f"ignore-merge-commits: {self.ignore_merge_commits}\n" + f"ignore-fixup-commits: {self.ignore_fixup_commits}\n" + f"ignore-fixup-amend-commits: {self.ignore_fixup_amend_commits}\n" + f"ignore-squash-commits: {self.ignore_squash_commits}\n" + f"ignore-revert-commits: {self.ignore_revert_commits}\n" + f"ignore-stdin: {self.ignore_stdin}\n" + f"staged: {self.staged}\n" + f"fail-without-commits: {self.fail_without_commits}\n" + f"regex-style-search: {self.regex_style_search}\n" + f"verbosity: {self.verbosity}\n" + f"debug: {self.debug}\n" + f"target: {self.target}\n" + f"[RULES]\n{self.rules}" + ) + + +class RuleCollection: + """Class representing an ordered list of rules. Methods are provided to easily retrieve, add or delete rules.""" + + def __init__(self, rule_classes=None, rule_attrs=None): + # Use an ordered dict so that the order in which rules are applied is always the same + self._rules = OrderedDict() + if rule_classes: + self.add_rules(rule_classes, rule_attrs) + + def find_rule(self, rule_id_or_name): + rule = self._rules.get(rule_id_or_name) + # if not found, try finding rule by name + if not rule: + rule = next((rule for rule in self._rules.values() if rule.name == rule_id_or_name), None) + return rule + + def add_rule(self, rule_class, rule_id, rule_attrs=None): + """Instantiates and adds a rule to RuleCollection. + Note: There can be multiple instantiations of the same rule_class in the RuleCollection, as long as the + rule_id is unique. + :param rule_class python class representing the rule + :param rule_id unique identifier for the rule. If not unique, it will + overwrite the existing rule with that id + :param rule_attrs dictionary of attributes to set on the instantiated rule obj + """ + rule_obj = rule_class() + rule_obj.id = rule_id + if rule_attrs: + for key, val in rule_attrs.items(): + setattr(rule_obj, key, val) + self._rules[rule_obj.id] = rule_obj + + def add_rules(self, rule_classes, rule_attrs=None): + """Convenience method to add multiple rules at once based on a list of rule classes.""" + for rule_class in rule_classes: + self.add_rule(rule_class, rule_class.id, rule_attrs) + + def delete_rules_by_attr(self, attr_name, attr_val): + """Deletes all rules from the collection that match a given attribute name and value""" + # Create a new list based on _rules.values() because in python 3, values() is a ValuesView as opposed to a list + # This means you can't modify the ValueView while iterating over it. + for rule in list(self._rules.values()): + if hasattr(rule, attr_name) and (getattr(rule, attr_name) == attr_val): + del self._rules[rule.id] + + def __iter__(self): + yield from self._rules.values() + + def __eq__(self, other): + return isinstance(other, RuleCollection) and self._rules == other._rules + + def __len__(self): + return len(self._rules) + + def __str__(self): + return_str = "" + for rule in self._rules.values(): + return_str += f" {rule.id}: {rule.name}\n" + for option_name, option_value in sorted(rule.options.items()): + if option_value.value is None: + option_val_repr = None + elif isinstance(option_value.value, list): + option_val_repr = ",".join(option_value.value) + elif isinstance(option_value, options.RegexOption): + option_val_repr = option_value.value.pattern + else: + option_val_repr = option_value.value + return_str += f" {option_name}={option_val_repr}\n" + return return_str + + +class LintConfigBuilder: + """Factory class that can build gitlint config. + This is primarily useful to deal with complex configuration scenarios where configuration can be set and overridden + from various sources (typically according to certain precedence rules) before the actual config should be + normalized, validated and build. Example usage can be found in gitlint.cli. + """ + + RULE_QUALIFIER_SYMBOL = ":" + + def __init__(self): + self._config_blueprint = OrderedDict() + self._config_path = None + + def set_option(self, section, option_name, option_value): + if section not in self._config_blueprint: + self._config_blueprint[section] = OrderedDict() + self._config_blueprint[section][option_name] = option_value + + def set_config_from_commit(self, commit): + """Given a git commit, applies config specified in the commit message. + Supported: + - gitlint-ignore: all + """ + for line in commit.message.body: + pattern = re.compile(r"^gitlint-ignore:\s*(.*)") + matches = pattern.match(line) + if matches and len(matches.groups()) == 1: + self.set_option("general", "ignore", matches.group(1)) + + def set_config_from_string_list(self, config_options): + """Given a list of config options of the form "<rule>.<option>=<value>", parses out the correct rule and option + and sets the value accordingly in this factory object.""" + for config_option in config_options: + try: + config_name, option_value = config_option.split("=", 1) + if not option_value: + raise ValueError() + rule_name, option_name = config_name.split(".", 1) + self.set_option(rule_name, option_name, option_value) + except ValueError as e: # raised if the config string is invalid + raise LintConfigError( + f"'{config_option}' is an invalid configuration option. Use '<rule>.<option>=<value>'" + ) from e + + def set_from_config_file(self, filename): + """Loads lint config from an ini-style config file""" + if not os.path.exists(filename): + raise LintConfigError(f"Invalid file path: {filename}") + self._config_path = os.path.realpath(filename) + try: + parser = ConfigParser() + + with open(filename, encoding=FILE_ENCODING) as config_file: + parser.read_file(config_file, filename) + + for section_name in parser.sections(): + for option_name, option_value in parser.items(section_name): + self.set_option(section_name, option_name, str(option_value)) + + except ConfigParserError as e: + raise LintConfigError(str(e)) from e + + def _add_named_rule(self, config, qualified_rule_name): + """Adds a Named Rule to a given LintConfig object. + IMPORTANT: This method does *NOT* overwrite existing Named Rules with the same canonical id. + """ + + # Split up named rule in its parts: the name/id that specifies the parent rule, + # And the name of the rule instance itself + rule_name_parts = qualified_rule_name.split(self.RULE_QUALIFIER_SYMBOL, 1) + rule_name = rule_name_parts[1].strip() + parent_rule_specifier = rule_name_parts[0].strip() + + # assert that the rule name is valid: + # - not empty + # - no whitespace or colons + if rule_name == "" or bool(re.search("\\s|:", rule_name, re.UNICODE)): + msg = f"The rule-name part in '{qualified_rule_name}' cannot contain whitespace, colons or be empty" + raise LintConfigError(msg) + + # find parent rule + parent_rule = config.rules.find_rule(parent_rule_specifier) + if not parent_rule: + msg = f"No such rule '{parent_rule_specifier}' (named rule: '{qualified_rule_name}')" + raise LintConfigError(msg) + + # Determine canonical id and name by recombining the parent id/name and instance name parts. + canonical_id = parent_rule.__class__.id + self.RULE_QUALIFIER_SYMBOL + rule_name + canonical_name = parent_rule.__class__.name + self.RULE_QUALIFIER_SYMBOL + rule_name + + # Add the rule to the collection of rules if it's not there already + if not config.rules.find_rule(canonical_id): + config.rules.add_rule(parent_rule.__class__, canonical_id, {"is_named": True, "name": canonical_name}) + + return canonical_id + + def build(self, config=None): + """Build a real LintConfig object by normalizing and validating the options that were previously set on this + factory.""" + # If we are passed a config object, then rebuild that object instead of building a new lintconfig object from + # scratch + if not config: + config = LintConfig() + + config._config_path = self._config_path + + # Set general options first as this might change the behavior or validity of the other options + general_section = self._config_blueprint.get("general") + if general_section: + for option_name, option_value in general_section.items(): + config.set_general_option(option_name, option_value) + + for section_name, section_dict in self._config_blueprint.items(): + for option_name, option_value in section_dict.items(): + qualified_section_name = section_name + # Skip over the general section, as we've already done that above + if qualified_section_name != "general": + # If the section name contains a colon (:), then this section is defining a Named Rule + # Which means we need to instantiate that Named Rule in the config. + if self.RULE_QUALIFIER_SYMBOL in section_name: + qualified_section_name = self._add_named_rule(config, qualified_section_name) + + config.set_rule_option(qualified_section_name, option_name, option_value) + + return config + + def clone(self): + """Creates an exact copy of a LintConfigBuilder.""" + builder = LintConfigBuilder() + builder._config_blueprint = copy.deepcopy(self._config_blueprint) + builder._config_path = self._config_path + return builder + + +GITLINT_CONFIG_TEMPLATE_SRC_PATH = os.path.join(os.path.dirname(os.path.realpath(__file__)), "files/gitlint") + + +class LintConfigGenerator: + @staticmethod + def generate_config(dest): + """Generates a gitlint config file at the given destination location. + Expects that the given ```dest``` points to a valid destination.""" + shutil.copyfile(GITLINT_CONFIG_TEMPLATE_SRC_PATH, dest) |