diff options
Diffstat (limited to '')
-rw-r--r-- | src/ansiblelint/rules/jinja.py | 170 |
1 files changed, 154 insertions, 16 deletions
diff --git a/src/ansiblelint/rules/jinja.py b/src/ansiblelint/rules/jinja.py index 08254bc..ff124a8 100644 --- a/src/ansiblelint/rules/jinja.py +++ b/src/ansiblelint/rules/jinja.py @@ -1,28 +1,35 @@ """Rule for checking content of jinja template strings.""" + from __future__ import annotations import logging +import os import re import sys -from collections import namedtuple +from dataclasses import dataclass from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, NamedTuple import black import jinja2 -from ansible.errors import AnsibleError, AnsibleParserError +from ansible.errors import AnsibleError, AnsibleFilterError, AnsibleParserError from ansible.parsing.yaml.objects import AnsibleUnicode from jinja2.exceptions import TemplateSyntaxError from ansiblelint.constants import LINE_NUMBER_KEY +from ansiblelint.errors import RuleMatchTransformMeta from ansiblelint.file_utils import Lintable -from ansiblelint.rules import AnsibleLintRule +from ansiblelint.rules import AnsibleLintRule, TransformMixin +from ansiblelint.runner import get_matches from ansiblelint.skip_utils import get_rule_skips_from_line from ansiblelint.text import has_jinja from ansiblelint.utils import parse_yaml_from_file, template from ansiblelint.yaml_utils import deannotate, nested_items_path if TYPE_CHECKING: + from ruamel.yaml.comments import CommentedMap, CommentedSeq + + from ansiblelint.config import Options from ansiblelint.errors import MatchError from ansiblelint.utils import Task @@ -30,7 +37,14 @@ if TYPE_CHECKING: _logger = logging.getLogger(__package__) KEYWORDS_WITH_IMPLICIT_TEMPLATE = ("changed_when", "failed_when", "until", "when") -Token = namedtuple("Token", "lineno token_type value") + +class Token(NamedTuple): + """Token.""" + + lineno: int + token_type: str + value: str + ignored_re = re.compile( "|".join( # noqa: FLY002 @@ -53,7 +67,27 @@ ignored_re = re.compile( ) -class JinjaRule(AnsibleLintRule): +@dataclass(frozen=True) +class JinjaRuleTMetaSpacing(RuleMatchTransformMeta): + """JinjaRule transform metadata. + + :param key: Key or index within the task + :param value: Value of the key + :param path: Path to the key + :param fixed: Value with spacing fixed + """ + + key: str | int + value: str | int + path: tuple[str | int, ...] + fixed: str + + def __str__(self) -> str: + """Return string representation.""" + return f"{self.key}={self.value} at {self.path} fixed to {self.fixed}" + + +class JinjaRule(AnsibleLintRule, TransformMixin): """Rule that looks inside jinja2 templates.""" id = "jinja" @@ -94,11 +128,13 @@ class JinjaRule(AnsibleLintRule): if isinstance(v, str): try: template( - basedir=file.path.parent if file else Path("."), + basedir=file.path.parent if file else Path(), value=v, variables=deannotate(task.get("vars", {})), fail_on_error=True, # we later decide which ones to ignore or not ) + except AnsibleFilterError: + bypass = True # ValueError RepresenterError except AnsibleError as exc: bypass = False @@ -111,7 +147,7 @@ class JinjaRule(AnsibleLintRule): ) if ignored_re.search(orig_exc_message) or isinstance( orig_exc, - AnsibleParserError, + AnsibleParserError | TypeError, ): # An unhandled exception occurred while running the lookup plugin 'template'. Error was a <class 'ansible.errors.AnsibleError'>, original message: the template file ... could not be found for the lookup. the template file ... could not be found for the lookup @@ -119,7 +155,7 @@ class JinjaRule(AnsibleLintRule): # AnsibleError(TemplateSyntaxError): template error while templating string: Could not load "ipwrap": 'Invalid plugin FQCN (ansible.netcommon.ipwrap): unable to locate collection ansible.netcommon'. String: Foo {{ buildset_registry.host | ipwrap }}. Could not load "ipwrap": 'Invalid plugin FQCN (ansible.netcommon.ipwrap): unable to locate collection ansible.netcommon' bypass = True elif ( - isinstance(orig_exc, (AnsibleError, TemplateSyntaxError)) + isinstance(orig_exc, AnsibleError | TemplateSyntaxError) and match ): error = match.group("error") @@ -166,6 +202,12 @@ class JinjaRule(AnsibleLintRule): details=details, filename=file, tag=f"{self.id}[{tag}]", + transform_meta=JinjaRuleTMetaSpacing( + key=key, + value=v, + path=tuple(path), + fixed=reformatted, + ), ), ) except Exception as exc: @@ -181,7 +223,6 @@ class JinjaRule(AnsibleLintRule): if str(file.kind) == "vars": data = parse_yaml_from_file(str(file.path)) - # pylint: disable=unused-variable for key, v, _path in nested_items_path(data): if isinstance(v, AnsibleUnicode): reformatted, details, tag = self.check_whitespace( @@ -249,7 +290,7 @@ class JinjaRule(AnsibleLintRule): last_value = value return result - # pylint: disable=too-many-statements,too-many-locals + # pylint: disable=too-many-locals def check_whitespace( self, text: str, @@ -327,7 +368,7 @@ class JinjaRule(AnsibleLintRule): # process expression # pylint: disable=unsupported-membership-test if isinstance(expr_str, str) and "\n" in expr_str: - raise NotImplementedError + raise NotImplementedError # noqa: TRY301 leading_spaces = " " * (len(expr_str) - len(expr_str.lstrip())) expr_str = leading_spaces + blacken(expr_str.lstrip()) if tokens[ @@ -348,7 +389,6 @@ class JinjaRule(AnsibleLintRule): except jinja2.exceptions.TemplateSyntaxError as exc: return "", str(exc.message), "invalid" - # https://github.com/PyCQA/pylint/issues/7433 - py311 only # pylint: disable=c-extension-no-member except (NotImplementedError, black.parsing.InvalidInput) as exc: # black is not able to recognize all valid jinja2 templates, so we @@ -370,6 +410,68 @@ class JinjaRule(AnsibleLintRule): ) return reformatted, details, "spacing" + def transform( + self: JinjaRule, + match: MatchError, + lintable: Lintable, + data: CommentedMap | CommentedSeq | str, + ) -> None: + """Transform jinja2 errors. + + :param match: MatchError instance + :param lintable: Lintable instance + :param data: data to transform + """ + if match.tag == "jinja[spacing]": + self._transform_spacing(match, data) + + def _transform_spacing( + self: JinjaRule, + match: MatchError, + data: CommentedMap | CommentedSeq | str, + ) -> None: + """Transform jinja2 spacing errors. + + The match error was found on a normalized task so we cannot compare the path + instead we only compare the key and value, if the task has 2 identical keys with the + exact same jinja spacing issue, we may transform them out of order + + :param match: MatchError instance + :param data: data to transform + """ + if not isinstance(match.transform_meta, JinjaRuleTMetaSpacing): + return + if isinstance(data, str): + return + + obj = self.seek(match.yaml_path, data) + if obj is None: + return + + ignored_keys = ("block", "ansible.builtin.block", "ansible.legacy.block") + for key, value, path in nested_items_path( + data_collection=obj, + ignored_keys=ignored_keys, + ): + if key == match.transform_meta.key and value == match.transform_meta.value: + if not path: + continue + for pth in path[:-1]: + try: + obj = obj[pth] + except (KeyError, TypeError) as exc: + err = f"Unable to transform {match.transform_meta}: {exc}" + _logger.error(err) # noqa: TRY400 + return + try: + obj[path[-1]][key] = match.transform_meta.fixed + match.fixed = True + + except (KeyError, TypeError) as exc: + err = f"Unable to transform {match.transform_meta}: {exc}" + _logger.error(err) # noqa: TRY400 + return + def blacken(text: str) -> str: """Format Jinja2 template using black.""" @@ -380,10 +482,14 @@ def blacken(text: str) -> str: if "pytest" in sys.modules: + from unittest import mock + import pytest - from ansiblelint.rules import RulesCollection # pylint: disable=ungrouped-imports - from ansiblelint.runner import Runner # pylint: disable=ungrouped-imports + # pylint: disable=ungrouped-imports + from ansiblelint.rules import RulesCollection + from ansiblelint.runner import Runner + from ansiblelint.transformer import Transformer @pytest.fixture(name="error_expected_lines") def fixture_error_expected_lines() -> list[int]: @@ -725,6 +831,38 @@ if "pytest" in sys.modules: errs = Runner(success, rules=collection).run() assert len(errs) == 0 + @mock.patch.dict(os.environ, {"ANSIBLE_LINT_WRITE_TMP": "1"}, clear=True) + def test_jinja_transform( + config_options: Options, + default_rules_collection: RulesCollection, + ) -> None: + """Test transform functionality for jinja rule.""" + playbook = Path("examples/playbooks/rule-jinja-before.yml") + config_options.write_list = ["all"] + + config_options.lintables = [str(playbook)] + runner_result = get_matches( + rules=default_rules_collection, + options=config_options, + ) + transformer = Transformer(result=runner_result, options=config_options) + transformer.run() + + matches = runner_result.matches + assert len(matches) == 2 + + orig_content = playbook.read_text(encoding="utf-8") + expected_content = playbook.with_suffix( + f".transformed{playbook.suffix}", + ).read_text(encoding="utf-8") + transformed_content = playbook.with_suffix(f".tmp{playbook.suffix}").read_text( + encoding="utf-8", + ) + + assert orig_content != transformed_content + assert expected_content == transformed_content + playbook.with_suffix(f".tmp{playbook.suffix}").unlink() + def _get_error_line(task: dict[str, Any], path: list[str | int]) -> int: """Return error line number.""" @@ -736,5 +874,5 @@ def _get_error_line(task: dict[str, Any], path: list[str | int]) -> int: line = ctx[LINE_NUMBER_KEY] if not isinstance(line, int): msg = "Line number is not an integer" - raise RuntimeError(msg) + raise TypeError(msg) return line |