summaryrefslogtreecommitdiffstats
path: root/src/ansiblelint/rules/jinja.py
diff options
context:
space:
mode:
Diffstat (limited to 'src/ansiblelint/rules/jinja.py')
-rw-r--r--src/ansiblelint/rules/jinja.py170
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