1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
|
import re
import shutil
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Any
from collections.abc import Callable, Iterable
import pytest
from testlib.fixtures.env import ManagedEnv
from 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
"""
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
"""
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 | Path):
"""
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):
orig = self.source if isinstance(self.source, Path) else origin / self.source
shutil.copyfile(orig, path)
class CopyTree(Fileish):
def __init__(
self, tree_base: str | Path, ignore: Callable[[str, list[str]], Iterable[str]] | None = None
):
"""
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
self.ignore = ignore
def copy_to(self, path: Path, origin: Path):
orig = self.tree_base if isinstance(self.tree_base, Path) else origin / self.tree_base
shutil.copytree(orig, path, dirs_exist_ok=True, ignore=self.ignore)
class CopyTemplate(_ByContentFileish):
def __init__(self, template: str | Path, 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 = self.template if isinstance(self.template, Path) else origin / self.template
template_content = template_path.read_text()
self.content = BalancedTemplater(template_content).substitute(**self.values)
return self.content
class EnvTemplate(_ByContentFileish):
def __init__(self, template_content: str, mode: int | None = None):
"""
Creates a File with the given content,
but replaces all @ENV_VARIABLE_NAME@ with the according value
:param template_content: String of the file content with placeholders
"""
self.template_content = template_content
self.env = None
super().__init__(mode)
def set_env(self, env: ManagedEnv):
self.env = env
def get_content(self, _: Path) -> str:
keys = re.findall(r"@(\w+)@", self.template_content)
return BalancedTemplater(self.template_content).substitute({k: self.env[k] for k in keys})
class Symlink(Fileish):
def __init__(self, target: str):
"""
Declares a file as a symlink to a different location.
NOTE: due to limitations of symlinks on Windows, tests using this might be flaky and fail!!
:param target: Path to the target where the symlink should be pointing
"""
self.target = target
def copy_to(self, path: Path, _origin: Path):
path.symlink_to(self.target)
class AssetSymlink(Fileish):
def __init__(self, source: str):
"""
Declares a file as a symlink to a local asset file.
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.
Paths are relative to the current test's module.
:raise ValueError: When the given source path is absolute
"""
self.source = source
def copy_to(self, path: Path, origin: Path):
if Path(self.source).is_absolute():
msg = "absolute paths are not allowed"
raise ValueError(msg)
target_path = origin / self.source
path.symlink_to(target_path)
type FileDeclaration = dict[str, Fileish | "FileDeclaration"]
def merge_file_declaration(a: FileDeclaration, b: FileDeclaration) -> FileDeclaration:
result = {}
for key in a.keys() | b.keys():
if (key in a) ^ (key in b):
result[key] = a.get(key) or b.get(key)
continue
if isinstance(a[key], Fileish) or isinstance(b[key], Fileish):
msg = "Cannot merge files; got two different values for the same path"
raise ValueError(msg, key)
try:
result[key] = merge_file_declaration(a[key], b[key])
except ValueError as e:
msg, path = e.args
raise ValueError(msg, f"{key}/{path}")
return result
def _init_files(
files: FileDeclaration, current_folder: Path, caller_path: Path, env: ManagedEnv
) -> None:
"""
This internal function is needed because one cannot call a fixture directly since pytest 4.0
"""
for name, definition in files.items():
destination = current_folder / name
if isinstance(definition, Fileish):
if isinstance(definition, EnvTemplate):
definition.set_env(env)
definition.copy_to(destination, caller_path)
else:
# Initialize subdirectory
destination.mkdir()
_init_files(definition, destination, caller_path, env)
def with_files(*files: FileDeclaration) -> Callable[[Any], Callable[[Any], None]]:
def decorator(func: Callable[[Any], None]) -> Callable[[Any], None]:
return pytest.mark.usefixtures("files")(
pytest.mark.parametrize("files", files, indirect=True)(func)
)
return decorator
@pytest.fixture
def files(env: ManagedEnv, 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 `@with_files(list_of_your_files_to_test, more_files_to_test)` 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 env: environment to get the HOME path from
:param request: Fixture information provided by pytest, used to parametrize the files
:return: Path to where the files were created
"""
home = env.dirs.home
if hasattr(request, "param"):
_init_files(request.param, home, request.path.parent, env)
return home
|