Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

py313: AttributeError: 'black.parsing.ASTSafetyError' object has no attribute '__dict__'. Did you mean: '__dir__'? #10090

Open
ssbarnea opened this issue Nov 22, 2024 · 0 comments
Labels
Crash 💥 A bug that makes pylint crash Needs PR This issue is accepted, sufficiently specified and now needs an implementation python 3.13
Milestone

Comments

@ssbarnea
Copy link
Contributor

This bug seems to be happen with current version of black (24.10.0) and python 3.13 (apparently not with other pythons).

Issue title:
Crash ```` (if possible, be more specific about what made pylint crash)

Bug description

When parsing the following a.py:

"""Rule for checking content of jinja template strings."""

from __future__ import annotations

import logging
import os
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any, NamedTuple

import jinja2
from ansible.errors import AnsibleError, AnsibleFilterError, AnsibleParserError
from ansible.parsing.yaml.objects import AnsibleUnicode
from black import FileMode, format_str
from black.parsing import InvalidInput
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, 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 (  # type: ignore[attr-defined]
    Templar,
    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


_logger = logging.getLogger(__package__)
KEYWORDS_WITH_IMPLICIT_TEMPLATE = ("changed_when", "failed_when", "until", "when")


class Token(NamedTuple):
    """Token."""

    lineno: int
    token_type: str
    value: str


ignored_re = re.compile(
    "|".join(  # noqa: FLY002
        [
            r"^Object of type method is not JSON serializable",
            r"^Unexpected templating type error occurred on",
            r"^obj must be a list of dicts or a nested dict$",
            r"^the template file (.*) could not be found for the lookup$",
            r"could not locate file in lookup",
            r"unable to locate collection",
            r"^Error in (.*)is undefined$",
            r"^Mandatory variable (.*) not defined.$",
            r"is undefined",
            r"Unrecognized type <<class 'ansible.template.AnsibleUndefined'>> for (.*) filter <value>$",
            # https://github.com/ansible/ansible-lint/issues/3155
            r"^The '(.*)' test expects a dictionary$",
            # https://github.com/ansible/ansible-lint/issues/4338
            r"An unhandled exception occurred while templating (.*). Error was a <class 'ansible.errors.AnsibleFilterError'>, original message: The (.*) test expects a dictionary$",
        ],
    ),
    flags=re.MULTILINE | re.DOTALL,
)


@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"
    severity = "LOW"
    tags = ["formatting"]
    version_added = "v6.5.0"
    _ansible_error_re = re.compile(
        (
            r"^(?P<error>.*): (?P<detail>.*)\. String: (?P<string>.*)$"
            r"|An unhandled exception occurred while templating '.*'\. Error was a .*, original message: (?P<nested_error>.*)"
        ),
        flags=re.MULTILINE,
    )

    env = jinja2.Environment(trim_blocks=False)
    _tag2msg = {
        "invalid": "Syntax error in jinja2 template: {value}",
        "spacing": "Jinja2 spacing could be improved: {value} -> {reformatted}",
    }
    _ids = {
        "jinja[invalid]": "Invalid jinja2 syntax",
        "jinja[spacing]": "Jinja2 spacing could be improved",
    }

    def _msg(self, tag: str, value: str, reformatted: str) -> str:
        """Generate error message."""
        return self._tag2msg[tag].format(value=value, reformatted=reformatted)

    # pylint: disable=too-many-locals
    def matchtask(
        self,
        task: Task,
        file: Lintable | None = None,
    ) -> list[MatchError]:
        result = []
        try:
            for key, v, path in nested_items_path(
                task,
                ignored_keys=("block", "ansible.builtin.block", "ansible.legacy.block"),
            ):
                if isinstance(v, str):
                    try:
                        template(
                            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
                        orig_exc = (
                            exc.orig_exc if getattr(exc, "orig_exc", None) else exc
                        )
                        orig_exc_message = getattr(orig_exc, "message", str(orig_exc))
                        match = self._ansible_error_re.match(
                            getattr(orig_exc, "message", str(orig_exc)),
                        )
                        if ignored_re.search(orig_exc_message) or isinstance(
                            orig_exc,
                            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

                            # ansible@devel (2.14) new behavior:
                            # 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)
                            and match
                        ):
                            error = match.group("error")
                            detail = match.group("detail")
                            nested_error = match.group("nested_error")
                            if error and error.startswith(
                                "template error while templating string",
                            ):
                                bypass = False
                            elif detail and detail.startswith(
                                "unable to locate collection",
                            ):
                                _logger.debug("Ignored AnsibleError: %s", exc)
                                bypass = True
                            elif nested_error and nested_error.startswith(
                                "Unexpected templating type error occurred on",
                            ):
                                bypass = True
                            else:
                                bypass = False
                        elif re.match(r"^lookup plugin (.*) not found$", exc.message):
                            # lookup plugin 'template' not found
                            bypass = True

                        # AnsibleError: template error while templating string: expected token ':', got '}'. String: {{ {{ '1' }} }}
                        # AnsibleError: template error while templating string: unable to locate collection ansible.netcommon. String: Foo {{ buildset_registry.host | ipwrap }}
                        if not bypass:
                            result.append(
                                self.create_matcherror(
                                    message=str(exc),
                                    lineno=_get_error_line(task, path),
                                    filename=file,
                                    tag=f"{self.id}[invalid]",
                                ),
                            )
                            continue
                    reformatted, details, tag = self.check_whitespace(
                        v,
                        key=key,
                        lintable=file,
                    )
                    if reformatted != v:
                        result.append(
                            self.create_matcherror(
                                message=self._msg(
                                    tag=tag,
                                    value=v,
                                    reformatted=reformatted,
                                ),
                                lineno=_get_error_line(task, path),
                                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:
            _logger.info("Exception in JinjaRule.matchtask: %s", exc)
            raise
        return result

    def matchyaml(self, file: Lintable) -> list[MatchError]:
        """Return matches for variables defined in vars files."""
        raw_results: list[MatchError] = []
        results: list[MatchError] = []

        if str(file.kind) == "vars":
            data = parse_yaml_from_file(str(file.path))
            if not isinstance(data, dict | list):
                msg = f"Unexpected data type: {type(data)}"
                raise TypeError(msg)
            for key, v, _path in nested_items_path(data):
                if isinstance(v, AnsibleUnicode):
                    reformatted, details, tag = self.check_whitespace(
                        v,
                        key=key,
                        lintable=file,
                    )
                    if reformatted != v:
                        results.append(
                            self.create_matcherror(
                                message=self._msg(
                                    tag=tag,
                                    value=v,
                                    reformatted=reformatted,
                                ),
                                lineno=v.ansible_pos[1],
                                details=details,
                                filename=file,
                                tag=f"{self.id}[{tag}]",
                            ),
                        )
            if raw_results:
                lines = file.content.splitlines()
                for match in raw_results:
                    # lineno starts with 1, not zero
                    skip_list = get_rule_skips_from_line(
                        line=lines[match.lineno - 1],
                        lintable=file,
                    )
                    if match.rule.id not in skip_list and match.tag not in skip_list:
                        results.append(match)
        else:
            results.extend(super().matchyaml(file))
        return results

    def lex(self, text: str) -> list[Token]:
        """Parse jinja template."""
        # https://github.com/pallets/jinja/issues/1711
        self.env.keep_trailing_newline = True

        self.env.lstrip_blocks = False
        self.env.trim_blocks = False
        self.env.autoescape = True
        self.env.newline_sequence = "\n"
        tokens = [
            Token(lineno=t[0], token_type=t[1], value=t[2]) for t in self.env.lex(text)
        ]
        new_text = self.unlex(tokens)
        if text != new_text:
            _logger.debug(
                "Unable to perform full roundtrip lex-unlex on jinja template (expected when '-' modifier is used): {text} -> {new_text}",
            )
        return tokens

    def unlex(self, tokens: list[Token]) -> str:
        """Return original text by compiling the lex output."""
        result = ""
        last_lineno = 1
        last_value = ""
        for lineno, _, value in tokens:
            if lineno > last_lineno and "\n" not in last_value:
                result += "\n"
            result += value
            last_lineno = lineno
            last_value = value
        return result

    # pylint: disable=too-many-locals
    def check_whitespace(
        self,
        text: str,
        key: str,
        lintable: Lintable | None = None,
    ) -> tuple[str, str, str]:
        """Check spacing inside given jinja2 template string.

        We aim to match Python Black formatting rules.
        :raises NotImplementedError: On few cases where valid jinja is not valid Python.

        :returns: (string, string, string)  reformatted text, detailed error, error tag
        """

        def cook(value: str, *, implicit: bool = False) -> str:
            """Prepare an implicit string for jinja parsing when needed."""
            if not implicit:
                return value
            if value.startswith("{{") and value.endswith("}}"):
                # maybe we should make this an error?
                return value
            return f"{{{{ {value} }}}}"

        def uncook(value: str, *, implicit: bool = False) -> str:
            """Restore an string to original form when it was an implicit one."""
            if not implicit:
                return value
            return value[3:-3]

        tokens = []
        details = ""
        begin_types = ("variable_begin", "comment_begin", "block_begin")
        end_types = ("variable_end", "comment_end", "block_end")
        implicit = False

        # implicit templates do not have the {{ }} wrapping
        if (
            key in KEYWORDS_WITH_IMPLICIT_TEMPLATE
            and lintable
            and lintable.kind
            in (
                "playbook",
                "task",
            )
        ):
            implicit = True
            text = cook(text, implicit=implicit)

        # don't try to lex strings that have no jinja inside them
        if not has_jinja(text):
            return text, "", "spacing"

        expr_str = None
        expr_type = None
        verb_skipped = True
        lineno = 1
        try:
            for token in self.lex(text):
                if (
                    expr_type
                    and expr_type.startswith("{%")
                    and token.token_type in ("name", "whitespace")
                    and not verb_skipped
                ):
                    # on {% blocks we do not take first word as part of the expression
                    tokens.append(token)
                    if token.token_type != "whitespace":
                        verb_skipped = True
                elif token.token_type in begin_types:
                    tokens.append(token)
                    expr_type = token.value  # such {#, {{, {%
                    expr_str = ""
                    verb_skipped = False
                elif token.token_type in end_types and expr_str is not None:
                    # process expression
                    # pylint: disable=unsupported-membership-test
                    if isinstance(expr_str, str) and "\n" in expr_str:
                        raise NotImplementedError  # noqa: TRY301
                    leading_spaces = " " * (len(expr_str) - len(expr_str.lstrip()))
                    expr_str = leading_spaces + blacken(expr_str.lstrip())
                    if tokens[
                        -1
                    ].token_type != "whitespace" and not expr_str.startswith(" "):
                        expr_str = " " + expr_str
                    if not expr_str.endswith(" "):
                        expr_str += " "
                    tokens.append(Token(lineno, "data", expr_str))
                    tokens.append(token)
                    expr_str = None
                    expr_type = None
                elif expr_str is not None:
                    expr_str += token.value
                else:
                    tokens.append(token)
                lineno = token.lineno

        except jinja2.exceptions.TemplateSyntaxError as exc:
            return "", str(exc.message), "invalid"
        # pylint: disable=c-extension-no-member
        except (NotImplementedError, InvalidInput) as exc:
            # black is not able to recognize all valid jinja2 templates, so we
            # just ignore InvalidInput errors.
            # NotImplementedError is raised internally for expressions with
            # newlines, as we decided to not touch them yet.
            # These both are documented as known limitations.
            _logger.debug("Ignored jinja internal error %s", exc)
            return uncook(text, implicit=implicit), "", "spacing"

        # finalize
        reformatted = self.unlex(tokens)
        failed = reformatted != text
        reformatted = uncook(reformatted, implicit=implicit)
        details = (
            f"Jinja2 template rewrite recommendation: `{reformatted}`."
            if failed
            else ""
        )
        return reformatted, details, "spacing"

    def transform(
        self,
        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,
        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."""
    return format_str(
        text,
        mode=FileMode(line_length=sys.maxsize, string_normalization=False),
    ).rstrip("\n")


if "pytest" in sys.modules:
    from unittest import mock

    import pytest

    # 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]:
        """Return list of expected error lines."""
        return [33, 36, 39, 42, 45, 48, 74]

    # 21 68
    @pytest.fixture(name="lint_error_lines")
    def fixture_lint_error_lines() -> list[int]:
        """Get VarHasSpacesRules linting results on test_playbook."""
        collection = RulesCollection()
        collection.register(JinjaRule())
        lintable = Lintable("examples/playbooks/jinja-spacing.yml")
        results = Runner(lintable, rules=collection).run()
        return [item.lineno for item in results]

    def test_jinja_spacing_playbook(
        error_expected_lines: list[int],
        lint_error_lines: list[int],
    ) -> None:
        """Ensure that expected error lines are matching found linting error lines."""
        # list unexpected error lines or non-matching error lines
        error_lines_difference = list(
            set(error_expected_lines).symmetric_difference(set(lint_error_lines)),
        )
        assert len(error_lines_difference) == 0

    def test_jinja_spacing_vars() -> None:
        """Ensure that expected error details are matching found linting error details."""
        collection = RulesCollection()
        collection.register(JinjaRule())
        lintable = Lintable("examples/playbooks/vars/jinja-spacing.yml")
        results = Runner(lintable, rules=collection).run()

        error_expected_lineno = [14, 15, 16, 17, 18, 19, 32]
        assert len(results) == len(error_expected_lineno)
        for idx, err in enumerate(results):
            assert err.lineno == error_expected_lineno[idx]

    @pytest.mark.parametrize(
        ("text", "expected", "tag"),
        (
            pytest.param(
                "{{-x}}{#a#}{%1%}",
                "{{- x }}{# a #}{% 1 %}",
                "spacing",
                id="add-missing-space",
            ),
            pytest.param("", "", "spacing", id="1"),
            pytest.param("foo", "foo", "spacing", id="2"),
            pytest.param("{##}", "{# #}", "spacing", id="3"),
            # we want to keep leading spaces as they might be needed for complex multiline jinja files
            pytest.param("{#  #}", "{#  #}", "spacing", id="4"),
            pytest.param(
                "{{-aaa|xx   }}foo\nbar{#some#}\n{%%}",
                "{{- aaa | xx }}foo\nbar{# some #}\n{% %}",
                "spacing",
                id="5",
            ),
            pytest.param(
                "Shell with jinja filter",
                "Shell with jinja filter",
                "spacing",
                id="6",
            ),
            pytest.param(
                "{{{'dummy_2':1}|true}}",
                "{{ {'dummy_2': 1} | true }}",
                "spacing",
                id="7",
            ),
            pytest.param("{{{foo:{}}}}", "{{ {foo: {}} }}", "spacing", id="8"),
            pytest.param(
                "{{ {'test': {'subtest': variable}} }}",
                "{{ {'test': {'subtest': variable}} }}",
                "spacing",
                id="9",
            ),
            pytest.param(
                "http://foo.com/{{\n  case1 }}",
                "http://foo.com/{{\n  case1 }}",
                "spacing",
                id="10",
            ),
            pytest.param("{{foo(123)}}", "{{ foo(123) }}", "spacing", id="11"),
            pytest.param("{{ foo(a.b.c) }}", "{{ foo(a.b.c) }}", "spacing", id="12"),
            # pytest.param(
            #     "spacing",
            # ),
            pytest.param(
                "{{foo(x =['server_options'])}}",
                "{{ foo(x=['server_options']) }}",
                "spacing",
                id="14",
            ),
            pytest.param(
                '{{ [ "host", "NA"] }}',
                '{{ ["host", "NA"] }}',
                "spacing",
                id="15",
            ),
            pytest.param(
                "{{ {'dummy_2': {'nested_dummy_1': value_1,\n    'nested_dummy_2': value_2}} |\ncombine(dummy_1) }}",
                "{{ {'dummy_2': {'nested_dummy_1': value_1,\n    'nested_dummy_2': value_2}} |\ncombine(dummy_1) }}",
                "spacing",
                id="17",
            ),
            pytest.param("{{ & }}", "", "invalid", id="18"),
            pytest.param(
                "{{ good_format }}/\n{{- good_format }}\n{{- good_format -}}\n",
                "{{ good_format }}/\n{{- good_format }}\n{{- good_format -}}\n",
                "spacing",
                id="19",
            ),
            pytest.param(
                "{{ {'a': {'b': 'x', 'c': y}} }}",
                "{{ {'a': {'b': 'x', 'c': y}} }}",
                "spacing",
                id="20",
            ),
            pytest.param(
                "2*(1+(3-1)) is {{ 2 * {{ 1 + {{ 3 - 1 }}}} }}",
                "2*(1+(3-1)) is {{ 2 * {{1 + {{3 - 1}}}} }}",
                "spacing",
                id="21",
            ),
            pytest.param(
                '{{ "absent"\nif (v is version("2.8.0", ">=")\nelse "present" }}',
                "",
                "invalid",
                id="22",
            ),
            pytest.param(
                '{{lookup("x",y+"/foo/"+z+".txt")}}',
                '{{ lookup("x", y + "/foo/" + z + ".txt") }}',
                "spacing",
                id="23",
            ),
            pytest.param(
                "{{ x | map(attribute='value') }}",
                "{{ x | map(attribute='value') }}",
                "spacing",
                id="24",
            ),
            pytest.param(
                "{{ r(a= 1,b= True,c= 0.0,d= '') }}",
                "{{ r(a=1, b=True, c=0.0, d='') }}",
                "spacing",
                id="25",
            ),
            pytest.param("{{ r(1,[]) }}", "{{ r(1, []) }}", "spacing", id="26"),
            pytest.param(
                "{{ lookup([ddd ]) }}",
                "{{ lookup([ddd]) }}",
                "spacing",
                id="27",
            ),
            pytest.param(
                "{{ [ x ] if x is string else x }}",
                "{{ [x] if x is string else x }}",
                "spacing",
                id="28",
            ),
            pytest.param(
                "{% if a|int <= 8 -%} iptables {%- else -%} iptables-nft {%- endif %}",
                "{% if a | int <= 8 -%} iptables{%- else -%} iptables-nft{%- endif %}",
                "spacing",
                id="29",
            ),
            pytest.param(
                # "- 2" -> "-2", minus does not get separated when there is no left side
                "{{ - 2 }}",
                "{{ -2 }}",
                "spacing",
                id="30",
            ),
            pytest.param(
                # "-2" -> "-2", minus does get an undesired spacing
                "{{ -2 }}",
                "{{ -2 }}",
                "spacing",
                id="31",
            ),
            pytest.param(
                # array ranges do not have space added
                "{{ foo[2:4] }}",
                "{{ foo[2:4] }}",
                "spacing",
                id="32",
            ),
            pytest.param(
                # array ranges have the extra space removed
                "{{ foo[2: 4] }}",
                "{{ foo[2:4] }}",
                "spacing",
                id="33",
            ),
            pytest.param(
                # negative array index
                "{{ foo[-1] }}",
                "{{ foo[-1] }}",
                "spacing",
                id="34",
            ),
            pytest.param(
                # negative array index, repair
                "{{ foo[- 1] }}",
                "{{ foo[-1] }}",
                "spacing",
                id="35",
            ),
            pytest.param("{{ a +~'b' }}", "{{ a + ~'b' }}", "spacing", id="36"),
            pytest.param(
                "{{ (a[: -4] *~ b) }}",
                "{{ (a[:-4] * ~b) }}",
                "spacing",
                id="37",
            ),
            pytest.param("{{ [a,~ b] }}", "{{ [a, ~b] }}", "spacing", id="38"),
            # Not supported yet due to being accepted by black:
            pytest.param("{{ item.0.user }}", "{{ item.0.user }}", "spacing", id="39"),
            # Not supported by back, while jinja allows ~ to be binary operator:
            pytest.param("{{ a ~ b }}", "{{ a ~ b }}", "spacing", id="40"),
            pytest.param(
                "--format='{{'{{'}}.Size{{'}}'}}'",
                "--format='{{ '{{' }}.Size{{ '}}' }}'",
                "spacing",
                id="41",
            ),
            pytest.param(
                "{{ list_one + {{ list_two | max }} }}",
                "{{ list_one + {{list_two | max}} }}",
                "spacing",
                id="42",
            ),
            pytest.param(
                "{{ lookup('file'   ,  '/tmp/non-existent',  errors='ignore') }}",
                "{{ lookup('file', '/tmp/non-existent', errors='ignore') }}",
                "spacing",
                id="43",
            ),
            # https://github.com/ansible/ansible-lint/pull/3057
            # since jinja 3.0.0, \r is converted to \n if the string has jinja in it
            pytest.param(
                "{{ 'foo' }}\r{{ 'bar' }}",
                "{{ 'foo' }}\n{{ 'bar' }}",
                "spacing",
                id="44",
            ),
            # if we do not have any jinja constructs, we should keep original \r
            # to match ansible behavior
            pytest.param(
                "foo\rbar",
                "foo\rbar",
                "spacing",
                id="45",
            ),
        ),
    )
    def test_jinja(text: str, expected: str, tag: str) -> None:
        """Tests our ability to spot spacing errors inside jinja2 templates."""
        rule = JinjaRule()

        reformatted, details, returned_tag = rule.check_whitespace(
            text,
            key="name",
            lintable=Lintable("playbook.yml"),
        )
        assert tag == returned_tag, details
        assert expected == reformatted

    @pytest.mark.parametrize(
        ("text", "expected", "tag"),
        (
            pytest.param(
                "1+2",
                "1 + 2",
                "spacing",
                id="0",
            ),
            pytest.param(
                "- 1",
                "-1",
                "spacing",
                id="1",
            ),
            # Ensure that we do not choke with double templating on implicit
            # and instead we remove them braces.
            pytest.param("{{ o | bool }}", "o | bool", "spacing", id="2"),
        ),
    )
    def test_jinja_implicit(text: str, expected: str, tag: str) -> None:
        """Tests our ability to spot spacing errors implicit jinja2 templates."""
        rule = JinjaRule()
        # implicit jinja2 are working only inside playbooks and tasks
        lintable = Lintable(name="playbook.yml", kind="playbook")
        reformatted, details, returned_tag = rule.check_whitespace(
            text,
            key="when",
            lintable=lintable,
        )
        assert tag == returned_tag, details
        assert expected == reformatted

    @pytest.mark.parametrize(
        ("lintable", "matches"),
        (pytest.param("examples/playbooks/vars/rule_jinja_vars.yml", 0, id="0"),),
    )
    def test_jinja_file(lintable: str, matches: int) -> None:
        """Tests our ability to process var filesspot spacing errors."""
        collection = RulesCollection()
        collection.register(JinjaRule())
        errs = Runner(lintable, rules=collection).run()
        assert len(errs) == matches
        for err in errs:
            assert isinstance(err, JinjaRule)
            assert errs[0].tag == "jinja[invalid]"
            assert errs[0].rule.id == "jinja"

    def test_jinja_invalid() -> None:
        """Tests our ability to spot spacing errors inside jinja2 templates."""
        collection = RulesCollection()
        collection.register(JinjaRule())
        success = "examples/playbooks/rule-jinja-fail.yml"
        errs = Runner(success, rules=collection).run()
        assert len(errs) == 2
        assert errs[0].tag == "jinja[spacing]"
        assert errs[0].rule.id == "jinja"
        assert errs[0].lineno == 9
        assert errs[1].tag == "jinja[invalid]"
        assert errs[1].rule.id == "jinja"
        assert errs[1].lineno == 9

    def test_jinja_valid() -> None:
        """Tests our ability to parse jinja, even when variables may not be defined."""
        collection = RulesCollection()
        collection.register(JinjaRule())
        success = "examples/playbooks/rule-jinja-pass.yml"
        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 test_jinja_nested_var_errors() -> None:
        """Tests our ability to handle nested var errors from jinja2 templates."""

        def _do_template(*args, **kwargs):  # type: ignore[no-untyped-def] # Templar.do_template has no type hint
            data = args[1]

            if data != "{{ 12 | random(seed=inventory_hostname) }}":
                return do_template(*args, **kwargs)

            msg = "Unexpected templating type error occurred on (foo): bar"
            raise AnsibleError(msg)

        do_template = Templar.do_template
        collection = RulesCollection()
        collection.register(JinjaRule())
        lintable = Lintable("examples/playbooks/jinja-nested-vars.yml")
        with mock.patch.object(Templar, "do_template", _do_template):
            results = Runner(lintable, rules=collection).run()
            assert len(results) == 0


def _get_error_line(task: dict[str, Any], path: list[str | int]) -> int:
    """Return error line number."""
    line = task[LINE_NUMBER_KEY]
    for _ in path:
        ctx = task[str(_)]
        if LINE_NUMBER_KEY in ctx:
            line = ctx[LINE_NUMBER_KEY]
    if not isinstance(line, int):
        msg = "Line number is not an integer"
        raise TypeError(msg)
    return line

Command used

pylint a.py

Pylint output

pylint crashed with a ``AstroidError`` and with the following stacktrace:
Traceback (most recent call last):
  File "/Users/ssbarnea/.cache/pre-commit/reporqvry_4n/py_env-python3.13/lib/python3.13/site-packages/pylint/checkers/imports.py", line 1014, in _get_imported_module
    return importnode.do_import_module(modname)
           ~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^
  File "/Users/ssbarnea/.cache/pre-commit/reporqvry_4n/py_env-python3.13/lib/python3.13/site-packages/astroid/nodes/_base_nodes.py", line 168, in do_import_module
    return mymodule.import_module(
           ~~~~~~~~~~~~~~~~~~~~~~^
        modname,
        ^^^^^^^^
    ...<2 lines>...
        use_cache=use_cache,
        ^^^^^^^^^^^^^^^^^^^^
    )
    ^
  File "/Users/ssbarnea/.cache/pre-commit/reporqvry_4n/py_env-python3.13/lib/python3.13/site-packages/astroid/nodes/scoped_nodes/scoped_nodes.py", line 462, in import_module
    return AstroidManager().ast_from_module_name(
           ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^
        absmodname, use_cache=use_cache
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    )
    ^
  File "/Users/ssbarnea/.cache/pre-commit/reporqvry_4n/py_env-python3.13/lib/python3.13/site-packages/astroid/manager.py", line 251, in ast_from_module_name
    return self.ast_from_module(named_module, modname)
           ~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/ssbarnea/.cache/pre-commit/reporqvry_4n/py_env-python3.13/lib/python3.13/site-packages/astroid/manager.py", line 356, in ast_from_module
    return AstroidBuilder(self).module_build(module, modname)
           ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^
  File "/Users/ssbarnea/.cache/pre-commit/reporqvry_4n/py_env-python3.13/lib/python3.13/site-packages/astroid/builder.py", line 101, in module_build
    node = self.inspect_build(module, modname=modname, path=path)
  File "/Users/ssbarnea/.cache/pre-commit/reporqvry_4n/py_env-python3.13/lib/python3.13/site-packages/astroid/raw_building.py", line 463, in inspect_build
    self.object_build(node, module)
    ~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^
  File "/Users/ssbarnea/.cache/pre-commit/reporqvry_4n/py_env-python3.13/lib/python3.13/site-packages/astroid/raw_building.py", line 504, in object_build
    class_node = object_build_class(node, member, name)
  File "/Users/ssbarnea/.cache/pre-commit/reporqvry_4n/py_env-python3.13/lib/python3.13/site-packages/astroid/raw_building.py", line 265, in object_build_class
    return _base_class_object_build(node, member, basenames, localname=localname)
  File "/Users/ssbarnea/.cache/pre-commit/reporqvry_4n/py_env-python3.13/lib/python3.13/site-packages/astroid/raw_building.py", line 373, in _base_class_object_build
    instdict = member().__dict__
               ^^^^^^^^^^^^^^^^^
AttributeError: 'black.parsing.ASTSafetyError' object has no attribute '__dict__'. Did you mean: '__dir__'?

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/Users/ssbarnea/.cache/pre-commit/reporqvry_4n/py_env-python3.13/lib/python3.13/site-packages/pylint/lint/pylinter.py", line 788, in _lint_file
    check_astroid_module(module)
    ~~~~~~~~~~~~~~~~~~~~^^^^^^^^
  File "/Users/ssbarnea/.cache/pre-commit/reporqvry_4n/py_env-python3.13/lib/python3.13/site-packages/pylint/lint/pylinter.py", line 1017, in check_astroid_module
    retval = self._check_astroid_module(
        ast_node, walker, rawcheckers, tokencheckers
    )
  File "/Users/ssbarnea/.cache/pre-commit/reporqvry_4n/py_env-python3.13/lib/python3.13/site-packages/pylint/lint/pylinter.py", line 1069, in _check_astroid_module
    walker.walk(node)
    ~~~~~~~~~~~^^^^^^
  File "/Users/ssbarnea/.cache/pre-commit/reporqvry_4n/py_env-python3.13/lib/python3.13/site-packages/pylint/utils/ast_walker.py", line 90, in walk
    self.walk(child)
    ~~~~~~~~~^^^^^^^
  File "/Users/ssbarnea/.cache/pre-commit/reporqvry_4n/py_env-python3.13/lib/python3.13/site-packages/pylint/utils/ast_walker.py", line 87, in walk
    callback(astroid)
    ~~~~~~~~^^^^^^^^^
  File "/Users/ssbarnea/.cache/pre-commit/reporqvry_4n/py_env-python3.13/lib/python3.13/site-packages/pylint/checkers/imports.py", line 557, in visit_importfrom
    imported_module = self._get_imported_module(node, basename)
  File "/Users/ssbarnea/.cache/pre-commit/reporqvry_4n/py_env-python3.13/lib/python3.13/site-packages/pylint/checkers/imports.py", line 1039, in _get_imported_module
    raise astroid.AstroidError from e
astroid.exceptions.AstroidError

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/Users/ssbarnea/.cache/pre-commit/reporqvry_4n/py_env-python3.13/lib/python3.13/site-packages/pylint/lint/pylinter.py", line 752, in _lint_files
    self._lint_file(fileitem, module, check_astroid_module)
    ~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/ssbarnea/.cache/pre-commit/reporqvry_4n/py_env-python3.13/lib/python3.13/site-packages/pylint/lint/pylinter.py", line 790, in _lint_file
    raise astroid.AstroidError from e
astroid.exceptions.AstroidError

Expected behavior

No crash.

Pylint version

pylint 3.3.1
astroid 3.3.5
Python 3.13.0 (main, Oct  7 2024, 23:47:22) [Clang 18.1.8 ]

OS / Environment

darwin (Darwin)

Additional dependencies

@Pierre-Sassoulas Pierre-Sassoulas added False Positive 🦟 A message is emitted but nothing is wrong with the code Needs PR This issue is accepted, sufficiently specified and now needs an implementation python 3.13 Crash 💥 A bug that makes pylint crash and removed False Positive 🦟 A message is emitted but nothing is wrong with the code labels Nov 23, 2024
@Pierre-Sassoulas Pierre-Sassoulas added this to the 4.0.0 milestone Nov 23, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Crash 💥 A bug that makes pylint crash Needs PR This issue is accepted, sufficiently specified and now needs an implementation python 3.13
Projects
None yet
Development

No branches or pull requests

2 participants