"""Exceptions and error representations.""" from __future__ import annotations import functools from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any from ansiblelint._internal.rules import BaseRule, RuntimeErrorRule from ansiblelint.file_utils import Lintable if TYPE_CHECKING: from ansiblelint.utils import Task class LintWarning(Warning): """Used by linter.""" @dataclass class WarnSource: """Container for warning information, so we can later create a MatchError from it.""" filename: Lintable lineno: int tag: str message: str | None = None @dataclass(frozen=True) class RuleMatchTransformMeta: """Additional metadata about a match error to be used during transformation.""" # pylint: disable=too-many-instance-attributes @dataclass(unsafe_hash=True) @functools.total_ordering class MatchError(ValueError): """Rule violation detected during linting. It can be raised as Exception but also just added to the list of found rules violations. Note that line argument is not considered when building hash of an instance. """ # order matters for these: message: str = field(init=True, repr=False, default="") lintable: Lintable = field(init=True, repr=False, default=Lintable(name="")) tag: str = field(init=True, repr=False, default="") lineno: int = 1 details: str = "" column: int | None = None # rule is not included in hash because we might have different instances # of the same rule, but we use the 'tag' to identify the rule. rule: BaseRule = field(hash=False, default=RuntimeErrorRule()) ignored: bool = False fixed: bool = False # True when a transform has resolved this MatchError transform_meta: RuleMatchTransformMeta | None = None def __post_init__(self) -> None: """Can be use by rules that can report multiple errors type, so we can still filter by them.""" self.filename = self.lintable.name # We want to catch accidental MatchError() which contains no useful # information. When no arguments are passed, the '_message' field is # set to 'property', only if passed it becomes a string. if self.rule.__class__ is RuntimeErrorRule: # so instance was created without a rule if not self.message: msg = f"{self.__class__.__name__}() missing a required argument: one of 'message' or 'rule'" raise TypeError(msg) if not isinstance(self.tag, str): msg = "MatchErrors must be created with either rule or tag specified." raise TypeError(msg) if not self.message: self.message = self.rule.shortdesc self.match_type: str | None = None # for task matches, save the normalized task object (useful for transforms) self.task: Task | None = None # path to the problem area, like: [0,"pre_tasks",3] for [0].pre_tasks[3] self.yaml_path: list[int | str] = [] if not self.tag: self.tag = self.rule.id # Safety measure to ensure we do not end-up with incorrect indexes if self.lineno == 0: # pragma: no cover msg = "MatchError called incorrectly as line numbers start with 1" raise RuntimeError(msg) if self.column == 0: # pragma: no cover msg = "MatchError called incorrectly as column numbers start with 1" raise RuntimeError(msg) self.lineno += self.lintable.line_offset # We make the lintable aware that we found a match inside it, as this # can be used to skip running other rules that do require current one # to pass. self.lintable.matches.append(self) @functools.cached_property def level(self) -> str: """Return the level of the rule: error, warning or notice.""" if ( not self.ignored and self.rule.options and {self.tag, self.rule.id, *self.rule.tags}.isdisjoint( self.rule.options.warn_list, ) ): return "error" return "warning" def __repr__(self) -> str: """Return a MatchError instance representation.""" formatstr = "[{0}] ({1}) matched {2}:{3} {4}" # note that `rule.id` can be int, str or even missing, as users # can defined their own custom rules. _id = getattr(self.rule, "id", "000") return formatstr.format( _id, self.message, self.filename, self.lineno, self.details, ) def __str__(self) -> str: """Return a MatchError instance string representation.""" return self.__repr__() @property def position(self) -> str: """Return error positioning, with column number if available.""" if self.column: return f"{self.lineno}:{self.column}" return str(self.lineno) @property def _hash_key(self) -> Any: # line attr is knowingly excluded, as dict is not hashable return ( self.filename, self.lineno, str(getattr(self.rule, "id", 0)), self.message, self.details, # -1 is used here to force errors with no column to sort before # all other errors. -1 if self.column is None else self.column, ) def __lt__(self, other: object) -> bool: """Return whether the current object is less than the other.""" if not isinstance(other, self.__class__): return NotImplemented return bool(self._hash_key < other._hash_key) def __eq__(self, other: object) -> bool: """Identify whether the other object represents the same rule match.""" if not isinstance(other, self.__class__): return NotImplemented return self.__hash__() == other.__hash__()