`test_root` can never be `None`. Change-Id: I9ed344b55a733ad92ffa79e6799e44d97285109f Signed-off-by: Raito Bezarius <raito@lix.systems>
281 lines
10 KiB
Python
281 lines
10 KiB
Python
import dataclasses
|
|
import logging
|
|
import platform
|
|
import shutil
|
|
from pathlib import Path
|
|
|
|
from functional2.testlib.environ import environ
|
|
|
|
import pytest
|
|
|
|
SLASHES_IN_STORE_PATH_UNTIL_PACKAGE = "/nix/store/hash-program_name/".count("/")
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclasses.dataclass
|
|
class _ManagedPath:
|
|
"""
|
|
Wrapper class to handle building the `PATH` environment variable
|
|
"""
|
|
|
|
build_shell: dataclasses.InitVar[str | None]
|
|
"""statically linked shell to use within builds which provides coreutils functionality"""
|
|
_path: list[str] = dataclasses.field(default_factory=list)
|
|
|
|
def __post_init__(self, build_shell: str | None):
|
|
if build_shell:
|
|
self.prepend(build_shell)
|
|
|
|
def to_path(self) -> str:
|
|
"""
|
|
:return: string to be put into the `PATH` environment variable containing all added paths/programs
|
|
"""
|
|
return ":".join(self._path)
|
|
|
|
def prepend(self, exec_path: str | Path) -> "_ManagedPath":
|
|
"""
|
|
Adds the given file or folder at the FRONT of the path variable
|
|
:param exec_path: executable or folder containing executables to be added
|
|
:return: self, to allow for chaining
|
|
"""
|
|
self._path.insert(0, str(exec_path))
|
|
return self
|
|
|
|
def append(self, exec_path: str | Path) -> "_ManagedPath":
|
|
"""
|
|
Adds the given file or folder at the END of the path variable
|
|
:param exec_path: executable or folder containing executables to be added
|
|
:return: self, to allow for chaining
|
|
"""
|
|
self._path.append(str(exec_path))
|
|
return self
|
|
|
|
def insert_at(self, exec_path: str | Path, index: int) -> "_ManagedPath":
|
|
"""
|
|
Adds the given file or folder at the GIVEN INDEX of the path variable
|
|
:param exec_path: executable or folder containing executable to be added
|
|
:param index: where to insert the path
|
|
:return: self, to allow for chaining
|
|
"""
|
|
self._path.insert(index, str(exec_path))
|
|
return self
|
|
|
|
def add_program(self, program_name: str, all_associated: bool = True) -> "_ManagedPath":
|
|
"""
|
|
Adds the given program to the path by name.
|
|
:param program_name: executable/program to add
|
|
:param all_associated: if True, the folder containing the executable will be added instead. Otherwise, only the provided executable will be added
|
|
:raises ValueError: if the program could not be found
|
|
:return: self, to allow for chaining
|
|
"""
|
|
path = shutil.which(program_name)
|
|
if path is None:
|
|
msg = f"Couldn't find program {program_name!r}"
|
|
raise ValueError(msg)
|
|
# Convert to path object for better checking and operations
|
|
path = Path(path)
|
|
if all_associated and not path.is_dir():
|
|
path = path.parent
|
|
# handle as string within the data structure
|
|
path = str(path)
|
|
if path not in self._path:
|
|
self._path.append(path)
|
|
return self
|
|
|
|
def remove_path(self, exec_path: str | Path) -> "_ManagedPath":
|
|
"""
|
|
Removes the given file or folder from the path
|
|
:param exec_path: file or folder to remove
|
|
:raises ValueError: if the file or folder could not be found
|
|
:return: self, to allow for chaining
|
|
"""
|
|
self._path.remove(str(exec_path))
|
|
return self
|
|
|
|
def remove_program(self, program_name: str) -> "_ManagedPath":
|
|
"""
|
|
Removes the given executable/program from the path by name
|
|
:param program_name: executable/program to remove
|
|
:raises ValueError: if the program was not found or isn't present in path
|
|
:return: self, to allow for chaining
|
|
"""
|
|
path = shutil.which(program_name)
|
|
if path is None:
|
|
msg = f"Couldn't find program {program_name!r}"
|
|
raise ValueError(msg)
|
|
if path in self._path or (path := str(Path(path).parent)) in self._path:
|
|
self.remove_path(path)
|
|
else:
|
|
# Mirror behavior of `remove_path`
|
|
msg = f"path.remove({program_name}): {program_name} not in path"
|
|
raise ValueError(msg)
|
|
return self
|
|
|
|
def to_sandbox_paths(self) -> list[str]:
|
|
"""
|
|
:return: list of strings to put into the `sandbox_paths` nix setting
|
|
"""
|
|
ret = []
|
|
for p in self._path:
|
|
if p.startswith("/nix/store/"):
|
|
# adds the entire package to the sandbox,
|
|
# to ensure that dependencies and libraries from within the package are also present
|
|
ret.append("/".join(p.split("/")[:SLASHES_IN_STORE_PATH_UNTIL_PACKAGE]))
|
|
else:
|
|
ret.append(p)
|
|
return ret
|
|
|
|
def which(self, program_name: str) -> Path:
|
|
path = shutil.which(program_name, path=self.to_path())
|
|
if not path:
|
|
raise ValueError(f"{program_name} is not in configured path")
|
|
return Path(path)
|
|
|
|
|
|
@dataclasses.dataclass
|
|
class _Dirs:
|
|
test_root: Path
|
|
home: Path
|
|
nix_log_dir: Path | None
|
|
nix_state_dir: Path | None
|
|
nix_conf_dir: Path | None
|
|
nix_bin_dir: Path | None
|
|
# this one *must not* be NIX_STORE_DIR, otherwise lix will pick it up
|
|
# and misconfigure itself. the config system is unbelievable bullshit
|
|
real_store_dir: Path | None
|
|
cache_dir: Path | None
|
|
xdg_cache_home: Path | None
|
|
tmpdir: Path | None
|
|
"""used for nar caching"""
|
|
nix_store_dir: Path | None = None
|
|
"""
|
|
store dir override for building on macos *specifically*. do not
|
|
use or set this for any other purpose or many tests will break.
|
|
"""
|
|
|
|
def get_env_keys(self) -> set[str]:
|
|
return {f.name.upper() for f in dataclasses.fields(self)}
|
|
|
|
def to_env_vars(self) -> dict[str, str]:
|
|
return {k.upper(): v for k, v in dataclasses.asdict(self).items() if v is not None}
|
|
|
|
|
|
class ManagedEnv:
|
|
def __init__(self, tmp_path: Path):
|
|
# Things fetched from the global env
|
|
build_shell = environ.get("BUILD_TEST_SHELL")
|
|
global_path = environ.get("PATH")
|
|
build_env = environ.get("BUILD_TEST_ENV")
|
|
# `NIX_BIN_DIR` either propagated from us or set by meson
|
|
# Set to the codebase internal output if started standalone
|
|
# This is where the current lix binaries are located.
|
|
# local import to avoid cyclic dependencies
|
|
from functional2.testlib.utils import lix_base_folder # noqa: PLC0415
|
|
|
|
lix_bin = Path(environ.get("NIX_BIN_DIR", lix_base_folder / "outputs/out/bin"))
|
|
|
|
self._env = {}
|
|
self.path = _ManagedPath(build_env)
|
|
self._tmp_path = tmp_path
|
|
self.shell_dir = build_shell or "/bin"
|
|
self.build_env = build_env
|
|
|
|
self.dirs = _Dirs(
|
|
test_root=self._get_dir(""),
|
|
home=self._get_dir("test-home"),
|
|
nix_log_dir=self._get_dir("var/log/nix"),
|
|
nix_state_dir=self._get_dir("var/nix"),
|
|
nix_conf_dir=self._get_dir("etc/nix"),
|
|
nix_bin_dir=lix_bin,
|
|
real_store_dir=self._get_dir("nix/store"),
|
|
cache_dir=self._get_dir("binary-cache"),
|
|
xdg_cache_home=self._get_dir("test-home/.cache"),
|
|
tmpdir=self._get_dir("tmp"),
|
|
)
|
|
self.path.prepend(self.dirs.nix_bin_dir)
|
|
self.path.prepend(self.shell_dir)
|
|
self.init_defaults(global_path)
|
|
|
|
def _get_dir(self, sub_path: str) -> Path:
|
|
p = self._tmp_path / sub_path
|
|
p.mkdir(parents=True, exist_ok=True)
|
|
return p
|
|
|
|
def init_defaults(self, global_path: str):
|
|
self._env = {
|
|
# Do not use the system-wide or local config for git, but *none* instead
|
|
"GIT_CONFIG_SYSTEM": "/dev/null",
|
|
# Shell to use, required by lix to run sub processes / commands
|
|
"SHELL": f"{self.shell_dir}/sh",
|
|
# when writing things to the terminal (esp with man pages) use cat, to print the full output to stdout
|
|
"PAGER": "cat",
|
|
"BUILD_TEST_SHELL": self.shell_dir,
|
|
}
|
|
if self.build_env:
|
|
self._env["BUILD_TEST_ENV"] = self.build_env
|
|
if platform.system() == "Darwin":
|
|
# Darwin / Apple behaves differently and requires _NIX_TEST_NO_SANDBOX to be set for whatever reason
|
|
self._env |= {"_NIX_TEST_NO_SANDBOX": "1"}
|
|
# copy global path to maintain features usually provided by busybox
|
|
[self.path.append(p) for p in global_path.split(":") if Path(p).exists()]
|
|
|
|
def set_env(self, name: str, value: str):
|
|
if name in self.dirs.get_env_keys():
|
|
msg = f"Overriding paths should be done using the `env.dirs` attribute, use `env.dirs.{name.lower()}` instead."
|
|
raise ValueError(msg)
|
|
if name == "PATH":
|
|
msg = "Setting of path not supported. use `env.path` instead"
|
|
raise ValueError(msg)
|
|
if value is None:
|
|
msg = "setting to `None` is not allowed. did you mean to use `env.unset_env`?"
|
|
raise ValueError(msg)
|
|
self._env[name] = value
|
|
|
|
def __setitem__(self, key: str, value: str) -> None:
|
|
return self.set_env(key, value)
|
|
|
|
def get_env(self, name: str, default: str | Path | None = None) -> str | Path | None:
|
|
if name in self.dirs.get_env_keys():
|
|
return getattr(self.dirs, name.lower())
|
|
if name == "PATH":
|
|
msg = "getting of path not supported, use `env.path` instead"
|
|
raise ValueError(msg)
|
|
return self._env.get(name, default)
|
|
|
|
def __getitem__(self, item: str) -> str | Path | None:
|
|
itm = self.get_env(item)
|
|
if itm is None:
|
|
msg = f"{itm} is not set"
|
|
raise KeyError(msg)
|
|
return itm
|
|
|
|
def unset_env(self, name: str) -> str | None:
|
|
if name in self.dirs.get_env_keys():
|
|
msg = f"Overriding paths should be done using the `env.dirs` attribute, use `env.dirs.{name.lower()}` instead."
|
|
raise ValueError(msg)
|
|
return self._env.pop(name, None)
|
|
|
|
def __delitem__(self, key: str) -> str | None:
|
|
itm = self.unset_env(key)
|
|
if itm is None:
|
|
msg = f"{itm} is not set"
|
|
raise KeyError(msg)
|
|
return itm
|
|
|
|
def to_env(self) -> dict[str, str]:
|
|
ret = self.dirs.to_env_vars()
|
|
ret["PATH"] = self.path.to_path()
|
|
for k, v in self._env.copy().items():
|
|
if v is not None:
|
|
ret[k] = v
|
|
else:
|
|
logger.warning("environment variable '%s' is none", k)
|
|
return ret
|
|
|
|
|
|
@pytest.fixture
|
|
def env(tmp_path: Path) -> ManagedEnv:
|
|
return ManagedEnv(tmp_path)
|