summaryrefslogtreecommitdiffstats
path: root/test/test_file_utils.py
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--test/test_file_utils.py538
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"