diff options
author | Joris Roovers <joris.roovers@gmail.com> | 2023-04-04 11:53:07 +0200 |
---|---|---|
committer | Joris Roovers <joris.roovers@gmail.com> | 2023-04-04 10:09:38 +0000 |
commit | 10f86a0f364c849189fc11bf581ab72f379bc646 (patch) | |
tree | c929f2d907c823d738a19e434115247e4ba5fa31 | |
parent | 6cbe7986f68a3e5cb336a3eb31f6bd428d469ea8 (diff) |
Dataclasses (#479)
Converts existing classes to dataclasses where applicable.
Relates to #321
-rw-r--r-- | gitlint-core/gitlint/cache.py | 7 | ||||
-rw-r--r-- | gitlint-core/gitlint/cli.py | 23 | ||||
-rw-r--r-- | gitlint-core/gitlint/config.py | 11 | ||||
-rw-r--r-- | gitlint-core/gitlint/deprecation.py | 5 | ||||
-rw-r--r-- | gitlint-core/gitlint/display.py | 7 | ||||
-rw-r--r-- | gitlint-core/gitlint/git.py | 270 | ||||
-rw-r--r-- | gitlint-core/gitlint/lint.py | 12 | ||||
-rw-r--r-- | gitlint-core/gitlint/options.py | 31 | ||||
-rw-r--r-- | gitlint-core/gitlint/rules.py | 50 | ||||
-rw-r--r-- | gitlint-core/gitlint/shell.py | 11 | ||||
-rw-r--r-- | gitlint-core/gitlint/tests/rules/test_rules.py | 17 |
11 files changed, 218 insertions, 226 deletions
diff --git a/gitlint-core/gitlint/cache.py b/gitlint-core/gitlint/cache.py index a3dd0c8..5c20896 100644 --- a/gitlint-core/gitlint/cache.py +++ b/gitlint-core/gitlint/cache.py @@ -1,8 +1,11 @@ +from dataclasses import dataclass, field + + +@dataclass class PropertyCache: """Mixin class providing a simple cache.""" - def __init__(self): - self._cache = {} + _cache: dict = field(init=False, default_factory=dict) def _try_cache(self, cache_key, cache_populate_func): """Tries to get a value from the cache identified by `cache_key`. diff --git a/gitlint-core/gitlint/cli.py b/gitlint-core/gitlint/cli.py index 240b005..82f35ce 100644 --- a/gitlint-core/gitlint/cli.py +++ b/gitlint-core/gitlint/cli.py @@ -4,12 +4,19 @@ import os import platform import stat import sys +from dataclasses import dataclass +from typing import Optional import click import gitlint from gitlint import hooks -from gitlint.config import LintConfigBuilder, LintConfigError, LintConfigGenerator +from gitlint.config import ( + LintConfig, + LintConfigBuilder, + LintConfigError, + LintConfigGenerator, +) from gitlint.deprecation import DEPRECATED_LOG_FORMAT from gitlint.deprecation import LOG as DEPRECATED_LOG from gitlint.exception import GitlintError @@ -230,16 +237,16 @@ def handle_gitlint_error(ctx, exc): ctx.exit(CONFIG_ERROR_CODE) +@dataclass class ContextObj: """Simple class to hold data that is passed between Click commands via the Click context.""" - def __init__(self, config, config_builder, commit_hash, refspec, msg_filename, gitcontext=None): - self.config = config - self.config_builder = config_builder - self.commit_hash = commit_hash - self.refspec = refspec - self.msg_filename = msg_filename - self.gitcontext = gitcontext + config: LintConfig + config_builder: LintConfigBuilder + commit_hash: str + refspec: str + msg_filename: str + gitcontext: Optional[GitContext] = None # fmt: off diff --git a/gitlint-core/gitlint/config.py b/gitlint-core/gitlint/config.py index 4b38d90..4637af2 100644 --- a/gitlint-core/gitlint/config.py +++ b/gitlint-core/gitlint/config.py @@ -5,6 +5,8 @@ import shutil from collections import OrderedDict from configparser import ConfigParser from configparser import Error as ConfigParserError +from dataclasses import dataclass, field +from typing import ClassVar, Optional from gitlint import ( options, @@ -416,6 +418,7 @@ class RuleCollection: return return_str +@dataclass 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 @@ -423,11 +426,9 @@ class LintConfigBuilder: 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 + RULE_QUALIFIER_SYMBOL: ClassVar[str] = ":" + _config_blueprint: OrderedDict = field(init=False, default_factory=OrderedDict) + _config_path: Optional[str] = field(init=False, default=None) def set_option(self, section, option_name, option_value): if section not in self._config_blueprint: diff --git a/gitlint-core/gitlint/deprecation.py b/gitlint-core/gitlint/deprecation.py index b7c2f42..67c7cf9 100644 --- a/gitlint-core/gitlint/deprecation.py +++ b/gitlint-core/gitlint/deprecation.py @@ -1,4 +1,5 @@ import logging +from typing import ClassVar, Optional, Set LOG = logging.getLogger("gitlint.deprecated") DEPRECATED_LOG_FORMAT = "%(levelname)s: %(message)s" @@ -8,10 +9,10 @@ class Deprecation: """Singleton class that handles deprecation warnings and behavior.""" # LintConfig class that is used to determine deprecation behavior - config = None + config: ClassVar[Optional[object]] = None # Set of warning messages that have already been logged, to prevent duplicate warnings - warning_msgs = set() + warning_msgs: ClassVar[Set[str]] = set() @classmethod def get_regex_method(cls, rule, regex_option): diff --git a/gitlint-core/gitlint/display.py b/gitlint-core/gitlint/display.py index 1de8d08..834bca0 100644 --- a/gitlint-core/gitlint/display.py +++ b/gitlint-core/gitlint/display.py @@ -1,11 +1,14 @@ +from dataclasses import dataclass from sys import stderr, stdout +from gitlint.config import LintConfig + +@dataclass class Display: """Utility class to print stuff to an output stream (stdout by default) based on the config's verbosity""" - def __init__(self, lint_config): - self.config = lint_config + config: LintConfig def _output(self, message, verbosity, exact, stream): """Output a message if the config's verbosity is >= to the given verbosity. If exact == True, the message diff --git a/gitlint-core/gitlint/git.py b/gitlint-core/gitlint/git.py index 6612a7d..9c08d2d 100644 --- a/gitlint-core/gitlint/git.py +++ b/gitlint-core/gitlint/git.py @@ -1,6 +1,9 @@ import logging import os +from dataclasses import dataclass, field +from datetime import datetime from pathlib import Path +from typing import Dict, List, Optional import arrow @@ -108,6 +111,95 @@ def _parse_git_changed_file_stats(changed_files_stats_raw): return changed_files_stats +@dataclass +class GitContext(PropertyCache): + """Class representing the git context in which gitlint is operating: a data object storing information about + the git repository that gitlint is linting. + """ + + commits: List["GitCommit"] = field(init=False, default_factory=list) + repository_path: Optional[str] = None + + @property + @cache + def commentchar(self): + return git_commentchar(self.repository_path) + + @property + @cache + def current_branch(self): + try: + current_branch = _git("rev-parse", "--abbrev-ref", "HEAD", _cwd=self.repository_path).strip() + except GitContextError: + # Maybe there is no commit. Try another way to get current branch (need Git 2.22+) + current_branch = _git("branch", "--show-current", _cwd=self.repository_path).strip() + return current_branch + + @staticmethod + def from_commit_msg(commit_msg_str): + """Determines git context based on a commit message. + :param commit_msg_str: Full git commit message. + """ + context = GitContext() + commit_msg_obj = GitCommitMessage.from_full_message(context, commit_msg_str) + commit = GitCommit(context, commit_msg_obj) + context.commits.append(commit) + return context + + @staticmethod + def from_staged_commit(commit_msg_str, repository_path): + """Determines git context based on a commit message that is a staged commit for a local git repository. + :param commit_msg_str: Full git commit message. + :param repository_path: Path to the git repository to retrieve the context from + """ + context = GitContext(repository_path=repository_path) + commit_msg_obj = GitCommitMessage.from_full_message(context, commit_msg_str) + commit = StagedLocalGitCommit(context, commit_msg_obj) + context.commits.append(commit) + return context + + @staticmethod + def from_local_repository(repository_path, refspec=None, commit_hashes=None): + """Retrieves the git context from a local git repository. + :param repository_path: Path to the git repository to retrieve the context from + :param refspec: The commit(s) to retrieve (mutually exclusive with `commit_hash`) + :param commit_hash: Hash of the commit to retrieve (mutually exclusive with `refspec`) + """ + + context = GitContext(repository_path=repository_path) + + if refspec: + sha_list = _git("rev-list", refspec, _cwd=repository_path).split() + elif commit_hashes: # One or more commit hashes, just pass it to `git log -1` + # Even though we have already been passed the commit hash, we ask git to retrieve this hash and + # return it to us. This way we verify that the passed hash is a valid hash for the target repo and we + # also convert it to the full hash format (we might have been passed a short hash). + sha_list = [] + for commit_hash in commit_hashes: + sha_list.append(_git("log", "-1", commit_hash, "--pretty=%H", _cwd=repository_path).replace("\n", "")) + else: # If no refspec is defined, fallback to the last commit on the current branch + # We tried many things here e.g.: defaulting to e.g. HEAD or HEAD^... (incl. dealing with + # repos that only have a single commit - HEAD^... doesn't work there), but then we still get into + # problems with e.g. merge commits. Easiest solution is just taking the SHA from `git log -1`. + sha_list = [_git("log", "-1", "--pretty=%H", _cwd=repository_path).replace("\n", "")] + + for sha in sha_list: + commit = LocalGitCommit(context, sha) + context.commits.append(commit) + + return context + + def __eq__(self, other): + return ( + isinstance(other, GitContext) + and self.commits == other.commits + and self.repository_path == other.repository_path + and self.commentchar == other.commentchar + and self.current_branch == other.current_branch + ) + + +@dataclass class GitCommitMessage: """Class representing a git commit message. A commit message consists of the following: - context: The `GitContext` this commit message is part of @@ -117,12 +209,11 @@ class GitCommitMessage: - body: all lines following the title """ - def __init__(self, context, original=None, full=None, title=None, body=None): - self.context = context - self.original = original - self.full = full - self.title = title - self.body = body + context: GitContext = field(compare=False) + original: str + full: str + title: str + body: List[str] @staticmethod def from_full_message(context, commit_msg_str): @@ -142,36 +233,20 @@ class GitCommitMessage: def __str__(self): return self.full - def __eq__(self, other): - return ( - isinstance(other, GitCommitMessage) - and self.original == other.original - and self.full == other.full - and self.title == other.title - and self.body == other.body - ) - +@dataclass class GitChangedFileStats: """Class representing the stats for a changed file in git""" - def __init__(self, filepath, additions, deletions): - self.filepath = Path(filepath) - self.additions = additions - self.deletions = deletions - - def __eq__(self, other): - return ( - isinstance(other, GitChangedFileStats) - and self.filepath == other.filepath - and self.additions == other.additions - and self.deletions == other.deletions - ) + filepath: Path + additions: int + deletions: int def __str__(self) -> str: return f"{self.filepath}: {self.additions} additions, {self.deletions} deletions" +@dataclass class GitCommit: """Class representing a git commit. A commit consists of: context, message, author name, author email, date, list of parent commit shas, @@ -179,27 +254,15 @@ class GitCommit: In the context of gitlint, only the git context and commit message are required. """ - def __init__( - self, - context, - message, - sha=None, - date=None, - author_name=None, - author_email=None, - parents=None, - changed_files_stats=None, - branches=None, - ): - self.context = context - self.message = message - self.sha = sha - self.date = date - self.author_name = author_name - self.author_email = author_email - self.parents = parents or [] # parent commit hashes - self.changed_files_stats = changed_files_stats or {} - self.branches = branches or [] + context: GitContext = field(compare=False) + message: GitCommitMessage + sha: Optional[str] = None + date: Optional[datetime] = None + author_name: Optional[str] = None + author_email: Optional[str] = None + parents: List[str] = field(default_factory=list) + changed_files_stats: Dict[str, GitChangedFileStats] = field(default_factory=dict) + branches: List[str] = field(default_factory=list) @property def is_merge_commit(self): @@ -250,27 +313,8 @@ class GitCommit: "-----------------------" ) - def __eq__(self, other): - # skip checking the context as context refers back to this obj, this will trigger a cyclic dependency - return ( - isinstance(other, GitCommit) - and self.message == other.message - and self.sha == other.sha - and self.author_name == other.author_name - and self.author_email == other.author_email - and self.date == other.date - and self.parents == other.parents - and self.is_merge_commit == other.is_merge_commit - and self.is_fixup_commit == other.is_fixup_commit - and self.is_fixup_amend_commit == other.is_fixup_amend_commit - and self.is_squash_commit == other.is_squash_commit - and self.is_revert_commit == other.is_revert_commit - and self.changed_files == other.changed_files - and self.changed_files_stats == other.changed_files_stats - and self.branches == other.branches - ) - +@dataclass class LocalGitCommit(GitCommit, PropertyCache): """Class representing a git commit that exists in the local git repository. This class uses lazy loading: it defers reading information from the local git repository until the associated @@ -366,6 +410,7 @@ class LocalGitCommit(GitCommit, PropertyCache): return self._try_cache("changed_files_stats", cache_changed_files_stats) +@dataclass class StagedLocalGitCommit(GitCommit, PropertyCache): """Class representing a git commit that has been staged, but not committed. @@ -419,92 +464,3 @@ class StagedLocalGitCommit(GitCommit, PropertyCache): self._cache["changed_files_stats"] = _parse_git_changed_file_stats(changed_files_stats_raw) return self._try_cache("changed_files_stats", cache_changed_files_stats) - - -class GitContext(PropertyCache): - """Class representing the git context in which gitlint is operating: a data object storing information about - the git repository that gitlint is linting. - """ - - def __init__(self, repository_path=None): - PropertyCache.__init__(self) - self.commits = [] - self.repository_path = repository_path - - @property - @cache - def commentchar(self): - return git_commentchar(self.repository_path) - - @property - @cache - def current_branch(self): - try: - current_branch = _git("rev-parse", "--abbrev-ref", "HEAD", _cwd=self.repository_path).strip() - except GitContextError: - # Maybe there is no commit. Try another way to get current branch (need Git 2.22+) - current_branch = _git("branch", "--show-current", _cwd=self.repository_path).strip() - return current_branch - - @staticmethod - def from_commit_msg(commit_msg_str): - """Determines git context based on a commit message. - :param commit_msg_str: Full git commit message. - """ - context = GitContext() - commit_msg_obj = GitCommitMessage.from_full_message(context, commit_msg_str) - commit = GitCommit(context, commit_msg_obj) - context.commits.append(commit) - return context - - @staticmethod - def from_staged_commit(commit_msg_str, repository_path): - """Determines git context based on a commit message that is a staged commit for a local git repository. - :param commit_msg_str: Full git commit message. - :param repository_path: Path to the git repository to retrieve the context from - """ - context = GitContext(repository_path=repository_path) - commit_msg_obj = GitCommitMessage.from_full_message(context, commit_msg_str) - commit = StagedLocalGitCommit(context, commit_msg_obj) - context.commits.append(commit) - return context - - @staticmethod - def from_local_repository(repository_path, refspec=None, commit_hashes=None): - """Retrieves the git context from a local git repository. - :param repository_path: Path to the git repository to retrieve the context from - :param refspec: The commit(s) to retrieve (mutually exclusive with `commit_hash`) - :param commit_hash: Hash of the commit to retrieve (mutually exclusive with `refspec`) - """ - - context = GitContext(repository_path=repository_path) - - if refspec: - sha_list = _git("rev-list", refspec, _cwd=repository_path).split() - elif commit_hashes: # One or more commit hashes, just pass it to `git log -1` - # Even though we have already been passed the commit hash, we ask git to retrieve this hash and - # return it to us. This way we verify that the passed hash is a valid hash for the target repo and we - # also convert it to the full hash format (we might have been passed a short hash). - sha_list = [] - for commit_hash in commit_hashes: - sha_list.append(_git("log", "-1", commit_hash, "--pretty=%H", _cwd=repository_path).replace("\n", "")) - else: # If no refspec is defined, fallback to the last commit on the current branch - # We tried many things here e.g.: defaulting to e.g. HEAD or HEAD^... (incl. dealing with - # repos that only have a single commit - HEAD^... doesn't work there), but then we still get into - # problems with e.g. merge commits. Easiest solution is just taking the SHA from `git log -1`. - sha_list = [_git("log", "-1", "--pretty=%H", _cwd=repository_path).replace("\n", "")] - - for sha in sha_list: - commit = LocalGitCommit(context, sha) - context.commits.append(commit) - - return context - - def __eq__(self, other): - return ( - isinstance(other, GitContext) - and self.commits == other.commits - and self.repository_path == other.repository_path - and self.commentchar == other.commentchar - and self.current_branch == other.current_branch - ) diff --git a/gitlint-core/gitlint/lint.py b/gitlint-core/gitlint/lint.py index 420d3ad..8878669 100644 --- a/gitlint-core/gitlint/lint.py +++ b/gitlint-core/gitlint/lint.py @@ -1,20 +1,24 @@ import logging +from dataclasses import dataclass, field -from gitlint import display from gitlint import rules as gitlint_rules +from gitlint.config import LintConfig from gitlint.deprecation import Deprecation +from gitlint.display import Display LOG = logging.getLogger(__name__) logging.basicConfig() +@dataclass class GitLinter: """Main linter class. This is where rules actually get applied. See the lint() method.""" - def __init__(self, config): - self.config = config + config: LintConfig + display: Display = field(init=False) - self.display = display.Display(config) + def __post_init__(self): + self.display = Display(self.config) def should_ignore_rule(self, rule): """Determines whether a rule should be ignored based on the general list of commits to ignore""" diff --git a/gitlint-core/gitlint/options.py b/gitlint-core/gitlint/options.py index ff7d9f1..b3be90e 100644 --- a/gitlint-core/gitlint/options.py +++ b/gitlint-core/gitlint/options.py @@ -1,6 +1,8 @@ import os import re from abc import abstractmethod +from dataclasses import dataclass +from typing import Any from gitlint.exception import GitlintError @@ -21,6 +23,7 @@ class RuleOptionError(GitlintError): pass +@dataclass class RuleOption: """Base class representing a configurable part (i.e. option) of a rule (e.g. the max-length of the title-max-line rule). @@ -28,11 +31,12 @@ class RuleOption: options of a particular type like int, str, etc. """ - def __init__(self, name, value, description): - self.name = name - self.description = description - self.value = None - self.set(value) + name: str + value: Any + description: str + + def __post_init__(self): + self.set(self.value) @abstractmethod def set(self, value): @@ -41,20 +45,17 @@ class RuleOption: def __str__(self): return f"({self.name}: {self.value} ({self.description}))" - def __eq__(self, other): - return self.name == other.name and self.description == other.description and self.value == other.value - +@dataclass class StrOption(RuleOption): @allow_none def set(self, value): self.value = str(value) +@dataclass class IntOption(RuleOption): - def __init__(self, name, value, description, allow_negative=False): - self.allow_negative = allow_negative - super().__init__(name, value, description) + allow_negative: bool = False def _raise_exception(self, value): if self.allow_negative: @@ -74,6 +75,7 @@ class IntOption(RuleOption): self._raise_exception(value) +@dataclass class BoolOption(RuleOption): # explicit choice to not annotate with @allow_none: Booleans must be False or True, they cannot be unset. def set(self, value): @@ -83,6 +85,7 @@ class BoolOption(RuleOption): self.value = value == "true" +@dataclass class ListOption(RuleOption): """Option that is either a given list or a comma-separated string that can be split into a list when being set.""" @@ -96,12 +99,11 @@ class ListOption(RuleOption): self.value = [str(item.strip()) for item in the_list if item.strip() != ""] +@dataclass class PathOption(RuleOption): """Option that accepts either a directory or both a directory and a file.""" - def __init__(self, name, value, description, type="dir"): - self.type = type - super().__init__(name, value, description) + type: str = "dir" @allow_none def set(self, value): @@ -129,6 +131,7 @@ class PathOption(RuleOption): self.value = os.path.realpath(value) +@dataclass class RegexOption(RuleOption): @allow_none def set(self, value): diff --git a/gitlint-core/gitlint/rules.py b/gitlint-core/gitlint/rules.py index ca4a05b..e316b97 100644 --- a/gitlint-core/gitlint/rules.py +++ b/gitlint-core/gitlint/rules.py @@ -1,29 +1,42 @@ import copy import logging import re +from dataclasses import dataclass, field +from typing import Any, ClassVar, Dict, List, Optional from gitlint.deprecation import Deprecation from gitlint.exception import GitlintError -from gitlint.options import BoolOption, IntOption, ListOption, RegexOption, StrOption +from gitlint.options import ( + BoolOption, + IntOption, + ListOption, + RegexOption, + RuleOption, + StrOption, +) +@dataclass class Rule: """Class representing gitlint rules.""" - options_spec = [] - id = None - name = None - target = None - _log = None - _log_deprecated_regex_style_search = None + # Class attributes + options_spec: ClassVar[List] = [] + id: ClassVar[str] + name: ClassVar[str] + target: ClassVar[Optional["LineRuleTarget"]] = None + _log: ClassVar[Optional[logging.Logger]] = None + _log_deprecated_regex_style_search: ClassVar[Any] - def __init__(self, opts=None): - if not opts: - opts = {} + # Instance attributes + _raw_options: Dict[str, str] = field(default_factory=dict, compare=False) + options: Dict[str, RuleOption] = field(init=False) + + def __post_init__(self): self.options = {} for op_spec in self.options_spec: self.options[op_spec.name] = copy.deepcopy(op_spec) - actual_option = opts.get(op_spec.name) + actual_option = self._raw_options.get(op_spec.name) if actual_option is not None: self.options[op_spec.name].set(actual_option) @@ -72,20 +85,15 @@ class CommitMessageBody(LineRuleTarget): """Target class used for rules that apply to a commit message body""" +@dataclass class RuleViolation: """Class representing a violation of a rule. I.e.: When a rule is broken, the rule will instantiate this class to indicate how and where the rule was broken.""" - def __init__(self, rule_id, message, content=None, line_nr=None): - self.rule_id = rule_id - self.line_nr = line_nr - self.message = message - self.content = content - - def __eq__(self, other): - equal = self.rule_id == other.rule_id and self.message == other.message - equal = equal and self.content == other.content and self.line_nr == other.line_nr - return equal + rule_id: str + message: str + content: Optional[str] = None + line_nr: Optional[int] = None def __str__(self): return f'{self.line_nr}: {self.rule_id} {self.message}: "{self.content}"' diff --git a/gitlint-core/gitlint/shell.py b/gitlint-core/gitlint/shell.py index 36160a9..95a91df 100644 --- a/gitlint-core/gitlint/shell.py +++ b/gitlint-core/gitlint/shell.py @@ -4,6 +4,7 @@ We still keep the `sh` API and semantics so the rest of the gitlint codebase doe """ import subprocess +from dataclasses import dataclass from gitlint.utils import TERMINAL_ENCODING @@ -18,14 +19,14 @@ class CommandNotFound(Exception): """Exception indicating a command was not found during execution""" +@dataclass class ShResult: """Result wrapper class""" - def __init__(self, full_cmd, stdout, stderr="", exitcode=0): - self.full_cmd = full_cmd - self.stdout = stdout - self.stderr = stderr - self.exit_code = exitcode + full_cmd: str + stdout: str + stderr: str = "" + exit_code: int = 0 def __str__(self): return self.stdout diff --git a/gitlint-core/gitlint/tests/rules/test_rules.py b/gitlint-core/gitlint/tests/rules/test_rules.py index b401372..0e438b6 100644 --- a/gitlint-core/gitlint/tests/rules/test_rules.py +++ b/gitlint-core/gitlint/tests/rules/test_rules.py @@ -8,12 +8,17 @@ class RuleTests(BaseTestCase): self.assertEqual(str(RuleViolation("rule-ïd", "Tēst message", "Tēst content", 57)), expected) def test_rule_equality(self): - self.assertEqual(Rule(), Rule()) - # Ensure rules are not equal if they differ on their attributes - for attr in ["id", "name", "target", "options"]: - rule = Rule() - setattr(rule, attr, "åbc") - self.assertNotEqual(Rule(), rule) + # Ensure rules are not equal if they differ on one of their attributes + rule_attrs = ["id", "name", "target", "options"] + for attr in rule_attrs: + rule1 = Rule() + rule2 = Rule() + for attr2 in rule_attrs: + setattr(rule1, attr2, "föo") + setattr(rule2, attr2, "föo") + self.assertEqual(rule1, rule2) + setattr(rule1, attr, "åbc") + self.assertNotEqual(rule1, rule2) def test_rule_log(self): rule = Rule() |