Merge "move nix fixture to own file" into main

This commit is contained in:
Commentator2.0
2025-05-09 11:15:52 +00:00
committed by Lix Systems Gerrit
10 changed files with 259 additions and 256 deletions
@@ -7,7 +7,7 @@ import pytest
from pathlib import Path
from textwrap import dedent
from functional2.testlib.fixtures import Nix
from functional2.testlib.fixtures.nix import Nix
COMMANDS = ["copy-closure", "collect-garbage"]
+1 -10
View File
@@ -1,14 +1,5 @@
import pytest
from pathlib import Path
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",
"functional2.testlib.fixtures.nix",
)
+1 -1
View File
@@ -1,4 +1,4 @@
from functional2.testlib.fixtures import Nix
from functional2.testlib.fixtures.nix import Nix
import pytest
from typing import NamedTuple
from textwrap import dedent
+1 -1
View File
@@ -1,4 +1,4 @@
from functional2.testlib.fixtures import Nix
from functional2.testlib.fixtures.nix import Nix
def test_trivial_addition(nix: Nix):
assert nix.eval('1 + 1').json() == 2
@@ -1,4 +1,4 @@
from functional2.testlib.fixtures import Nix
from functional2.testlib.fixtures.nix import Nix
def test_fetchTree_presence(nix: Nix):
@@ -1,6 +1,6 @@
from pathlib import Path
from textwrap import dedent
from functional2.testlib.fixtures import Nix
from functional2.testlib.fixtures.nix import Nix
import re
def test_invalid_flake_lock(nix: Nix, tmp_path: Path):
+1 -1
View File
@@ -1,6 +1,6 @@
from pathlib import Path
from textwrap import dedent
from functional2.testlib.fixtures import Nix
from functional2.testlib.fixtures.nix import Nix
import re
+13 -4
View File
@@ -1,10 +1,19 @@
import pytest
import os
import unicodedata
from pathlib import Path
from ..testlib.nar import *
from ..testlib.fixtures import Nix
from io import BytesIO
from pathlib import Path
import pytest
from ..testlib.fixtures.nix import Nix
from ..testlib.nar import (
DirectoryUnordered,
NarItem,
NarListener,
Regular,
Symlink,
write_with_export_header,
)
meow_orig = 'méow'
meow_nfc_ = unicodedata.normalize('NFC', meow_orig)
@@ -1,236 +0,0 @@
import os
import json
import subprocess
from typing import Any
from pathlib import Path
from functools import partialmethod
from functional2.testlib.terminal_code_eater import eat_terminal_codes
import dataclasses
@dataclasses.dataclass
class CommandResult:
cmd: list[str]
rc: int
"""Return code"""
stderr: bytes
"""Outputted stderr"""
stdout: bytes
"""Outputted stdout"""
def ok(self):
if self.rc != 0:
print('stdout:', self.stdout_s)
print('stderr:', self.stderr_s)
raise subprocess.CalledProcessError(returncode=self.rc,
cmd=self.cmd,
stderr=self.stderr,
output=self.stdout)
return self
def expect(self, rc: int):
if self.rc != rc:
print('stdout:', self.stdout_s)
print('stderr:', self.stderr_s)
raise subprocess.CalledProcessError(returncode=self.rc,
cmd=self.cmd,
stderr=self.stderr,
output=self.stdout)
return self
@property
def stdout_s(self) -> str:
"""Command stdout as str"""
return self.stdout.decode('utf-8', errors='replace')
@property
def stderr_s(self) -> str:
"""Command stderr as str"""
return self.stderr.decode('utf-8', errors='replace')
@property
def stdout_plain(self) -> str:
"""Command stderr as str with terminal escape sequences eaten and whitespace stripped"""
return eat_terminal_codes(self.stdout).decode('utf-8', errors='replace').strip()
@property
def stderr_plain(self) -> str:
"""Command stderr as str with terminal escape sequences eaten and whitespace stripped"""
return eat_terminal_codes(self.stderr).decode('utf-8', errors='replace').strip()
def json(self) -> Any:
self.ok()
return json.loads(self.stdout)
@dataclasses.dataclass
class NixSettings:
"""Settings for invoking Nix"""
experimental_features: set[str] | None = None
store: str | None = None
"""
The store to operate on (may be a path or other thing, see nix help-stores).
Note that this can be set to the test's store directory if you want to use
/nix/store paths inside that test rather than NIX_STORE_DIR renaming
/nix/store to some unstable name (assuming that no builds are invoked).
"""
nix_store_dir: Path | None = None
"""
Alternative name to use for /nix/store: breaks all references and NAR imports if
set, but does allow builds in tests (since builds do not require chroots if
the store is relocated).
"""
def feature(self, *names: str):
self.experimental_features = (self.experimental_features
or set()) | set(names)
return self
def to_config(self) -> str:
config = ''
def serialise(value):
if type(value) in {str, int}:
return str(value)
elif type(value) in {list, set}:
return ' '.join(str(e) for e in value)
else:
raise ValueError(
f'Value is unsupported in nix config: {value!r}')
def field_may(name, value, serialiser=serialise):
nonlocal config
if value is not None:
config += f'{name} = {serialiser(value)}\n'
field_may('experimental-features', self.experimental_features)
field_may('store', self.store)
assert self.store or self.nix_store_dir, 'Failing to set either nix_store_dir or store will cause accidental use of the system store.'
return config
def to_env_overlay(self) -> dict[str, str]:
ret = {'NIX_CONFIG': self.to_config()}
if self.nix_store_dir:
ret['NIX_STORE_DIR'] = str(self.nix_store_dir)
return ret
@dataclasses.dataclass
class Command:
argv: list[str]
env: dict[str, str] = dataclasses.field(default_factory=dict)
stdin: bytes | None = None
cwd: Path | None = None
def with_env(self, **kwargs) -> 'Command':
self.env.update(kwargs)
return self
def with_stdin(self, stdin: bytes) -> 'Command':
self.stdin = stdin
return self
def run(self) -> CommandResult:
proc = subprocess.Popen(
self.argv,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
stdin=subprocess.PIPE if self.stdin else subprocess.DEVNULL,
cwd=self.cwd,
env=self.env,
)
(stdout, stderr) = proc.communicate(input=self.stdin)
rc = proc.returncode
return CommandResult(cmd=self.argv,
rc=rc,
stdout=stdout,
stderr=stderr)
@dataclasses.dataclass
class NixCommand(Command):
settings: NixSettings = dataclasses.field(default_factory=NixSettings)
def apply_nix_config(self):
self.env.update(self.settings.to_env_overlay())
def run(self) -> CommandResult:
self.apply_nix_config()
return super().run()
@dataclasses.dataclass
class Nix:
test_root: Path
def hermetic_env(self):
# mirroring vars-and-functions.sh
home = self.test_root / 'test-home'
home.mkdir(parents=True, exist_ok=True)
return {
'NIX_LOCALSTATE_DIR': self.test_root / 'var',
'NIX_LOG_DIR': self.test_root / 'var/log/nix',
'NIX_STATE_DIR': self.test_root / 'var/nix',
'NIX_CONF_DIR': self.test_root / 'etc',
'NIX_DAEMON_SOCKET_PATH': self.test_root / 'daemon-socket',
'NIX_USER_CONF_FILES': '',
'HOME': home,
}
def make_env(self):
# We conservatively assume that people might want to successfully get
# some env through to the subprocess, so we override whatever is in the
# global env.
d = os.environ.copy()
d.update(self.hermetic_env())
return d
def cmd(self, argv: list[str]):
return Command(argv=argv, cwd=self.test_root, env=self.make_env())
def settings(self, allow_builds: bool = False):
"""
Parameters:
- allow_builds: relocate the Nix store so that builds work (however, makes store paths non-reproducible across test runs!)
"""
settings = NixSettings()
store_path = self.test_root / 'store'
if allow_builds:
settings.nix_store_dir = store_path
else:
settings.store = str(store_path)
return settings
def nix_cmd(self, argv: list[str], flake: bool = False):
"""
Constructs a NixCommand with the appropriate settings.
"""
settings = self.settings()
if flake:
settings.feature('nix-command', 'flakes')
return NixCommand(argv=argv,
cwd=self.test_root,
env=self.make_env(),
settings=settings)
def nix(self, cmd: list[str], nix_exe: str = 'nix', flake: bool = False) -> NixCommand:
return self.nix_cmd([nix_exe, *cmd], flake=flake)
nix_build = partialmethod(nix, nix_exe='nix-build')
nix_shell = partialmethod(nix, nix_exe='nix-shell')
nix_store = partialmethod(nix, nix_exe='nix-store')
nix_env = partialmethod(nix, nix_exe='nix-env')
nix_instantiate = partialmethod(nix, nix_exe='nix-instantiate')
nix_channel = partialmethod(nix, nix_exe='nix-channel')
nix_prefetch_url = partialmethod(nix, nix_exe='nix-prefetch-url')
def eval(
self, expr: str,
settings: NixSettings | None = None) -> CommandResult:
# clone due to reference-shenanigans
settings = dataclasses.replace(settings or self.settings()).feature('nix-command')
cmd = self.nix(['eval', '--json', '--expr', expr])
cmd.settings = settings
return cmd.run()
+239
View File
@@ -0,0 +1,239 @@
import dataclasses
import json
import logging
import os
import subprocess
from functools import partialmethod
from pathlib import Path
from typing import Any, AnyStr, Callable, Dict
import pytest
from functional2.testlib.terminal_code_eater import eat_terminal_codes
@dataclasses.dataclass
class CommandResult:
cmd: list[str]
rc: int
"""Return code"""
stderr: bytes
"""Outputted stderr"""
stdout: bytes
"""Outputted stdout"""
def ok(self) -> "CommandResult":
if self.rc != 0:
print("stdout: %s", self.stderr_s)
print("stderr: %s", self.stderr_s)
raise subprocess.CalledProcessError(
returncode=self.rc, cmd=self.cmd, stderr=self.stderr, output=self.stdout
)
return self
def expect(self, rc: int) -> "CommandResult":
if self.rc != rc:
print("stdout: %s", self.stderr_s)
print("stderr: %s", self.stderr_s)
raise subprocess.CalledProcessError(
returncode=self.rc, cmd=self.cmd, stderr=self.stderr, output=self.stdout
)
return self
@property
def stdout_s(self) -> str:
"""Command stdout as str"""
return self.stdout.decode("utf-8", errors="replace")
@property
def stderr_s(self) -> str:
"""Command stderr as str"""
return self.stderr.decode("utf-8", errors="replace")
@property
def stdout_plain(self) -> str:
"""Command stderr as str with terminal escape sequences eaten and whitespace stripped"""
return eat_terminal_codes(self.stdout).decode("utf-8", errors="replace").strip()
@property
def stderr_plain(self) -> str:
"""Command stderr as str with terminal escape sequences eaten and whitespace stripped"""
return eat_terminal_codes(self.stderr).decode("utf-8", errors="replace").strip()
def json(self) -> Any:
self.ok()
return json.loads(self.stdout)
@dataclasses.dataclass
class NixSettings:
"""Settings for invoking Nix"""
experimental_features: set[str] | None = None
store: str | None = None
"""
The store to operate on (may be a path or other thing, see nix help-stores).
Note that this can be set to the test's store directory if you want to use
/nix/store paths inside that test rather than NIX_STORE_DIR renaming
/nix/store to some unstable name (assuming that no builds are invoked).
"""
nix_store_dir: Path | None = None
"""
Alternative name to use for /nix/store: breaks all references and NAR imports if
set, but does allow builds in tests (since builds do not require chroots if
the store is relocated).
"""
def feature(self, *names: str) -> "NixSettings":
self.experimental_features = (self.experimental_features or set()) | set(names)
return self
def to_config(self) -> str:
config = ""
def serialise(value: Any) -> str:
if type(value) in {str, int}:
return str(value)
if type(value) in {list, set}:
return " ".join(str(e) for e in value)
msg = f"Value is unsupported in nix config: {value!r}"
raise ValueError(msg)
def field_may(name: str, value: Any, serializer: Callable[[Any], str] = serialise):
nonlocal config
if value is not None:
config += f"{name} = {serializer(value)}\n"
field_may("experimental-features", self.experimental_features)
field_may("store", self.store)
assert (
self.store or self.nix_store_dir
), "Failing to set either nix_store_dir or store will cause accidental use of the system store."
return config
def to_env_overlay(self) -> dict[str, str]:
ret = {"NIX_CONFIG": self.to_config()}
if self.nix_store_dir:
ret["NIX_STORE_DIR"] = str(self.nix_store_dir)
return ret
@dataclasses.dataclass
class Command:
argv: list[str]
env: dict[str, str] = dataclasses.field(default_factory=dict)
stdin: bytes | None = None
cwd: Path | None = None
def with_env(self, **kwargs) -> "Command":
self.env.update(kwargs)
return self
def with_stdin(self, stdin: bytes) -> "Command":
self.stdin = stdin
return self
def run(self) -> CommandResult:
proc = subprocess.Popen(
self.argv,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
stdin=subprocess.PIPE if self.stdin else subprocess.DEVNULL,
cwd=self.cwd,
env=self.env,
)
(stdout, stderr) = proc.communicate(input=self.stdin)
rc = proc.returncode
return CommandResult(cmd=self.argv, rc=rc, stdout=stdout, stderr=stderr)
@dataclasses.dataclass
class NixCommand(Command):
settings: NixSettings = dataclasses.field(default_factory=NixSettings)
def apply_nix_config(self):
self.env.update(self.settings.to_env_overlay())
def run(self) -> CommandResult:
self.apply_nix_config()
return super().run()
@dataclasses.dataclass
class Nix:
test_root: Path
def hermetic_env(self) -> Dict[str, Path]:
# mirroring vars-and-functions.sh
home = self.test_root / "test-home"
home.mkdir(parents=True, exist_ok=True)
return {
"NIX_LOCALSTATE_DIR": self.test_root / "var",
"NIX_LOG_DIR": self.test_root / "var/log/nix",
"NIX_STATE_DIR": self.test_root / "var/nix",
"NIX_CONF_DIR": self.test_root / "etc",
"NIX_DAEMON_SOCKET_PATH": self.test_root / "daemon-socket",
"NIX_USER_CONF_FILES": "",
"HOME": home,
}
def make_env(self) -> Dict[AnyStr, AnyStr]:
# We conservatively assume that people might want to successfully get
# some env through to the subprocess, so we override whatever is in the
# global env.
d = os.environ.copy()
d.update(self.hermetic_env())
return d
def cmd(self, argv: list[str]) -> Command:
return Command(argv=argv, cwd=self.test_root, env=self.make_env())
def settings(self, allow_builds: bool = False) -> NixSettings:
"""
Parameters:
- allow_builds: relocate the Nix store so that builds work (however, makes store paths non-reproducible across test runs!)
"""
settings = NixSettings()
store_path = self.test_root / "store"
if allow_builds:
settings.nix_store_dir = store_path
else:
settings.store = str(store_path)
return settings
def nix_cmd(self, argv: list[str], flake: bool = False) -> NixCommand:
"""
Constructs a NixCommand with the appropriate settings.
"""
settings = self.settings()
if flake:
settings.feature("nix-command", "flakes")
return NixCommand(argv=argv, cwd=self.test_root, env=self.make_env(), settings=settings)
def nix(self, cmd: list[str], nix_exe: str = "nix", flake: bool = False) -> NixCommand:
return self.nix_cmd([nix_exe, *cmd], flake=flake)
nix_build = partialmethod(nix, nix_exe="nix-build")
nix_shell = partialmethod(nix, nix_exe="nix-shell")
nix_store = partialmethod(nix, nix_exe="nix-store")
nix_env = partialmethod(nix, nix_exe="nix-env")
nix_instantiate = partialmethod(nix, nix_exe="nix-instantiate")
nix_channel = partialmethod(nix, nix_exe="nix-channel")
nix_prefetch_url = partialmethod(nix, nix_exe="nix-prefetch-url")
def eval(self, expr: str, settings: NixSettings | None = None) -> CommandResult:
if settings is None:
settings = self.settings()
# clone due to reference-shenanigans
settings = dataclasses.replace(settings).feature("nix-command")
cmd = self.nix(["eval", "--json", "--expr", expr])
cmd.settings = settings
return cmd.run()
@pytest.fixture
def nix(tmp_path: Path) -> Nix:
return Nix(tmp_path)