diff options
Diffstat (limited to 'test/test_file_utils.py')
-rw-r--r-- | test/test_file_utils.py | 491 |
1 files changed, 491 insertions, 0 deletions
diff --git a/test/test_file_utils.py b/test/test_file_utils.py new file mode 100644 index 0000000..b427fa8 --- /dev/null +++ b/test/test_file_utils.py @@ -0,0 +1,491 @@ +"""Tests for file utility functions.""" +from __future__ import annotations + +import os +import time +from argparse import Namespace +from pathlib import Path +from typing import Any + +import pytest +from _pytest.capture import CaptureFixture +from _pytest.logging import LogCaptureFixture +from _pytest.monkeypatch import MonkeyPatch + +from ansiblelint import cli, file_utils +from ansiblelint.__main__ import initialize_logger +from ansiblelint.constants import FileType +from ansiblelint.file_utils import ( + Lintable, + expand_path_vars, + expand_paths_vars, + guess_project_dir, + normpath, + normpath_path, +) +from ansiblelint.rules import RulesCollection +from ansiblelint.runner import Runner + +from .conftest import cwd + + +@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("~") + 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"), + ), +) +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 + + +@pytest.mark.parametrize( + ("reset_env_var", "message_prefix"), + ( + # simulate absence of git command + ("PATH", "Failed to locate command: "), + # simulate a missing git repo + ("GIT_DIR", "Looking up for files"), + ), + ids=("no-git-cli", "outside-git-repo"), +) +def test_discover_lintables_git_verbose( + reset_env_var: str, + message_prefix: str, + monkeypatch: MonkeyPatch, + caplog: LogCaptureFixture, +) -> None: + """Ensure that autodiscovery lookup failures are logged.""" + options = cli.get_config(["-v"]) + initialize_logger(options.verbosity) + monkeypatch.setenv(reset_env_var, "") + file_utils.discover_lintables(options) + + assert any(m[2].startswith("Looking up for files") for m in caplog.record_tuples) + assert any(m.startswith(message_prefix) for m in caplog.messages) + + +@pytest.mark.parametrize( + "is_in_git", + (True, False), + ids=("in Git", "outside Git"), +) +def test_discover_lintables_silent( + is_in_git: bool, monkeypatch: MonkeyPatch, capsys: CaptureFixture[str] +) -> 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. + """ + options = cli.get_config([]) + test_dir = Path(__file__).resolve().parent + lint_path = test_dir / ".." / "examples" / "roles" / "test-role" + if not is_in_git: + monkeypatch.setenv("GIT_DIR", "") + + yaml_count = len(list(lint_path.glob("**/*.yml"))) + len( + list(lint_path.glob("**/*.yaml")) + ) + + monkeypatch.chdir(str(lint_path)) + files = file_utils.discover_lintables(options) + stderr = capsys.readouterr().err + assert not stderr, "No stderr output is expected when the verbosity is off" + 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" + + 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", "arg_specs", 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.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" + ), + ), +) +def test_kinds(monkeypatch: MonkeyPatch, path: str, kind: FileType) -> None: + """Verify auto-detection logic based on DEFAULT_KINDS.""" + options = cli.get_config([]) + + # pylint: disable=unused-argument + def mockreturn(options: Namespace) -> dict[str, Any]: + return {normpath(path): kind} + + # assert Lintable is able to determine file type + lintable_detected = Lintable(path) + lintable_expected = Lintable(path, kind=kind) + assert lintable_detected == lintable_expected + + monkeypatch.setattr(file_utils, "discover_lintables", mockreturn) + result = file_utils.discover_lintables(options) + assert lintable_detected.kind == result[lintable_expected.name] + + +def test_guess_project_dir_tmp_path(tmp_path: Path) -> None: + """Verify guess_project_dir().""" + with cwd(str(tmp_path)): + result = guess_project_dir(None) + assert result == str(tmp_path) + + +def test_guess_project_dir_dotconfig() -> None: + """Verify guess_project_dir().""" + with cwd("examples"): + assert os.path.exists( + ".config/ansible-lint.yml" + ), "Test requires config file inside .config folder." + result = guess_project_dir(".config/ansible-lint.yml") + assert result == str(os.getcwd()) + + +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"), "~/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 = "~/.cache/ansible-lint/playbook.yml" + os.makedirs(os.path.dirname(os.path.expanduser(filename)), exist_ok=True) + lintable = Lintable(filename, content="---\n- hosts: all\n") + lintable.write(force=True) + with cwd(str(tmp_path)): + results = Runner(filename, rules=default_rules_collection).run() + assert len(results) == 1 + assert results[0].rule.id == "name" |