See: cl/4840
When importing Python modules, we include `functional2` in the module
path, like this:
from functional2.testlib.fixtures.env import ManagedEnv
This means that python expects to see a file like
`functional2/testlib/fixtures/env.py`. We run `pytest` from `tests/` in
the `justfile` and have `tests/functional2/__init__.py` so `pytest` in
`meson` is able to find these imports.
However, language servers generally consider the `pyproject.toml` to be
the project root, so (e.g.) `pyright` is unable to follow any of the
`functional2` imports, leading to lots of spurious errors.
In cl/4840 I moved `tests/functional2/pyproject.toml` to
`tests/pyproject.toml`, which worked but was considered aesthetically
unappealing.
This diff is much larger but it's a more elegant solution.
Change-Id: I2983c7b87f88f59a4e3521451a9f5acd6a6a6964
230 lines
7.8 KiB
Python
230 lines
7.8 KiB
Python
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
|