Added functions for copying files into tempdir for declarative testing without side effects
Resolves: #601 Change-Id: I79661e96149fa133fe003c698efb47f4c54bbf1a
This commit is contained in:
@@ -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 @@
|
||||
a
|
||||
@@ -0,0 +1 @@
|
||||
b
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user