summaryrefslogtreecommitdiffstats
path: root/gitlint-core/gitlint/config.py
diff options
context:
space:
mode:
Diffstat (limited to 'gitlint-core/gitlint/config.py')
-rw-r--r--gitlint-core/gitlint/config.py561
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)