diff options
Diffstat (limited to '')
-rw-r--r-- | test/test_file_utils.py | 538 |
1 files changed, 538 insertions, 0 deletions
diff --git a/test/test_file_utils.py b/test/test_file_utils.py new file mode 100644 index 0000000..b7b9115 --- /dev/null +++ b/test/test_file_utils.py @@ -0,0 +1,538 @@ +"""Tests for file utility functions.""" +from __future__ import annotations + +import copy +import logging +import os +import time +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import pytest + +from ansiblelint import cli, file_utils +from ansiblelint.file_utils import ( + Lintable, + cwd, + expand_path_vars, + expand_paths_vars, + find_project_root, + normpath, + normpath_path, +) +from ansiblelint.runner import Runner + +if TYPE_CHECKING: + from _pytest.capture import CaptureFixture + from _pytest.logging import LogCaptureFixture + from _pytest.monkeypatch import MonkeyPatch + + from ansiblelint.constants import FileType + from ansiblelint.rules import RulesCollection + + +@pytest.mark.parametrize( + ("path", "expected"), + ( + pytest.param(Path("a/b/../"), "a", id="pathlib.Path"), + pytest.param("a/b/../", "a", id="str"), + pytest.param("", ".", id="empty"), + pytest.param(".", ".", id="empty"), + ), +) +def test_normpath(path: str, expected: str) -> None: + """Ensure that relative parent dirs are normalized in paths.""" + assert normpath(path) == expected + + +def test_expand_path_vars(monkeypatch: MonkeyPatch) -> None: + """Ensure that tilde and env vars are expanded in paths.""" + test_path = "/test/path" + monkeypatch.setenv("TEST_PATH", test_path) + assert expand_path_vars("~") == os.path.expanduser("~") # noqa: PTH111 + assert expand_path_vars("$TEST_PATH") == test_path + + +@pytest.mark.parametrize( + ("test_path", "expected"), + ( + pytest.param(Path("$TEST_PATH"), "/test/path", id="pathlib.Path"), + pytest.param("$TEST_PATH", "/test/path", id="str"), + pytest.param(" $TEST_PATH ", "/test/path", id="stripped-str"), + pytest.param("~", os.path.expanduser("~"), id="home"), # noqa: PTH:111 + ), +) +def test_expand_paths_vars( + test_path: str | Path, + expected: str, + monkeypatch: MonkeyPatch, +) -> None: + """Ensure that tilde and env vars are expanded in paths lists.""" + monkeypatch.setenv("TEST_PATH", "/test/path") + assert expand_paths_vars([test_path]) == [expected] # type: ignore[list-item] + + +def test_discover_lintables_silent( + monkeypatch: MonkeyPatch, + capsys: CaptureFixture[str], + caplog: LogCaptureFixture, +) -> None: + """Verify that no stderr output is displayed while discovering yaml files. + + (when the verbosity is off, regardless of the Git or Git-repo presence) + + Also checks expected number of files are detected. + """ + caplog.set_level(logging.FATAL) + options = cli.get_config([]) + test_dir = Path(__file__).resolve().parent + lint_path = (test_dir / ".." / "examples" / "roles" / "test-role").resolve() + + yaml_count = len(list(lint_path.glob("**/*.yml"))) + len( + list(lint_path.glob("**/*.yaml")), + ) + + monkeypatch.chdir(str(lint_path)) + my_options = copy.deepcopy(options) + my_options.lintables = [str(lint_path)] + files = file_utils.discover_lintables(my_options) + stderr = capsys.readouterr().err + assert ( + not stderr + ), f"No stderr output is expected when the verbosity is off, got: {stderr}" + assert ( + len(files) == yaml_count + ), "Expected to find {yaml_count} yaml files in {lint_path}".format_map( + locals(), + ) + + +def test_discover_lintables_umlaut(monkeypatch: MonkeyPatch) -> None: + """Verify that filenames containing German umlauts are not garbled by the discover_lintables.""" + options = cli.get_config([]) + test_dir = Path(__file__).resolve().parent + lint_path = (test_dir / ".." / "examples" / "playbooks").resolve() + + monkeypatch.chdir(str(lint_path)) + files = file_utils.discover_lintables(options) + assert '"with-umlaut-\\303\\244.yml"' not in files + assert "with-umlaut-รค.yml" in files + + +@pytest.mark.parametrize( + ("path", "kind"), + ( + pytest.param("tasks/run_test_playbook.yml", "tasks", id="0"), + pytest.param("foo/playbook.yml", "playbook", id="1"), + pytest.param("playbooks/foo.yml", "playbook", id="2"), + pytest.param("examples/roles/foo.yml", "yaml", id="3"), + # the only yml file that is not a playbook inside molecule/ folders + pytest.param( + "examples/.config/molecule/config.yml", + "yaml", + id="4", + ), # molecule shared config + pytest.param( + "test/schemas/test/molecule/cluster/base.yml", + "yaml", + id="5", + ), # molecule scenario base config + pytest.param( + "test/schemas/test/molecule/cluster/molecule.yml", + "yaml", + id="6", + ), # molecule scenario config + pytest.param( + "test/schemas/test/molecule/cluster/foobar.yml", + "playbook", + id="7", + ), # custom playbook name + pytest.param( + "test/schemas/test/molecule/cluster/converge.yml", + "playbook", + id="8", + ), # common playbook name + pytest.param( + "roles/foo/molecule/scenario3/requirements.yml", + "requirements", + id="9", + ), # requirements + pytest.param( + "roles/foo/molecule/scenario3/collections.yml", + "requirements", + id="10", + ), # requirements + pytest.param( + "roles/foo/meta/argument_specs.yml", + "role-arg-spec", + id="11", + ), # role argument specs + # tasks files: + pytest.param("tasks/directory with spaces/main.yml", "tasks", id="12"), # tasks + pytest.param("tasks/requirements.yml", "tasks", id="13"), # tasks + # requirements (we do not support includes yet) + pytest.param( + "requirements.yml", + "requirements", + id="14", + ), # collection requirements + pytest.param( + "roles/foo/meta/requirements.yml", + "requirements", + id="15", + ), # inside role requirements + # Undeterminable files: + pytest.param("test/fixtures/unknown-type.yml", "yaml", id="16"), + pytest.param( + "releasenotes/notes/run-playbooks-refactor.yaml", + "reno", + id="17", + ), # reno + pytest.param("examples/host_vars/localhost.yml", "vars", id="18"), + pytest.param("examples/group_vars/all.yml", "vars", id="19"), + pytest.param("examples/playbooks/vars/other.yml", "vars", id="20"), + pytest.param( + "examples/playbooks/vars/subfolder/settings.yml", + "vars", + id="21", + ), # deep vars + pytest.param( + "molecule/scenario/collections.yml", + "requirements", + id="22", + ), # deprecated 2.8 format + pytest.param( + "../roles/geerlingguy.mysql/tasks/configure.yml", + "tasks", + id="23", + ), # relative path involved + pytest.param("galaxy.yml", "galaxy", id="24"), + pytest.param("foo.j2.yml", "jinja2", id="25"), + pytest.param("foo.yml.j2", "jinja2", id="26"), + pytest.param("foo.j2.yaml", "jinja2", id="27"), + pytest.param("foo.yaml.j2", "jinja2", id="28"), + pytest.param( + "examples/playbooks/rulebook.yml", + "playbook", + id="29", + ), # playbooks folder should determine kind + pytest.param( + "examples/rulebooks/rulebook-pass.yml", + "rulebook", + id="30", + ), # content should determine it as a rulebook + pytest.param( + "examples/yamllint/valid.yml", + "yaml", + id="31", + ), # empty yaml is valid yaml, not assuming anything else + pytest.param( + "examples/other/guess-1.yml", + "playbook", + id="32", + ), # content should determine is as a play + pytest.param( + "examples/playbooks/tasks/passing_task.yml", + "tasks", + id="33", + ), # content should determine is tasks + pytest.param("examples/collection/galaxy.yml", "galaxy", id="34"), + pytest.param("examples/meta/runtime.yml", "meta-runtime", id="35"), + pytest.param("examples/meta/changelogs/changelog.yaml", "changelog", id="36"), + pytest.param("examples/inventory/inventory.yml", "inventory", id="37"), + pytest.param("examples/inventory/production.yml", "inventory", id="38"), + pytest.param("examples/playbooks/vars/empty_vars.yml", "vars", id="39"), + pytest.param( + "examples/playbooks/vars/subfolder/settings.yaml", + "vars", + id="40", + ), + pytest.param( + "examples/sanity_ignores/tests/sanity/ignore-2.14.txt", + "sanity-ignore-file", + id="41", + ), + pytest.param("examples/playbooks/tasks/vars/bug-3289.yml", "vars", id="42"), + pytest.param( + "examples/site.yml", + "playbook", + id="43", + ), # content should determine it as a play + ), +) +def test_kinds(path: str, kind: FileType) -> None: + """Verify auto-detection logic based on DEFAULT_KINDS.""" + # assert Lintable is able to determine file type + lintable_detected = Lintable(path) + lintable_expected = Lintable(path, kind=kind) + assert lintable_detected == lintable_expected + + +def test_find_project_root_1(tmp_path: Path) -> None: + """Verify find_project_root().""" + # this matches black behavior in absence of any config files or .git/.hg folders. + with cwd(tmp_path): + path, method = find_project_root([]) + assert str(path) == "/" + assert method == "file system root" + + +def test_find_project_root_dotconfig() -> None: + """Verify find_project_root().""" + # this expects to return examples folder as project root because this + # folder already has an .config/ansible-lint.yml file inside, which should + # be enough. + with cwd(Path("examples")): + assert Path( + ".config/ansible-lint.yml", + ).exists(), "Test requires config file inside .config folder." + path, method = find_project_root([]) + assert str(path) == str(Path.cwd()) + assert ".config/ansible-lint.yml" in method + + +BASIC_PLAYBOOK = """ +- name: "playbook" + tasks: + - name: Hello + debug: + msg: 'world' +""" + + +@pytest.fixture(name="tmp_updated_lintable") +def fixture_tmp_updated_lintable( + tmp_path: Path, + path: str, + content: str, + updated_content: str, +) -> Lintable: + """Create a temp file Lintable with a content update that is not on disk.""" + lintable = Lintable(tmp_path / path, content) + with lintable.path.open("w", encoding="utf-8") as f: + f.write(content) + # move mtime to a time in the past to avoid race conditions in the test + mtime = time.time() - 60 * 60 # 1hr ago + os.utime(str(lintable.path), (mtime, mtime)) + lintable.content = updated_content + return lintable + + +@pytest.mark.parametrize( + ("path", "content", "updated_content", "updated"), + ( + pytest.param( + "no_change.yaml", + BASIC_PLAYBOOK, + BASIC_PLAYBOOK, + False, + id="no_change", + ), + pytest.param( + "quotes.yaml", + BASIC_PLAYBOOK, + BASIC_PLAYBOOK.replace('"', "'"), + True, + id="updated_quotes", + ), + pytest.param( + "shorten.yaml", + BASIC_PLAYBOOK, + "# short file\n", + True, + id="shorten_file", + ), + ), +) +def test_lintable_updated( + path: str, + content: str, + updated_content: str, + updated: bool, +) -> None: + """Validate ``Lintable.updated`` when setting ``Lintable.content``.""" + lintable = Lintable(path, content) + + assert lintable.content == content + + lintable.content = updated_content + + assert lintable.content == updated_content + + assert lintable.updated is updated + + +@pytest.mark.parametrize( + "updated_content", + ((None,), (b"bytes",)), + ids=("none", "bytes"), +) +def test_lintable_content_setter_with_bad_types(updated_content: Any) -> None: + """Validate ``Lintable.updated`` when setting ``Lintable.content``.""" + lintable = Lintable("bad_type.yaml", BASIC_PLAYBOOK) + assert lintable.content == BASIC_PLAYBOOK + + with pytest.raises(TypeError): + lintable.content = updated_content + + assert not lintable.updated + + +def test_lintable_with_new_file(tmp_path: Path) -> None: + """Validate ``Lintable.updated`` for a new file.""" + lintable = Lintable(tmp_path / "new.yaml") + + lintable.content = BASIC_PLAYBOOK + lintable.content = BASIC_PLAYBOOK + assert lintable.content == BASIC_PLAYBOOK + + assert lintable.updated + + assert not lintable.path.exists() + lintable.write() + assert lintable.path.exists() + assert lintable.path.read_text(encoding="utf-8") == BASIC_PLAYBOOK + + +@pytest.mark.parametrize( + ("path", "force", "content", "updated_content", "updated"), + ( + pytest.param( + "no_change.yaml", + False, + BASIC_PLAYBOOK, + BASIC_PLAYBOOK, + False, + id="no_change", + ), + pytest.param( + "forced.yaml", + True, + BASIC_PLAYBOOK, + BASIC_PLAYBOOK, + False, + id="forced_rewrite", + ), + pytest.param( + "quotes.yaml", + False, + BASIC_PLAYBOOK, + BASIC_PLAYBOOK.replace('"', "'"), + True, + id="updated_quotes", + ), + pytest.param( + "shorten.yaml", + False, + BASIC_PLAYBOOK, + "# short file\n", + True, + id="shorten_file", + ), + pytest.param( + "forced.yaml", + True, + BASIC_PLAYBOOK, + BASIC_PLAYBOOK.replace('"', "'"), + True, + id="forced_and_updated", + ), + ), +) +def test_lintable_write( + tmp_updated_lintable: Lintable, + force: bool, + content: str, + updated_content: str, + updated: bool, +) -> None: + """Validate ``Lintable.write`` writes when it should.""" + pre_updated = tmp_updated_lintable.updated + pre_stat = tmp_updated_lintable.path.stat() + + tmp_updated_lintable.write(force=force) + + post_stat = tmp_updated_lintable.path.stat() + post_updated = tmp_updated_lintable.updated + + # write() should not hide that an update happened + assert pre_updated == post_updated == updated + + if force or updated: + assert pre_stat.st_mtime < post_stat.st_mtime + else: + assert pre_stat.st_mtime == post_stat.st_mtime + + with tmp_updated_lintable.path.open("r", encoding="utf-8") as f: + post_content = f.read() + + if updated: + assert content != post_content + else: + assert content == post_content + assert post_content == updated_content + + +@pytest.mark.parametrize( + ("path", "content", "updated_content"), + ( + pytest.param( + "quotes.yaml", + BASIC_PLAYBOOK, + BASIC_PLAYBOOK.replace('"', "'"), + id="updated_quotes", + ), + ), +) +def test_lintable_content_deleter( + tmp_updated_lintable: Lintable, + content: str, + updated_content: str, +) -> None: + """Ensure that resetting content cache triggers re-reading file.""" + assert content != updated_content + assert tmp_updated_lintable.content == updated_content + del tmp_updated_lintable.content + assert tmp_updated_lintable.content == content + + +@pytest.mark.parametrize( + ("path", "result"), + ( + pytest.param("foo", "foo", id="rel"), + pytest.param( + os.path.expanduser("~/xxx"), # noqa: PTH111 + "~/xxx", + id="rel-to-home", + ), + pytest.param("/a/b/c", "/a/b/c", id="absolute"), + pytest.param( + "examples/playbooks/roles", + "examples/roles", + id="resolve-symlink", + ), + ), +) +def test_normpath_path(path: str, result: str) -> None: + """Tests behavior of normpath.""" + assert normpath_path(path) == Path(result) + + +def test_bug_2513( + tmp_path: Path, + default_rules_collection: RulesCollection, +) -> None: + """Regression test for bug 2513. + + Test that when CWD is outside ~, and argument is like ~/playbook.yml + we will still be able to process the files. + See: https://github.com/ansible/ansible-lint/issues/2513 + """ + filename = Path("~/.cache/ansible-lint/playbook.yml").expanduser() + filename.parent.mkdir(parents=True, exist_ok=True) + lintable = Lintable(filename, content="---\n- hosts: all\n") + lintable.write(force=True) + with cwd(tmp_path): + results = Runner(filename, rules=default_rules_collection).run() + assert len(results) == 1 + assert results[0].rule.id == "name" |