Merge "Added functions for copying files into tempdir for declarative testing without side effects" into main

This commit is contained in:
Commentator2.0
2025-05-09 11:15:11 +00:00
committed by Lix Systems Gerrit
11 changed files with 527 additions and 1 deletions
+6
View File
@@ -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",
)
@@ -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]
@@ -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@@@
@@ -0,0 +1 @@
some amazing content
@@ -0,0 +1 @@
in the interest of time
@@ -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
@@ -0,0 +1,29 @@
import string
from string import Template
from typing import Dict, Type
import pytest
class BalancedTemplater(Template):
delimiter = "@"
pattern = r"@((?P<escaped>@)|(?P<named>\w+?)@|\{(?P<braced>\w+?)\}@|(?P<invalid>.*?))"
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
@@ -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"
@@ -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")