From 8fe81478507ef9688cccdaeac8b71d9f446804fd Mon Sep 17 00:00:00 2001 From: "Commentator2.0" Date: Tue, 22 Apr 2025 08:55:09 +0200 Subject: [PATCH] Added functions for copying files into tempdir for declarative testing without side effects Resolves: #601 Change-Id: I79661e96149fa133fe003c698efb47f4c54bbf1a --- tests/functional2/conftest.py | 6 + .../functional2/testlib/fixtures/__init__.py | 1 - .../copy_file_template.template | 6 + .../test_file_helper/copy_file_test.txt | 1 + .../assets/test_file_helper/test_folder/a.txt | 1 + .../assets/test_file_helper/test_folder/b.txt | 1 + .../test_folder/sub_test/c.txt | 1 + .../testlib/fixtures/file_helper.py | 189 +++++++++++++ .../functional2/testlib/fixtures/formatter.py | 29 ++ .../testlib/fixtures/test_file_helper.py | 263 ++++++++++++++++++ .../testlib/fixtures/test_formatter.py | 30 ++ 11 files changed, 527 insertions(+), 1 deletion(-) create mode 100644 tests/functional2/testlib/fixtures/assets/test_file_helper/copy_file_template.template create mode 100644 tests/functional2/testlib/fixtures/assets/test_file_helper/copy_file_test.txt create mode 100644 tests/functional2/testlib/fixtures/assets/test_file_helper/test_folder/a.txt create mode 100644 tests/functional2/testlib/fixtures/assets/test_file_helper/test_folder/b.txt create mode 100644 tests/functional2/testlib/fixtures/assets/test_file_helper/test_folder/sub_test/c.txt create mode 100644 tests/functional2/testlib/fixtures/file_helper.py create mode 100644 tests/functional2/testlib/fixtures/formatter.py create mode 100644 tests/functional2/testlib/fixtures/test_file_helper.py create mode 100644 tests/functional2/testlib/fixtures/test_formatter.py diff --git a/tests/functional2/conftest.py b/tests/functional2/conftest.py index 406f97b75..a5d9484ab 100644 --- a/tests/functional2/conftest.py +++ b/tests/functional2/conftest.py @@ -6,3 +6,9 @@ from .testlib import fixtures @pytest.fixture def nix(tmp_path: Path): return fixtures.Nix(tmp_path) + + +pytest_plugins = ( + "functional2.testlib.fixtures.file_helper", + "functional2.testlib.fixtures.formatter", +) diff --git a/tests/functional2/testlib/fixtures/__init__.py b/tests/functional2/testlib/fixtures/__init__.py index 49d686772..f350a9716 100644 --- a/tests/functional2/testlib/fixtures/__init__.py +++ b/tests/functional2/testlib/fixtures/__init__.py @@ -7,7 +7,6 @@ from functools import partialmethod from functional2.testlib.terminal_code_eater import eat_terminal_codes import dataclasses - @dataclasses.dataclass class CommandResult: cmd: list[str] diff --git a/tests/functional2/testlib/fixtures/assets/test_file_helper/copy_file_template.template b/tests/functional2/testlib/fixtures/assets/test_file_helper/copy_file_template.template new file mode 100644 index 000000000..fae4c6b69 --- /dev/null +++ b/tests/functional2/testlib/fixtures/assets/test_file_helper/copy_file_template.template @@ -0,0 +1,6 @@ + +some_key is set to @some_key@ +more of @some_key@ +other_key is @other_key@ +@@not_a_key@@ +@@@key_in_braces@@@ diff --git a/tests/functional2/testlib/fixtures/assets/test_file_helper/copy_file_test.txt b/tests/functional2/testlib/fixtures/assets/test_file_helper/copy_file_test.txt new file mode 100644 index 000000000..daf566bf1 --- /dev/null +++ b/tests/functional2/testlib/fixtures/assets/test_file_helper/copy_file_test.txt @@ -0,0 +1 @@ +some amazing content diff --git a/tests/functional2/testlib/fixtures/assets/test_file_helper/test_folder/a.txt b/tests/functional2/testlib/fixtures/assets/test_file_helper/test_folder/a.txt new file mode 100644 index 000000000..789819226 --- /dev/null +++ b/tests/functional2/testlib/fixtures/assets/test_file_helper/test_folder/a.txt @@ -0,0 +1 @@ +a diff --git a/tests/functional2/testlib/fixtures/assets/test_file_helper/test_folder/b.txt b/tests/functional2/testlib/fixtures/assets/test_file_helper/test_folder/b.txt new file mode 100644 index 000000000..617807982 --- /dev/null +++ b/tests/functional2/testlib/fixtures/assets/test_file_helper/test_folder/b.txt @@ -0,0 +1 @@ +b diff --git a/tests/functional2/testlib/fixtures/assets/test_file_helper/test_folder/sub_test/c.txt b/tests/functional2/testlib/fixtures/assets/test_file_helper/test_folder/sub_test/c.txt new file mode 100644 index 000000000..7359b0fc0 --- /dev/null +++ b/tests/functional2/testlib/fixtures/assets/test_file_helper/test_folder/sub_test/c.txt @@ -0,0 +1 @@ +in the interest of time diff --git a/tests/functional2/testlib/fixtures/file_helper.py b/tests/functional2/testlib/fixtures/file_helper.py new file mode 100644 index 000000000..59685ea46 --- /dev/null +++ b/tests/functional2/testlib/fixtures/file_helper.py @@ -0,0 +1,189 @@ +import shutil +from abc import ABC, abstractmethod +from enum import Enum +from pathlib import Path +from typing import Any, Dict + +import pytest + +from functional2.testlib.fixtures.formatter import BalancedTemplater + + +class Fileish(ABC): + """ + Baseclass, which allows files to be copied declaratively + """ + + @abstractmethod + def copy_to(self, path: Path, origin: Path) -> None: + """ + Copies this file to the given TempDir + :param path: TempDir for the test + :param origin: Directory the tests originates in. Used to adjust relative paths + """ + pass + + +class _ByContentFileish(Fileish, ABC): + def __init__(self, mode: int | None = None): + self.mode = mode + + @abstractmethod + def get_content(self, origin: Path) -> str: + """ + Returns the content, which should be present in the current path + :return: content as a string + """ + pass + + def copy_to(self, path: Path, origin: Path) -> None: + path.write_text(self.get_content(origin)) + if self.mode is not None: + path.chmod(self.mode) + + +class File(_ByContentFileish): + def __init__(self, file_contents: str, mode: int | None = None): + """ + Declares a file by its content + :param file_contents: content of the file as a string + :param mode: Optionally change the mode of the file (e.g. to executable) + """ + super().__init__(mode) + self.file_contents = file_contents + + def get_content(self, _: Path) -> str: + return self.file_contents + + +class CopyFile(Fileish): + def __init__(self, source: str): + """ + Declares a file as a copy of an existing file + :param source: Path to the file to be copied + """ + self.source = source + + def copy_to(self, path: Path, origin: Path): + shutil.copyfile(origin / self.source, path) + + +class CopyTree(Fileish): + def __init__(self, tree_base: str): + """ + Declares a folder as a copy of an existing folder + :param tree_base: base folder of the tree being copied + """ + self.tree_base = tree_base + + def copy_to(self, path: Path, origin: Path): + shutil.copytree(origin / self.tree_base, path, dirs_exist_ok=True) + + +class CopyTemplate(_ByContentFileish): + def __init__(self, template: str, values: Dict[str, Any], mode: int | None = None): + """ + Declares a file as an initiated version of the given file template + :param template: source template's file name. Parameters formatted as `{key_name}` are replaced by corresponding values + :param values: dictionary of key_name and value to be replaced in the template. + :param mode: Optionally change the mode of the file (e.g. to executable) + """ + self.template = template + self.values = values + self.content: str | None = None + + super().__init__(mode) + + def get_content(self, origin: Path) -> str: + template_path = origin / self.template + template_content = template_path.read_text() + + self.content = BalancedTemplater(template_content).substitute(**self.values) + + return self.content + + +class RelativeTo(str, Enum): + TEST = "test" + """ + Base for the given path is the directory of the test + """ + TARGET = "target" + """ + Base for the given path is the directory of the target / where all files are created + """ + ROOT = "root" + """ + Base for the given path is the root folder (/) + """ + SELF = "self" + """ + No base path is given, used to create relative symlinks (e.g. `"../test"`) + """ + + +class Symlink(Fileish): + def __init__(self, source: str, relative_to: RelativeTo = RelativeTo.TARGET): + """ + Declares a file as a symlink to a different location + + NOTE: due to limitations of symlinks on Windows, tests using this might be flakey and fail!! + :param source: Path to the source where the symlink should be pointing + :raise ValueError: When the given source is an absolute Path, but relative to wasn't set to ROOT + """ + self.source = source + self.relative_to = relative_to + + def copy_to(self, path: Path, origin: Path): + if self.relative_to is not RelativeTo.ROOT and Path(self.source).is_absolute(): + msg = "absolute paths are only supported when using RelativeTo.ROOT" + raise ValueError(msg) + match self.relative_to: + case RelativeTo.TEST: + base = origin + case RelativeTo.TARGET: + base = path.parent + case RelativeTo.ROOT: + base = Path("/") + case RelativeTo.SELF: + base = None + case _: + msg = f"unknown relativity {self.relative_to}" + raise ValueError(msg) + target_path = base / self.source if base is not None else Path(self.source) + path.symlink_to(target_path) + + +type FileDeclaration = Dict[str, Fileish | "FileDeclaration"] + + +def _init_files(files: FileDeclaration, tmp_path: Path, request: pytest.FixtureRequest) -> None: + """ + This internal function is needed because one cannot call a fixture directly since pytest 4.0 + """ + + for name, definition in files.items(): + destination = tmp_path / name + if isinstance(definition, Fileish): + definition.copy_to(destination, request.path.parent) + else: + # Initialize subdirectory + destination.mkdir() + _init_files(definition, destination, request) + + +@pytest.fixture +def files(tmp_path: Path, request: pytest.FixtureRequest) -> Path: + """ + Initializes the given files into the TempDir of the test. + This ensures all necessary files and only those are present + To use this add `@pytest.mark.parametrize("files", [list_of_your_files_to_test, more_files_to_test], indirect=True)` above your test. + The test is run once for each of the sets of files provided as the second argument. + Each Set of files should be of the :py:type:`FileDeclaration` type + + :param tmp_path: TempDir of the test + :param request: Fixture information provided by pytest, used to parametrize the files + :return: Path to where the files were created + """ + _init_files(request.param, tmp_path, request) + return tmp_path diff --git a/tests/functional2/testlib/fixtures/formatter.py b/tests/functional2/testlib/fixtures/formatter.py new file mode 100644 index 000000000..e96c93a49 --- /dev/null +++ b/tests/functional2/testlib/fixtures/formatter.py @@ -0,0 +1,29 @@ +import string +from string import Template +from typing import Dict, Type + +import pytest + + +class BalancedTemplater(Template): + delimiter = "@" + pattern = r"@((?P@)|(?P\w+?)@|\{(?P\w+?)\}@|(?P.*?))" + + def substitute(self, mapping: Dict[str, object] | None = None, /, **kwargs) -> str: + if mapping is None: + mapping = {} + tmpl_idents = set(self.get_identifiers()) + subs_idents = mapping.keys() | kwargs.keys() + errs = [] + for key in subs_idents - tmpl_idents: + msg = f"Unused named argument `{key}` with value: {kwargs.get(key, mapping.get(key))}" + errs.append(KeyError(msg)) + if len(errs) > 0: + msg = "Unused arguments passed to substitute" + raise ExceptionGroup(msg, errs) + return super().substitute(mapping, **kwargs) + + +@pytest.fixture +def balanced_templater() -> Type[string.Template]: + return BalancedTemplater diff --git a/tests/functional2/testlib/fixtures/test_file_helper.py b/tests/functional2/testlib/fixtures/test_file_helper.py new file mode 100644 index 000000000..4ecbe85ad --- /dev/null +++ b/tests/functional2/testlib/fixtures/test_file_helper.py @@ -0,0 +1,263 @@ +from pathlib import Path +from textwrap import dedent + +import pytest +from functional2.testlib.fixtures.file_helper import ( + CopyFile, + CopyTemplate, + CopyTree, + File, + Symlink, + RelativeTo, +) + + +@pytest.mark.parametrize( + "files", [{"test_file_1": File("this is some test content")}], indirect=True +) +def test_file_creates(files: Path): + file_name = "test_file_1" + file_content = "this is some test content" + + target_path = files / file_name + assert target_path.exists() + assert target_path.read_text() == file_content + + +@pytest.mark.parametrize("files", [{"test_file_2": File("asdf")}], indirect=True) +def test_no_file_leakage(files: Path): + file_name = "test_file_2" + file_content = "asdf" + + some_other_file_name = "test_file_1" + + assert (files / file_name).exists() + assert (files / file_name).read_text() == file_content + # Check for no leakage from other tests + assert not (files / some_other_file_name).exists() + + +@pytest.mark.parametrize( + "files", + [{"copy_file_test.txt": CopyFile("assets/test_file_helper/copy_file_test.txt")}], + indirect=True, +) +def test_copy_file_no_name(files: Path): + target_path = files / "copy_file_test.txt" + + assert target_path.exists() + + assert target_path.read_text() == "some amazing content\n" + + +@pytest.mark.parametrize( + "files", + [{"new_name.txt": CopyFile("assets/test_file_helper/copy_file_test.txt")}], + indirect=True, +) +def test_copy_file_with_name(files: Path): + assert (files / "new_name.txt").exists() + assert not (files / "copy_file_test.txt").exists() + + +@pytest.mark.parametrize( + "files", + [ + { + "target.txt": CopyTemplate( + "assets/test_file_helper/copy_file_template.template", + {"some_key": "abc", "other_key": 123, "key_in_braces": "in_braces"}, + ) + } + ], + indirect=True, +) +def test_template(files: Path): + assert (files / "target.txt").exists() + + expected_content = dedent(""" + some_key is set to abc + more of abc + other_key is 123 + @not_a_key@ + @in_braces@ + """) + + actual_content = (files / "target.txt").read_text() + assert actual_content == expected_content + + +@pytest.mark.parametrize( + "files", + [ + { + "target.txt": CopyTemplate( + "assets/test_file_helper/copy_file_template.template", + {"some_key": "abc", "key_in_braces": "in_braces"}, + ) + } + ], + indirect=True, +) +@pytest.mark.xfail(raises=KeyError) +def test_template_missing_key(files: Path): + # Empty, because the initialization of the CopyTemplate fails. Caught and tested for by the xfail mark + ... + + +@pytest.mark.parametrize( + "files", + [ + { + "target.txt": CopyTemplate( + "assets/test_file_helper/copy_file_template.template", + { + "some_key": "Eragon", + "key_in_braces": "in_braces", + "other_key": "Arthur Leywin", + "the beginning": "after the end", + }, + ) + } + ], + indirect=True, +) +@pytest.mark.xfail(raises=ExceptionGroup) +def test_template_too_many_keys(files: Path): + # Empty, because the initialization of the CopyTemplate fails. Caught and tested for by the xfail mark + ... + + +@pytest.mark.parametrize( + "files", [{"some_folder": CopyTree("assets/test_file_helper/test_folder")}], indirect=True +) +def test_copy_tree(files: Path): + files = files / "some_folder" + assert (files / "a.txt").exists() + assert (files / "b.txt").exists() + assert (files / "sub_test").exists() + assert (files / "sub_test" / "c.txt").exists() + + +@pytest.mark.parametrize( + "files", + [ + { + "a.txt": File("Hello World"), + "assembler.txt": File("reinforced iron plates\n"), + "folder_1": { + "empty.nix": File(""), + "folder_2": {"not_empty.py": File("print('Arrruuuuuuuuuuuu')")}, + }, + } + ], + indirect=True, +) +def test_files_sub_dirs(files: Path): + f = files / "a.txt" + assert f.exists() + assert f.read_text() == "Hello World" + + f = files / "assembler.txt" + assert f.exists() + + fldr = files / "folder_1" + assert fldr.exists() + f = fldr / "empty.nix" + assert f.exists() + + fldr /= "folder_2" + assert fldr.exists() + + f = fldr / "not_empty.py" + assert f.exists() + + +@pytest.mark.parametrize( + "files", [{"not_empty": {"a.txt": File("Hello World")}, "empty": {}}], indirect=True +) +def test_creates_empty_directory(files: Path): + assert (files / "not_empty").exists() + assert (files / "empty").exists() + assert (files / "empty").is_dir() + + +@pytest.mark.parametrize("files", [{"a.sh": File("echo test", mode=0o477)}], indirect=True) +def test_mode_setting(files: Path): + file = files / "a.sh" + assert file.exists() + assert file.stat().st_mode & 0o477 == 0o477 + + +@pytest.mark.parametrize( + "files", + [{"a": Symlink("assets/test_file_helper/copy_file_test.txt", RelativeTo.TEST)}], + indirect=True, +) +def test_file_symlink(files: Path): + file = files / "a" + assert file.exists(follow_symlinks=False) + + assert file.is_symlink() + + assert file.readlink().exists() + + assert file.read_text() == "some amazing content\n" + + +@pytest.mark.parametrize( + "files", + [{"a": Symlink("test_folder/b.txt", RelativeTo.TARGET), "test_folder": {"b.txt": File("zzz")}}], + indirect=True, +) +def test_file_symlink_target(files: Path): + file = files / "a" + assert file.exists(follow_symlinks=False) + + assert file.is_symlink() + + assert file.readlink().exists() + + assert file.read_text() == "zzz" + + +@pytest.mark.parametrize( + "files", [{"a": Symlink("assets/test_file_helper/test_folder", RelativeTo.TEST)}], indirect=True +) +def test_dir_symlink(files: Path): + folder = files / "a" + assert folder.exists(follow_symlinks=False) + + assert folder.is_symlink() + + assert folder.readlink().exists() + + assert folder.readlink().is_dir() + + assert (folder / "a.txt").exists() + + +@pytest.mark.parametrize( + "files", + [{"a": Symlink("assets/test_file_helper/this_does_not_exist", RelativeTo.TEST)}], + indirect=True, +) +def test_invalid_symlink(files: Path): + link = files / "a" + assert link.exists(follow_symlinks=False) + assert link.is_symlink() + assert not link.readlink().exists() + + +@pytest.mark.parametrize( + "files", + [{"tg": File("Hello World"), "folder": {"link": Symlink("../tg", RelativeTo.SELF)}}], + indirect=True, +) +def test_direct_relativity_symlink(files: Path): + assert (files / "tg").exists() + link = files / "folder" / "link" + assert link.exists(follow_symlinks=False) + assert link.is_symlink() + # check that this is actually relative and not an absolute path + assert str(link.readlink()) == "../tg" diff --git a/tests/functional2/testlib/fixtures/test_formatter.py b/tests/functional2/testlib/fixtures/test_formatter.py new file mode 100644 index 000000000..a576c851f --- /dev/null +++ b/tests/functional2/testlib/fixtures/test_formatter.py @@ -0,0 +1,30 @@ +from string import Template +from typing import Type + +import pytest + + +def test_template_balanced_kwargs(balanced_templater: Type[Template]): + assert balanced_templater("@test_str@").substitute(test_str="valid") == "valid" + + +def test_template_balanced_multi_use(balanced_templater: Type[Template]): + assert balanced_templater("@test_str@ @test_str@").substitute(test_str="valid") == "valid valid" + + +def test_balanced_escapement(balanced_templater: Type[Template]): + assert balanced_templater("@@ @@{test} @{test}@").substitute(test="t") == "@ @{test} t" + + +# And that correct exceptions get thrown +def test_template_balanced_extra_kwarg(balanced_templater: Type[Template]): + with pytest.raises(ExceptionGroup) as excinfo: + balanced_templater("{test_str}").substitute(test_str="valid", random_str="random") + assert excinfo.group_contains(KeyError, match=r"random_str") + assert not excinfo.group_contains(ValueError) + + +def test_template_balanced_missing_arg(balanced_templater: Type[Template]): + with pytest.raises(KeyError) as e: + balanced_templater("@TEST@ @MISSING@").substitute(TEST="valid") + assert e.match("MISSING")