"""Tests for yaml-related utility functions.""" from __future__ import annotations from io import StringIO from pathlib import Path from typing import TYPE_CHECKING, Any import pytest from ruamel.yaml.main import YAML from yamllint.linter import run as run_yamllint import ansiblelint.yaml_utils from ansiblelint.file_utils import Lintable from ansiblelint.utils import task_in_list if TYPE_CHECKING: from ruamel.yaml.comments import CommentedMap, CommentedSeq from ruamel.yaml.emitter import Emitter fixtures_dir = Path(__file__).parent / "fixtures" formatting_before_fixtures_dir = fixtures_dir / "formatting-before" formatting_prettier_fixtures_dir = fixtures_dir / "formatting-prettier" formatting_after_fixtures_dir = fixtures_dir / "formatting-after" @pytest.fixture(name="empty_lintable") def fixture_empty_lintable() -> Lintable: """Return a Lintable with no contents.""" lintable = Lintable("__empty_file__.yaml", content="") return lintable def test_tasks_in_list_empty_file(empty_lintable: Lintable) -> None: """Make sure that task_in_list returns early when files are empty.""" assert empty_lintable.kind assert empty_lintable.path res = list( task_in_list( data=empty_lintable, file=empty_lintable, kind=empty_lintable.kind, ), ) assert not res def test_nested_items_path() -> None: """Verify correct function of nested_items_path().""" data = { "foo": "text", "bar": {"some": "text2"}, "fruits": ["apple", "orange"], "answer": [{"forty-two": ["life", "universe", "everything"]}], } items = [ ("foo", "text", []), ("bar", {"some": "text2"}, []), ("some", "text2", ["bar"]), ("fruits", ["apple", "orange"], []), (0, "apple", ["fruits"]), (1, "orange", ["fruits"]), ("answer", [{"forty-two": ["life", "universe", "everything"]}], []), (0, {"forty-two": ["life", "universe", "everything"]}, ["answer"]), ("forty-two", ["life", "universe", "everything"], ["answer", 0]), (0, "life", ["answer", 0, "forty-two"]), (1, "universe", ["answer", 0, "forty-two"]), (2, "everything", ["answer", 0, "forty-two"]), ] assert list(ansiblelint.yaml_utils.nested_items_path(data)) == items @pytest.mark.parametrize( "invalid_data_input", ( "string", 42, 1.234, ("tuple",), {"set"}, # NoneType is no longer include, as we assume we have to ignore it ), ) def test_nested_items_path_raises_typeerror(invalid_data_input: Any) -> None: """Verify non-dict/non-list types make nested_items_path() raises TypeError.""" with pytest.raises(TypeError, match=r"Expected a dict or a list.*"): list(ansiblelint.yaml_utils.nested_items_path(invalid_data_input)) _input_playbook = [ { "name": "It's a playbook", # unambiguous; no quotes needed "tasks": [ { "name": '"fun" task', # should be a single-quoted string "debug": { # ruamel.yaml default to single-quotes # our Emitter defaults to double-quotes "msg": "{{ msg }}", }, }, ], }, ] _SINGLE_QUOTE_WITHOUT_INDENTS = """\ --- - name: It's a playbook tasks: - name: '"fun" task' debug: msg: '{{ msg }}' """ _SINGLE_QUOTE_WITH_INDENTS = """\ --- - name: It's a playbook tasks: - name: '"fun" task' debug: msg: '{{ msg }}' """ _DOUBLE_QUOTE_WITHOUT_INDENTS = """\ --- - name: It's a playbook tasks: - name: '"fun" task' debug: msg: "{{ msg }}" """ _DOUBLE_QUOTE_WITH_INDENTS_EXCEPT_ROOT_LEVEL = """\ --- - name: It's a playbook tasks: - name: '"fun" task' debug: msg: "{{ msg }}" """ @pytest.mark.parametrize( ( "map_indent", "sequence_indent", "sequence_dash_offset", "alternate_emitter", "expected_output", ), ( pytest.param( 2, 2, 0, None, _SINGLE_QUOTE_WITHOUT_INDENTS, id="single_quote_without_indents", ), pytest.param( 2, 4, 2, None, _SINGLE_QUOTE_WITH_INDENTS, id="single_quote_with_indents", ), pytest.param( 2, 2, 0, ansiblelint.yaml_utils.FormattedEmitter, _DOUBLE_QUOTE_WITHOUT_INDENTS, id="double_quote_without_indents", ), pytest.param( 2, 4, 2, ansiblelint.yaml_utils.FormattedEmitter, _DOUBLE_QUOTE_WITH_INDENTS_EXCEPT_ROOT_LEVEL, id="double_quote_with_indents_except_root_level", ), ), ) def test_custom_ruamel_yaml_emitter( map_indent: int, sequence_indent: int, sequence_dash_offset: int, alternate_emitter: Emitter | None, expected_output: str, ) -> None: """Test ``ruamel.yaml.YAML.dump()`` sequence formatting and quotes.""" yaml = YAML(typ="rt") # NB: ruamel.yaml does not have typehints, so mypy complains about everything here. yaml.explicit_start = True yaml.map_indent = map_indent yaml.sequence_indent = sequence_indent yaml.sequence_dash_offset = sequence_dash_offset if alternate_emitter is not None: yaml.Emitter = alternate_emitter # ruamel.yaml only writes to a stream (there is no `dumps` function) with StringIO() as output_stream: yaml.dump(_input_playbook, output_stream) output = output_stream.getvalue() assert output == expected_output @pytest.fixture(name="yaml_formatting_fixtures") def fixture_yaml_formatting_fixtures(fixture_filename: str) -> tuple[str, str, str]: """Get the contents for the formatting fixture files. To regenerate these fixtures, please run ``pytest --regenerate-formatting-fixtures``. Ideally, prettier should not have to change any ``formatting-after`` fixtures. """ before_path = formatting_before_fixtures_dir / fixture_filename prettier_path = formatting_prettier_fixtures_dir / fixture_filename after_path = formatting_after_fixtures_dir / fixture_filename before_content = before_path.read_text() prettier_content = prettier_path.read_text() formatted_content = after_path.read_text() return before_content, prettier_content, formatted_content @pytest.mark.parametrize( "fixture_filename", ( "fmt-1.yml", "fmt-2.yml", "fmt-3.yml", ), ) def test_formatted_yaml_loader_dumper( yaml_formatting_fixtures: tuple[str, str, str], fixture_filename: str, # noqa: ARG001 ) -> None: """Ensure that FormattedYAML loads/dumps formatting fixtures consistently.""" # pylint: disable=unused-argument before_content, prettier_content, after_content = yaml_formatting_fixtures assert before_content != prettier_content assert before_content != after_content yaml = ansiblelint.yaml_utils.FormattedYAML() data_before = yaml.loads(before_content) dump_from_before = yaml.dumps(data_before) data_prettier = yaml.loads(prettier_content) dump_from_prettier = yaml.dumps(data_prettier) data_after = yaml.loads(after_content) dump_from_after = yaml.dumps(data_after) # comparing data does not work because the Comment objects # have different IDs even if contents do not match. assert dump_from_before == after_content assert dump_from_prettier == after_content assert dump_from_after == after_content # We can't do this because FormattedYAML is stricter in some cases: # # Instead, `pytest --regenerate-formatting-fixtures` will fail if prettier would # change any files in test/fixtures/formatting-after # Running our files through yamllint, after we reformatted them, # should not yield any problems. config = ansiblelint.yaml_utils.load_yamllint_config() assert not list(run_yamllint(after_content, config)) @pytest.fixture(name="lintable") def fixture_lintable(file_path: str) -> Lintable: """Return a playbook Lintable for use in ``get_path_to_*`` tests.""" return Lintable(file_path) @pytest.fixture(name="ruamel_data") def fixture_ruamel_data(lintable: Lintable) -> CommentedMap | CommentedSeq: """Return the loaded YAML data for the Lintable.""" yaml = ansiblelint.yaml_utils.FormattedYAML() data: CommentedMap | CommentedSeq = yaml.loads(lintable.content) return data @pytest.mark.parametrize( ("file_path", "lineno", "expected_path"), ( # ignored lintables pytest.param( "examples/playbooks/tasks/passing_task.yml", 2, [], id="ignore_tasks_file", ), pytest.param( "examples/roles/more_complex/handlers/main.yml", 2, [], id="ignore_handlers_file", ), pytest.param("examples/playbooks/vars/other.yml", 2, [], id="ignore_vars_file"), pytest.param( "examples/host_vars/localhost.yml", 2, [], id="ignore_host_vars_file", ), pytest.param("examples/group_vars/all.yml", 2, [], id="ignore_group_vars_file"), pytest.param( "examples/inventory/inventory.yml", 2, [], id="ignore_inventory_file", ), pytest.param( "examples/roles/dependency_in_meta/meta/main.yml", 2, [], id="ignore_meta_file", ), pytest.param( "examples/reqs_v1/requirements.yml", 2, [], id="ignore_requirements_v1_file", ), pytest.param( "examples/reqs_v2/requirements.yml", 2, [], id="ignore_requirements_v2_file", ), # we don't have any release notes examples. Oh well. pytest.param( ".pre-commit-config.yaml", 2, [], id="ignore_unrecognized_yaml_file", ), # playbook lintables pytest.param( "examples/playbooks/become.yml", 1, [], id="1_play_playbook-line_before_play", ), pytest.param( "examples/playbooks/become.yml", 2, [0], id="1_play_playbook-first_line_in_play", ), pytest.param( "examples/playbooks/become.yml", 10, [0], id="1_play_playbook-middle_line_in_play", ), pytest.param( "examples/playbooks/become.yml", 100, [0], id="1_play_playbook-line_after_eof", ), pytest.param( "examples/playbooks/rule-partial-become-without-become-pass.yml", 1, [], id="4_play_playbook-line_before_play_1", ), pytest.param( "examples/playbooks/rule-partial-become-without-become-pass.yml", 2, [0], id="4_play_playbook-first_line_in_play_1", ), pytest.param( "examples/playbooks/rule-partial-become-without-become-pass.yml", 5, [0], id="4_play_playbook-middle_line_in_play_1", ), pytest.param( "examples/playbooks/rule-partial-become-without-become-pass.yml", 9, [0], id="4_play_playbook-last_line_in_play_1", ), pytest.param( "examples/playbooks/rule-partial-become-without-become-pass.yml", 10, [1], id="4_play_playbook-first_line_in_play_2", ), pytest.param( "examples/playbooks/rule-partial-become-without-become-pass.yml", 14, [1], id="4_play_playbook-middle_line_in_play_2", ), pytest.param( "examples/playbooks/rule-partial-become-without-become-pass.yml", 18, [1], id="4_play_playbook-last_line_in_play_2", ), pytest.param( "examples/playbooks/rule-partial-become-without-become-pass.yml", 19, [2], id="4_play_playbook-first_line_in_play_3", ), pytest.param( "examples/playbooks/rule-partial-become-without-become-pass.yml", 23, [2], id="4_play_playbook-middle_line_in_play_3", ), pytest.param( "examples/playbooks/rule-partial-become-without-become-pass.yml", 27, [2], id="4_play_playbook-last_line_in_play_3", ), pytest.param( "examples/playbooks/rule-partial-become-without-become-pass.yml", 28, [3], id="4_play_playbook-first_line_in_play_4", ), pytest.param( "examples/playbooks/rule-partial-become-without-become-pass.yml", 31, [3], id="4_play_playbook-middle_line_in_play_4", ), pytest.param( "examples/playbooks/rule-partial-become-without-become-pass.yml", 35, [3], id="4_play_playbook-last_line_in_play_4", ), pytest.param( "examples/playbooks/rule-partial-become-without-become-pass.yml", 100, [3], id="4_play_playbook-line_after_eof", ), pytest.param( "examples/playbooks/playbook-parent.yml", 1, [], id="import_playbook-line_before_play_1", ), pytest.param( "examples/playbooks/playbook-parent.yml", 2, [0], id="import_playbook-first_line_in_play_1", ), pytest.param( "examples/playbooks/playbook-parent.yml", 3, [0], id="import_playbook-middle_line_in_play_1", ), pytest.param( "examples/playbooks/playbook-parent.yml", 4, [0], id="import_playbook-last_line_in_play_1", ), pytest.param( "examples/playbooks/playbook-parent.yml", 5, [1], id="import_playbook-first_line_in_play_2", ), pytest.param( "examples/playbooks/playbook-parent.yml", 6, [1], id="import_playbook-middle_line_in_play_2", ), pytest.param( "examples/playbooks/playbook-parent.yml", 7, [1], id="import_playbook-last_line_in_play_2", ), pytest.param( "examples/playbooks/playbook-parent.yml", 8, [2], id="import_playbook-first_line_in_play_3", ), pytest.param( "examples/playbooks/playbook-parent.yml", 9, [2], id="import_playbook-last_line_in_play_3", ), pytest.param( "examples/playbooks/playbook-parent.yml", 15, [2], id="import_playbook-line_after_eof", ), ), ) def test_get_path_to_play( lintable: Lintable, lineno: int, ruamel_data: CommentedMap | CommentedSeq, expected_path: list[int | str], ) -> None: """Ensure ``get_path_to_play`` returns the expected path given a file + line.""" path_to_play = ansiblelint.yaml_utils.get_path_to_play( lintable, lineno, ruamel_data, ) assert path_to_play == expected_path @pytest.mark.parametrize( ("file_path", "lineno", "expected_path"), ( # ignored lintables pytest.param("examples/playbooks/vars/other.yml", 2, [], id="ignore_vars_file"), pytest.param( "examples/host_vars/localhost.yml", 2, [], id="ignore_host_vars_file", ), pytest.param("examples/group_vars/all.yml", 2, [], id="ignore_group_vars_file"), pytest.param( "examples/inventory/inventory.yml", 2, [], id="ignore_inventory_file", ), pytest.param( "examples/roles/dependency_in_meta/meta/main.yml", 2, [], id="ignore_meta_file", ), pytest.param( "examples/reqs_v1/requirements.yml", 2, [], id="ignore_requirements_v1_file", ), pytest.param( "examples/reqs_v2/requirements.yml", 2, [], id="ignore_requirements_v2_file", ), # we don't have any release notes examples. Oh well. pytest.param( ".pre-commit-config.yaml", 2, [], id="ignore_unrecognized_yaml_file", ), # tasks-containing lintables pytest.param( "examples/playbooks/become.yml", 4, [], id="1_task_playbook-line_before_tasks", ), pytest.param( "examples/playbooks/become.yml", 5, [0, "tasks", 0], id="1_task_playbook-first_line_in_task_1", ), pytest.param( "examples/playbooks/become.yml", 10, [0, "tasks", 0], id="1_task_playbook-middle_line_in_task_1", ), pytest.param( "examples/playbooks/become.yml", 15, [0, "tasks", 0], id="1_task_playbook-last_line_in_task_1", ), pytest.param( "examples/playbooks/become.yml", 100, [0, "tasks", 0], id="1_task_playbook-line_after_eof_without_anything_after_task", ), pytest.param( "examples/playbooks/rule-partial-become-without-become-pass.yml", 1, [], id="4_play_playbook-play_1_line_before_tasks", ), pytest.param( "examples/playbooks/rule-partial-become-without-become-pass.yml", 7, [0, "tasks", 0], id="4_play_playbook-play_1_first_line_task_1", ), pytest.param( "examples/playbooks/rule-partial-become-without-become-pass.yml", 9, [0, "tasks", 0], id="4_play_playbook-play_1_last_line_task_1", ), pytest.param( "examples/playbooks/rule-partial-become-without-become-pass.yml", 10, [], id="4_play_playbook-play_2_line_before_tasks", ), pytest.param( "examples/playbooks/rule-partial-become-without-become-pass.yml", 12, [], id="4_play_playbook-play_2_line_before_tasks", ), pytest.param( "examples/playbooks/rule-partial-become-without-become-pass.yml", 13, [1, "tasks", 0], id="4_play_playbook-play_2_first_line_task_1", ), pytest.param( "examples/playbooks/rule-partial-become-without-become-pass.yml", 18, [1, "tasks", 0], id="4_play_playbook-play_2_middle_line_task_1", ), pytest.param( "examples/playbooks/rule-partial-become-without-become-pass.yml", 18, [1, "tasks", 0], id="4_play_playbook-play_2_last_line_task_1", ), pytest.param( "examples/playbooks/rule-partial-become-without-become-pass.yml", 19, [], id="4_play_playbook-play_3_line_before_tasks", ), pytest.param( "examples/playbooks/rule-partial-become-without-become-pass.yml", 22, [], id="4_play_playbook-play_3_line_before_tasks", ), pytest.param( "examples/playbooks/rule-partial-become-without-become-pass.yml", 23, [2, "tasks", 0], id="4_play_playbook-play_3_first_line_task_1", ), pytest.param( "examples/playbooks/rule-partial-become-without-become-pass.yml", 25, [2, "tasks", 0], id="4_play_playbook-play_3_middle_line_task_1", ), pytest.param( "examples/playbooks/rule-partial-become-without-become-pass.yml", 27, [2, "tasks", 0], id="4_play_playbook-play_3_last_line_task_1", ), pytest.param( "examples/playbooks/rule-partial-become-without-become-pass.yml", 28, [], id="4_play_playbook-play_4_line_before_tasks", ), pytest.param( "examples/playbooks/rule-partial-become-without-become-pass.yml", 31, [], id="4_play_playbook-play_4_line_before_tasks", ), pytest.param( "examples/playbooks/rule-partial-become-without-become-pass.yml", 32, [3, "tasks", 0], id="4_play_playbook-play_4_first_line_task_1", ), pytest.param( "examples/playbooks/rule-partial-become-without-become-pass.yml", 33, [3, "tasks", 0], id="4_play_playbook-play_4_middle_line_task_1", ), pytest.param( "examples/playbooks/rule-partial-become-without-become-pass.yml", 35, [3, "tasks", 0], id="4_play_playbook-play_4_last_line_task_1", ), # playbook with multiple tasks + tasks blocks in a play pytest.param( # must have at least one key after one of the tasks blocks "examples/playbooks/include.yml", 6, [0, "pre_tasks", 0], id="playbook-multi_tasks_blocks-pre_tasks_last_task_before_roles", ), pytest.param( "examples/playbooks/include.yml", 7, [], id="playbook-multi_tasks_blocks-roles_after_pre_tasks", ), pytest.param( "examples/playbooks/include.yml", 10, [], id="playbook-multi_tasks_blocks-roles_before_tasks", ), pytest.param( "examples/playbooks/include.yml", 12, [0, "tasks", 0], id="playbook-multi_tasks_blocks-tasks_first_task", ), pytest.param( "examples/playbooks/include.yml", 14, [0, "tasks", 1], id="playbook-multi_tasks_blocks-tasks_last_task_before_handlers", ), pytest.param( "examples/playbooks/include.yml", 16, [0, "handlers", 0], id="playbook-multi_tasks_blocks-handlers_task", ), # playbook with subtasks blocks pytest.param( "examples/playbooks/blockincludes.yml", 14, [0, "tasks", 0, "block", 1, "block", 0], id="playbook-deeply_nested_task", ), pytest.param( "examples/playbooks/block.yml", 12, [0, "tasks", 0, "block", 1], id="playbook-subtasks-block_task_2", ), pytest.param( "examples/playbooks/block.yml", 22, [0, "tasks", 0, "rescue", 2], id="playbook-subtasks-rescue_task_3", ), pytest.param( "examples/playbooks/block.yml", 25, [0, "tasks", 0, "always", 0], id="playbook-subtasks-always_task_3", ), # tasks files pytest.param("examples/playbooks/tasks/x.yml", 2, [0], id="tasks-null_task"), pytest.param( "examples/playbooks/tasks/x.yml", 6, [1], id="tasks-null_task_next", ), pytest.param( "examples/playbooks/tasks/empty_blocks.yml", 7, [0], # this IS part of the first task and "rescue" does not have subtasks. id="tasks-null_rescue", ), pytest.param( "examples/playbooks/tasks/empty_blocks.yml", 8, [0], # this IS part of the first task and "always" does not have subtasks. id="tasks-empty_always", ), pytest.param( "examples/playbooks/tasks/empty_blocks.yml", 16, [1, "always", 0], id="tasks-task_beyond_empty_blocks", ), pytest.param( "examples/roles/more_complex/tasks/main.yml", 1, [], id="tasks-line_before_tasks", ), pytest.param( "examples/roles/more_complex/tasks/main.yml", 2, [0], id="tasks-first_line_in_task_1", ), pytest.param( "examples/roles/more_complex/tasks/main.yml", 3, [0], id="tasks-middle_line_in_task_1", ), pytest.param( "examples/roles/more_complex/tasks/main.yml", 4, [0], id="tasks-last_line_in_task_1", ), pytest.param( "examples/roles/more_complex/tasks/main.yml", 5, [1], id="tasks-first_line_in_task_2", ), pytest.param( "examples/roles/more_complex/tasks/main.yml", 6, [1], id="tasks-middle_line_in_task_2", ), pytest.param( "examples/roles/more_complex/tasks/main.yml", 7, [1], id="tasks-last_line_in_task_2", ), pytest.param( "examples/roles/more_complex/tasks/main.yml", 8, [2], id="tasks-first_line_in_task_3", ), pytest.param( "examples/roles/more_complex/tasks/main.yml", 9, [2], id="tasks-last_line_in_task_3", ), pytest.param( "examples/roles/more_complex/tasks/main.yml", 100, [2], id="tasks-line_after_eof", ), # handlers pytest.param( "examples/roles/more_complex/handlers/main.yml", 1, [], id="handlers-line_before_tasks", ), pytest.param( "examples/roles/more_complex/handlers/main.yml", 2, [0], id="handlers-first_line_in_task_1", ), pytest.param( "examples/roles/more_complex/handlers/main.yml", 3, [0], id="handlers-last_line_in_task_1", ), pytest.param( "examples/roles/more_complex/handlers/main.yml", 100, [0], id="handlers-line_after_eof", ), ), ) def test_get_path_to_task( lintable: Lintable, lineno: int, ruamel_data: CommentedMap | CommentedSeq, expected_path: list[int | str], ) -> None: """Ensure ``get_task_to_play`` returns the expected path given a file + line.""" path_to_task = ansiblelint.yaml_utils.get_path_to_task( lintable, lineno, ruamel_data, ) assert path_to_task == expected_path @pytest.mark.parametrize( ("file_path", "lineno"), ( pytest.param("examples/playbooks/become.yml", 0, id="1_play_playbook"), pytest.param( "examples/playbooks/rule-partial-become-without-become-pass.yml", 0, id="4_play_playbook", ), pytest.param("examples/playbooks/playbook-parent.yml", 0, id="import_playbook"), pytest.param("examples/playbooks/become.yml", 0, id="1_task_playbook"), ), ) def test_get_path_to_play_raises_value_error_for_bad_lineno( lintable: Lintable, lineno: int, ruamel_data: CommentedMap | CommentedSeq, ) -> None: """Ensure ``get_path_to_play`` raises ValueError for lineno < 1.""" with pytest.raises( ValueError, match=f"expected lineno >= 1, got {lineno}", ): ansiblelint.yaml_utils.get_path_to_play(lintable, lineno, ruamel_data) @pytest.mark.parametrize( ("file_path", "lineno"), (pytest.param("examples/roles/more_complex/tasks/main.yml", 0, id="tasks"),), ) def test_get_path_to_task_raises_value_error_for_bad_lineno( lintable: Lintable, lineno: int, ruamel_data: CommentedMap | CommentedSeq, ) -> None: """Ensure ``get_task_to_play`` raises ValueError for lineno < 1.""" with pytest.raises( ValueError, match=f"expected lineno >= 1, got {lineno}", ): ansiblelint.yaml_utils.get_path_to_task(lintable, lineno, ruamel_data) @pytest.mark.parametrize( ("before", "after"), ( pytest.param(None, None, id="1"), pytest.param(1, 1, id="2"), pytest.param({}, {}, id="3"), pytest.param({"__file__": 1}, {}, id="simple"), pytest.param({"foo": {"__file__": 1}}, {"foo": {}}, id="nested"), pytest.param([{"foo": {"__file__": 1}}], [{"foo": {}}], id="nested-in-lint"), pytest.param({"foo": [{"__file__": 1}]}, {"foo": [{}]}, id="nested-in-lint"), ), ) def test_deannotate( before: Any, after: Any, ) -> None: """Ensure deannotate works as intended.""" assert ansiblelint.yaml_utils.deannotate(before) == after