From 8a513f462a4f877b94f28d8b82cb71d4fdb6f71e Mon Sep 17 00:00:00 2001 From: Qubasa Date: Fri, 22 Mar 2024 19:08:35 +0100 Subject: [PATCH 1/2] clan-vm-manager: Basic pytest framework established --- .../clan_vm_manager/__init__.py | 4 +- .../clan_vm_manager/windows/main_window.py | 2 +- pkgs/clan-vm-manager/default.nix | 22 +++++++ pkgs/clan-vm-manager/pyproject.toml | 10 ++- pkgs/clan-vm-manager/shell.nix | 6 +- pkgs/clan-vm-manager/tests/command.py | 64 +++++++++++++++++++ pkgs/clan-vm-manager/tests/conftest.py | 44 +++++++++++++ pkgs/clan-vm-manager/tests/helpers/cli.py | 15 +++++ pkgs/clan-vm-manager/tests/root.py | 35 ++++++++++ pkgs/clan-vm-manager/tests/temporary_dir.py | 27 ++++++++ pkgs/clan-vm-manager/tests/test_cli.py | 8 +++ pkgs/clan-vm-manager/tests/wayland.py | 24 +++++++ 12 files changed, 254 insertions(+), 7 deletions(-) create mode 100644 pkgs/clan-vm-manager/tests/command.py create mode 100644 pkgs/clan-vm-manager/tests/conftest.py create mode 100644 pkgs/clan-vm-manager/tests/helpers/cli.py create mode 100644 pkgs/clan-vm-manager/tests/root.py create mode 100644 pkgs/clan-vm-manager/tests/temporary_dir.py create mode 100644 pkgs/clan-vm-manager/tests/test_cli.py create mode 100644 pkgs/clan-vm-manager/tests/wayland.py diff --git a/pkgs/clan-vm-manager/clan_vm_manager/__init__.py b/pkgs/clan-vm-manager/clan_vm_manager/__init__.py index 97d3317cd..d1cc2a96f 100644 --- a/pkgs/clan-vm-manager/clan_vm_manager/__init__.py +++ b/pkgs/clan-vm-manager/clan_vm_manager/__init__.py @@ -9,6 +9,6 @@ log = logging.getLogger(__name__) @profile -def main() -> int: +def main(argv: list[str] = sys.argv) -> int: app = MainApplication() - return app.run(sys.argv) + return app.run(argv) diff --git a/pkgs/clan-vm-manager/clan_vm_manager/windows/main_window.py b/pkgs/clan-vm-manager/clan_vm_manager/windows/main_window.py index 887027325..622420818 100644 --- a/pkgs/clan-vm-manager/clan_vm_manager/windows/main_window.py +++ b/pkgs/clan-vm-manager/clan_vm_manager/windows/main_window.py @@ -25,7 +25,7 @@ class MainWindow(Adw.ApplicationWindow): def __init__(self, config: ClanConfig) -> None: super().__init__() self.set_title("cLAN Manager") - self.set_default_size(980, 650) + self.set_default_size(980, 850) overlay = ToastOverlay.use().overlay view = Adw.ToolbarView() diff --git a/pkgs/clan-vm-manager/default.nix b/pkgs/clan-vm-manager/default.nix index 71ab3d60e..521c6276f 100644 --- a/pkgs/clan-vm-manager/default.nix +++ b/pkgs/clan-vm-manager/default.nix @@ -12,6 +12,7 @@ clan-cli, makeDesktopItem, libadwaita, + weston, }: let source = ./.; @@ -54,6 +55,16 @@ python3.pkgs.buildPythonApplication rec { passthru.externalPythonDeps ]; + checkPython = python3.withPackages (_ps: clan-cli.passthru.pytestDependencies); + + devDependencies = [ + checkPython + weston + ] ++ nativeBuildInputs ++ buildInputs ++ propagatedBuildInputs; + + passthru.checkPython = checkPython; + passthru.devDependencies = devDependencies; + # also re-expose dependencies so we test them in CI passthru = { inherit desktop-file; @@ -65,6 +76,17 @@ python3.pkgs.buildPythonApplication rec { pygobject-stubs ]; tests = { + clan-vm-manager-pytest = + runCommand "clan-vm-manager-pytest" { nativeBuildInputs = devDependencies; } + '' + cp -r ${source} ./src + chmod +w -R ./src + cd ./src + + export NIX_STATE_DIR=$TMPDIR/nix IN_NIX_SANDBOX=1 + ${checkPython}/bin/python -m pytest -m "not impure" ./tests + touch $out + ''; clan-vm-manager-no-breakpoints = runCommand "clan-vm-manager-no-breakpoints" { } '' if grep --include \*.py -Rq "breakpoint()" ${source}; then echo "breakpoint() found in ${source}:" diff --git a/pkgs/clan-vm-manager/pyproject.toml b/pkgs/clan-vm-manager/pyproject.toml index 8016c21eb..bdacae4c6 100644 --- a/pkgs/clan-vm-manager/pyproject.toml +++ b/pkgs/clan-vm-manager/pyproject.toml @@ -3,7 +3,6 @@ requires = ["setuptools"] build-backend = "setuptools.build_meta" - [project] name = "clan-vm-manager" dynamic = ["version"] @@ -15,6 +14,15 @@ exclude = ["result"] [tool.setuptools.package-data] clan_vm_manager = ["**/assets/*"] +[tool.pytest.ini_options] +testpaths = "tests" +faulthandler_timeout = 60 +log_level = "DEBUG" +log_format = "%(levelname)s: %(message)s\n %(pathname)s:%(lineno)d::%(funcName)s" +addopts = "--cov . --cov-report term --cov-report html:.reports/html --no-cov-on-fail --durations 5 --color=yes --new-first" # Add --pdb for debugging +norecursedirs = "tests/helpers" +markers = ["impure"] + [tool.mypy] python_version = "3.11" warn_redundant_casts = true diff --git a/pkgs/clan-vm-manager/shell.nix b/pkgs/clan-vm-manager/shell.nix index 360944228..6d1d8565f 100644 --- a/pkgs/clan-vm-manager/shell.nix +++ b/pkgs/clan-vm-manager/shell.nix @@ -33,7 +33,7 @@ mkShell ( --add-flags '-ex "source ${python3}/share/gdb/libpython.py"' ''; in - { + rec { inherit (clan-vm-manager) propagatedBuildInputs buildInputs; linuxOnlyPackages = lib.optionals stdenv.isLinux [ @@ -42,14 +42,14 @@ mkShell ( ]; # To debug clan-vm-manger execute pygdb --args python ./bin/clan-vm-manager - nativeBuildInputs = [ + packages = [ ruff desktop-file-utils mypy python3Packages.ipdb gtk4.dev libadwaita.devdoc # has the demo called 'adwaita-1-demo' - ] ++ clan-vm-manager.nativeBuildInputs ++ clan-vm-manager.propagatedBuildInputs; + ] ++ clan-vm-manager.devDependencies ++ linuxOnlyPackages; PYTHONBREAKPOINT = "ipdb.set_trace"; diff --git a/pkgs/clan-vm-manager/tests/command.py b/pkgs/clan-vm-manager/tests/command.py new file mode 100644 index 000000000..f951c8dd5 --- /dev/null +++ b/pkgs/clan-vm-manager/tests/command.py @@ -0,0 +1,64 @@ +import os +import signal +import subprocess +from collections.abc import Iterator +from pathlib import Path +from typing import IO, Any + +import pytest + +_FILE = None | int | IO[Any] + + +class Command: + def __init__(self) -> None: + self.processes: list[subprocess.Popen[str]] = [] + + def run( + self, + command: list[str], + extra_env: dict[str, str] = {}, + stdin: _FILE = None, + stdout: _FILE = None, + stderr: _FILE = None, + workdir: Path | None = None, + ) -> subprocess.Popen[str]: + env = os.environ.copy() + env.update(extra_env) + # We start a new session here so that we can than more reliably kill all childs as well + p = subprocess.Popen( + command, + env=env, + start_new_session=True, + stdout=stdout, + stderr=stderr, + stdin=stdin, + text=True, + cwd=workdir, + ) + self.processes.append(p) + return p + + def terminate(self) -> None: + # Stop in reverse order in case there are dependencies. + # We just kill all processes as quickly as possible because we don't + # care about corrupted state and want to make tests fasts. + for p in reversed(self.processes): + try: + os.killpg(os.getpgid(p.pid), signal.SIGKILL) + except OSError: + pass + + +@pytest.fixture +def command() -> Iterator[Command]: + """ + Starts a background command. The process is automatically terminated in the end. + >>> p = command.run(["some", "daemon"]) + >>> print(p.pid) + """ + c = Command() + try: + yield c + finally: + c.terminate() diff --git a/pkgs/clan-vm-manager/tests/conftest.py b/pkgs/clan-vm-manager/tests/conftest.py new file mode 100644 index 000000000..1841dc81e --- /dev/null +++ b/pkgs/clan-vm-manager/tests/conftest.py @@ -0,0 +1,44 @@ +import subprocess +import sys +from pathlib import Path + +import pytest +from clan_cli.custom_logger import setup_logging +from clan_cli.nix import nix_shell + +sys.path.append(str(Path(__file__).parent / "helpers")) +sys.path.append( + str(Path(__file__).parent.parent) +) # Also add clan vm manager to PYTHONPATH + +pytest_plugins = [ + "temporary_dir", + "root", + "command", + "wayland", +] + + +# Executed on pytest session start +def pytest_sessionstart(session: pytest.Session) -> None: + # This function will be called once at the beginning of the test session + print("Starting pytest session") + # You can access the session config, items, testsfailed, etc. + print(f"Session config: {session.config}") + + setup_logging(level="DEBUG") + + +# fixture for git_repo +@pytest.fixture +def git_repo(tmp_path: Path) -> Path: + # initialize a git repository + cmd = nix_shell(["nixpkgs#git"], ["git", "init"]) + subprocess.run(cmd, cwd=tmp_path, check=True) + # set user.name and user.email + cmd = nix_shell(["nixpkgs#git"], ["git", "config", "user.name", "test"]) + subprocess.run(cmd, cwd=tmp_path, check=True) + cmd = nix_shell(["nixpkgs#git"], ["git", "config", "user.email", "test@test.test"]) + subprocess.run(cmd, cwd=tmp_path, check=True) + # return the path to the git repository + return tmp_path diff --git a/pkgs/clan-vm-manager/tests/helpers/cli.py b/pkgs/clan-vm-manager/tests/helpers/cli.py new file mode 100644 index 000000000..1c2532d5b --- /dev/null +++ b/pkgs/clan-vm-manager/tests/helpers/cli.py @@ -0,0 +1,15 @@ +import logging +import shlex + +from clan_cli.custom_logger import get_caller + +from clan_vm_manager import main + +log = logging.getLogger(__name__) + + +class Cli: + def run(self, args: list[str]) -> None: + cmd = shlex.join(["clan", *args]) + log.debug(f"$ {cmd} \nCaller: {get_caller()}") + main(args) diff --git a/pkgs/clan-vm-manager/tests/root.py b/pkgs/clan-vm-manager/tests/root.py new file mode 100644 index 000000000..0cac067be --- /dev/null +++ b/pkgs/clan-vm-manager/tests/root.py @@ -0,0 +1,35 @@ +import os +from pathlib import Path + +import pytest + +TEST_ROOT = Path(__file__).parent.resolve() +PROJECT_ROOT = TEST_ROOT.parent +if CLAN_CORE_ := os.environ.get("CLAN_CORE"): + CLAN_CORE = Path(CLAN_CORE_) +else: + CLAN_CORE = PROJECT_ROOT.parent.parent + + +@pytest.fixture(scope="session") +def project_root() -> Path: + """ + Root directory the clan-cli + """ + return PROJECT_ROOT + + +@pytest.fixture(scope="session") +def test_root() -> Path: + """ + Root directory of the tests + """ + return TEST_ROOT + + +@pytest.fixture(scope="session") +def clan_core() -> Path: + """ + Directory of the clan-core flake + """ + return CLAN_CORE diff --git a/pkgs/clan-vm-manager/tests/temporary_dir.py b/pkgs/clan-vm-manager/tests/temporary_dir.py new file mode 100644 index 000000000..aaa54ca27 --- /dev/null +++ b/pkgs/clan-vm-manager/tests/temporary_dir.py @@ -0,0 +1,27 @@ +import logging +import os +import tempfile +from collections.abc import Iterator +from pathlib import Path + +import pytest + +log = logging.getLogger(__name__) + + +@pytest.fixture +def temporary_home(monkeypatch: pytest.MonkeyPatch) -> Iterator[Path]: + env_dir = os.getenv("TEST_TEMPORARY_DIR") + if env_dir is not None: + path = Path(env_dir).resolve() + log.debug("Temp HOME directory: %s", str(path)) + monkeypatch.setenv("HOME", str(path)) + monkeypatch.chdir(str(path)) + yield path + else: + with tempfile.TemporaryDirectory(prefix="pytest-") as dirpath: + monkeypatch.setenv("HOME", str(dirpath)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(Path(dirpath) / ".config")) + monkeypatch.chdir(str(dirpath)) + log.debug("Temp HOME directory: %s", str(dirpath)) + yield Path(dirpath) diff --git a/pkgs/clan-vm-manager/tests/test_cli.py b/pkgs/clan-vm-manager/tests/test_cli.py new file mode 100644 index 000000000..654fa82a2 --- /dev/null +++ b/pkgs/clan-vm-manager/tests/test_cli.py @@ -0,0 +1,8 @@ +import pytest +from cli import Cli + + +def test_help(capfd: pytest.CaptureFixture) -> None: + cli = Cli() + with pytest.raises(SystemExit): + cli.run(["clan-vm-manager", "--help"]) diff --git a/pkgs/clan-vm-manager/tests/wayland.py b/pkgs/clan-vm-manager/tests/wayland.py new file mode 100644 index 000000000..a9150dd29 --- /dev/null +++ b/pkgs/clan-vm-manager/tests/wayland.py @@ -0,0 +1,24 @@ +from collections.abc import Generator +from subprocess import Popen + +import pytest + + +@pytest.fixture(scope="session") +def wayland_compositor() -> Generator[Popen, None, None]: + # Start the Wayland compositor (e.g., Weston) + compositor = Popen(["weston", "--backend=headless-backend.so"]) + yield compositor + # Cleanup: Terminate the compositor + compositor.terminate() + + +@pytest.fixture(scope="function") +def gtk_app(wayland_compositor: Popen) -> Generator[Popen, None, None]: + # Assuming your GTK4 app can be started via a command line + # It's important to ensure it uses the Wayland session initiated by the fixture + env = {"GDK_BACKEND": "wayland"} + app = Popen(["clan-vm-manager"], env=env) + yield app + # Cleanup: Terminate your application + app.terminate() From d6d8a88549cd69148bdd54016cf73bacedb41765 Mon Sep 17 00:00:00 2001 From: Qubasa Date: Sun, 24 Mar 2024 23:36:00 +0100 Subject: [PATCH 2/2] clan-vm-manager: Working pytest skeleton. clan-cli: Fixing devshell depending on itself --- devShell-python.nix | 115 ------------------ flake.nix | 1 - formatter.nix | 5 +- pkgs/clan-cli/.envrc | 3 +- pkgs/clan-cli/clan_cli/machines/machines.py | 2 +- pkgs/clan-cli/{ => clan_cli}/qemu/__init__.py | 0 pkgs/clan-cli/{ => clan_cli}/qemu/qga.py | 0 pkgs/clan-cli/{ => clan_cli}/qemu/qmp.py | 0 pkgs/clan-cli/default.nix | 106 ++++++++-------- pkgs/clan-cli/shell.nix | 52 ++------ pkgs/clan-cli/tests/conftest.py | 6 +- pkgs/clan-cli/tests/test_vms_cli.py | 4 +- pkgs/clan-vm-manager/.envrc | 3 +- .../clan_vm_manager/__main__.py | 6 + pkgs/clan-vm-manager/default.nix | 83 ++++++++----- pkgs/clan-vm-manager/flake-module.nix | 2 +- pkgs/clan-vm-manager/pyproject.toml | 4 - pkgs/clan-vm-manager/shell.nix | 98 +++++---------- pkgs/clan-vm-manager/tests/test_join.py | 8 ++ pkgs/clan-vm-manager/tests/wayland.py | 19 +-- 20 files changed, 181 insertions(+), 336 deletions(-) delete mode 100644 devShell-python.nix rename pkgs/clan-cli/{ => clan_cli}/qemu/__init__.py (100%) rename pkgs/clan-cli/{ => clan_cli}/qemu/qga.py (100%) rename pkgs/clan-cli/{ => clan_cli}/qemu/qmp.py (100%) create mode 100644 pkgs/clan-vm-manager/clan_vm_manager/__main__.py create mode 100644 pkgs/clan-vm-manager/tests/test_join.py diff --git a/devShell-python.nix b/devShell-python.nix deleted file mode 100644 index 066c44731..000000000 --- a/devShell-python.nix +++ /dev/null @@ -1,115 +0,0 @@ -{ - perSystem = - { - pkgs, - self', - lib, - ... - }: - let - python3 = pkgs.python3; - pypkgs = python3.pkgs; - clan-cli = self'.packages.clan-cli; - clan-vm-manager = self'.packages.clan-vm-manager; - pythonWithDeps = python3.withPackages ( - ps: - clan-cli.propagatedBuildInputs - ++ clan-cli.devDependencies - ++ [ ps.pip ] - ++ [ clan-vm-manager.externalPythonDeps ] - # clan-vm-manager deps - ); - linuxOnlyPackages = lib.optionals pkgs.stdenv.isLinux [ pkgs.xdg-utils ]; - in - { - devShells.python = pkgs.mkShell { - inputsFrom = [ self'.devShells.default ]; - packages = - [ - pythonWithDeps - pypkgs.mypy - pypkgs.ipdb - pkgs.desktop-file-utils - pkgs.gtk4.dev - pkgs.ruff - pkgs.libadwaita.devdoc # has the demo called 'adwaita-1-demo' - ] - ++ linuxOnlyPackages - ++ clan-vm-manager.nativeBuildInputs - ++ clan-vm-manager.buildInputs - ++ clan-cli.nativeBuildInputs; - - PYTHONBREAKPOINT = "ipdb.set_trace"; - - shellHook = '' - ln -sfT ${clan-cli.nixpkgs} ./pkgs/clan-cli/clan_cli/nixpkgs - - ## PYTHON - - tmp_path="$(realpath ./.direnv/python)" - repo_root=$(realpath .) - mkdir -p "$tmp_path/${pythonWithDeps.sitePackages}" - - # local dependencies - localPackages=( - $repo_root/pkgs/clan-cli - $repo_root/pkgs/clan-vm-manager - ) - - # Install executable wrappers for local python packages scripts - # This is done by utilizing `pip install --editable` - # As a result, executables like `clan` can be executed from within the dev-shell - # while using the current version of the code and its dependencies. - for package in "''${localPackages[@]}"; do - pname=$(basename "$package") - if - [ ! -e "$tmp_path/meta/$pname/pyproject.toml" ] \ - || [ ! -e "$package/pyproject.toml" ] \ - || ! cmp -s "$tmp_path/meta/$pname/pyproject.toml" "$package/pyproject.toml" - then - echo "==== Installing local python package $pname in editable mode ====" - mkdir -p "$tmp_path/meta/$pname" - cp $package/pyproject.toml $tmp_path/meta/$pname/pyproject.toml - ${python3.pkgs.pip}/bin/pip install \ - --quiet \ - --disable-pip-version-check \ - --no-index \ - --no-build-isolation \ - --prefix "$tmp_path" \ - --editable "$package" - fi - done - - export PATH="$tmp_path/bin:$PATH" - export PYTHONPATH="''${PYTHONPATH:+$PYTHONPATH:}$tmp_path/${pythonWithDeps.sitePackages}" - - for package in "''${localPackages[@]}"; do - export PYTHONPATH="$package:$PYTHONPATH" - done - - - - ## GUI - - if ! command -v xdg-mime &> /dev/null; then - echo "Warning: 'xdg-mime' is not available. The desktop file cannot be installed." - fi - - # install desktop file - set -eou pipefail - DESKTOP_FILE_NAME=org.clan.vm-manager.desktop - DESKTOP_DST=~/.local/share/applications/$DESKTOP_FILE_NAME - DESKTOP_SRC=${clan-vm-manager.desktop-file}/share/applications/$DESKTOP_FILE_NAME - UI_BIN="clan-vm-manager" - - cp -f $DESKTOP_SRC $DESKTOP_DST - sed -i "s|Exec=.*clan-vm-manager|Exec=$UI_BIN|" $DESKTOP_DST - xdg-mime default $DESKTOP_FILE_NAME x-scheme-handler/clan - echo "==== Validating desktop file installation ====" - set -x - desktop-file-validate $DESKTOP_DST - set +xeou pipefail - ''; - }; - }; -} diff --git a/flake.nix b/flake.nix index eb482c23f..1f03f9cc2 100644 --- a/flake.nix +++ b/flake.nix @@ -35,7 +35,6 @@ imports = [ ./checks/flake-module.nix ./devShell.nix - ./devShell-python.nix ./formatter.nix ./templates/flake-module.nix ./clanModules/flake-module.nix diff --git a/formatter.nix b/formatter.nix index 4aeacde3b..ac96744c0 100644 --- a/formatter.nix +++ b/formatter.nix @@ -9,9 +9,8 @@ treefmt.programs.mypy.enable = true; treefmt.programs.mypy.directories = { - "pkgs/clan-cli".extraPythonPackages = self'.packages.clan-cli.pytestDependencies; - "pkgs/clan-vm-manager".extraPythonPackages = - self'.packages.clan-vm-manager.externalPythonDeps ++ self'.packages.clan-cli.pytestDependencies; + "pkgs/clan-cli".extraPythonPackages = self'.packages.clan-cli.testDependencies; + "pkgs/clan-vm-manager".extraPythonPackages = self'.packages.clan-vm-manager.testDependencies; }; treefmt.settings.formatter.nix = { diff --git a/pkgs/clan-cli/.envrc b/pkgs/clan-cli/.envrc index 79a6a2d46..179c25fd8 100644 --- a/pkgs/clan-cli/.envrc +++ b/pkgs/clan-cli/.envrc @@ -1,4 +1,5 @@ -source_up +# BUG: If this is enabled the devshell depends on clan_cli building successfully +# source_up watch_file flake-module.nix default.nix diff --git a/pkgs/clan-cli/clan_cli/machines/machines.py b/pkgs/clan-cli/clan_cli/machines/machines.py index 72e4b9f99..49393a258 100644 --- a/pkgs/clan-cli/clan_cli/machines/machines.py +++ b/pkgs/clan-cli/clan_cli/machines/machines.py @@ -8,7 +8,7 @@ from typing import Any from clan_cli.clan_uri import ClanURI, MachineData from clan_cli.dirs import vm_state_dir -from qemu.qmp import QEMUMonitorProtocol +from clan_cli.qemu.qmp import QEMUMonitorProtocol from ..cmd import run from ..errors import ClanError diff --git a/pkgs/clan-cli/qemu/__init__.py b/pkgs/clan-cli/clan_cli/qemu/__init__.py similarity index 100% rename from pkgs/clan-cli/qemu/__init__.py rename to pkgs/clan-cli/clan_cli/qemu/__init__.py diff --git a/pkgs/clan-cli/qemu/qga.py b/pkgs/clan-cli/clan_cli/qemu/qga.py similarity index 100% rename from pkgs/clan-cli/qemu/qga.py rename to pkgs/clan-cli/clan_cli/qemu/qga.py diff --git a/pkgs/clan-cli/qemu/qmp.py b/pkgs/clan-cli/clan_cli/qemu/qmp.py similarity index 100% rename from pkgs/clan-cli/qemu/qmp.py rename to pkgs/clan-cli/clan_cli/qemu/qmp.py diff --git a/pkgs/clan-cli/default.nix b/pkgs/clan-cli/default.nix index fa5252f13..5fd929d02 100644 --- a/pkgs/clan-cli/default.nix +++ b/pkgs/clan-cli/default.nix @@ -1,4 +1,5 @@ { + # Inputs for the package age, lib, argcomplete, @@ -10,14 +11,11 @@ pytest-xdist, pytest-subprocess, pytest-timeout, - remote-pdb, - ipdb, python3, runCommand, setuptools, sops, stdenv, - wheel, fakeroot, rsync, bash, @@ -30,33 +28,15 @@ gnupg, e2fsprogs, mypy, - rope, clan-core-path, }: let - - dependencies = [ - argcomplete # optional dependency: if not enabled, shell completion will not work + # Dependencies that are directly used in the project + pythonDependencies = [ + argcomplete # Enables shell completion; without it, this feature won't work. ]; - pytestDependencies = - runtimeDependencies - ++ dependencies - ++ [ - pytest - pytest-cov - pytest-subprocess - pytest-xdist - pytest-timeout - remote-pdb - ipdb - openssh - git - gnupg - stdenv.cc - ]; - - # Optional dependencies for clan cli, we re-expose them here to make sure they all build. + # Runtime dependencies required by the application runtimeDependencies = [ bash nix @@ -74,14 +54,31 @@ let e2fsprogs ]; + # Dependencies required for running tests + testDependencies = + runtimeDependencies + ++ [ + gnupg + stdenv.cc # Compiler used for certain native extensions + ] + ++ pythonDependencies + ++ [ + pytest # Testing framework + pytest-cov # Generate coverage reports + pytest-subprocess # fake the real subprocess behavior to make your tests more independent. + pytest-xdist # Run tests in parallel on multiple cores + pytest-timeout # Add timeouts to your tests + ]; + + # Convert runtimeDependencies into an attribute set for easier access runtimeDependenciesAsSet = builtins.listToAttrs ( builtins.map (p: lib.nameValuePair (lib.getName p.name) p) runtimeDependencies ); - checkPython = python3.withPackages (_ps: pytestDependencies); + # Setup Python environment with all dependencies for running tests + pythonWithTestDeps = python3.withPackages (_ps: testDependencies); - # - vendor the jsonschema nix lib (copy instead of symlink). - # Interesting fact: using nixpkgs from flakes instead of nixpkgs.path is reduces evaluation time by 5s. + # Prepare the source code for the project, including copying over jsonschema and nixpkgs source = runCommand "clan-cli-source" { } '' cp -r ${./.} $out chmod -R +w $out @@ -89,6 +86,8 @@ let ln -s ${nixpkgs'} $out/clan_cli/nixpkgs cp -r ${../../lib/jsonschema} $out/clan_cli/config/jsonschema ''; + + # Create a custom nixpkgs for use within the project nixpkgs' = runCommand "nixpkgs" { nativeBuildInputs = [ nix ]; } '' mkdir $out cat > $out/flake.nix << EOF @@ -114,36 +113,36 @@ python3.pkgs.buildPythonApplication { src = source; format = "pyproject"; - makeWrapperArgs = [ - # This prevents problems with mixed glibc versions that might occur when the - # cli is called through a browser built against another glibc - "--unset LD_LIBRARY_PATH" - ]; + # Arguments for the wrapper to unset LD_LIBRARY_PATH to avoid glibc version issues + makeWrapperArgs = [ "--unset LD_LIBRARY_PATH" ]; + # Build-time dependencies. nativeBuildInputs = [ setuptools installShellFiles ]; - propagatedBuildInputs = dependencies; - # also re-expose dependencies so we test them in CI + propagatedBuildInputs = pythonDependencies; + + # Define and expose the tests and checks to run in CI passthru.tests = (lib.mapAttrs' (n: lib.nameValuePair "clan-dep-${n}") runtimeDependenciesAsSet) // rec { clan-pytest-without-core = - runCommand "clan-pytest-without-core" { nativeBuildInputs = [ checkPython ] ++ pytestDependencies; } + runCommand "clan-pytest-without-core" + { nativeBuildInputs = [ pythonWithTestDeps ] ++ testDependencies; } '' cp -r ${source} ./src chmod +w -R ./src cd ./src export NIX_STATE_DIR=$TMPDIR/nix IN_NIX_SANDBOX=1 - ${checkPython}/bin/python -m pytest -m "not impure and not with_core" ./tests + ${pythonWithTestDeps}/bin/python -m pytest -m "not impure and not with_core" ./tests touch $out ''; - # separate the tests that can never be cached clan-pytest-with-core = - runCommand "clan-pytest-with-core" { nativeBuildInputs = [ checkPython ] ++ pytestDependencies; } + runCommand "clan-pytest-with-core" + { nativeBuildInputs = [ pythonWithTestDeps ] ++ testDependencies; } '' cp -r ${source} ./src chmod +w -R ./src @@ -151,15 +150,11 @@ python3.pkgs.buildPythonApplication { export CLAN_CORE=${clan-core-path} export NIX_STATE_DIR=$TMPDIR/nix IN_NIX_SANDBOX=1 - ${checkPython}/bin/python -m pytest -m "not impure and with_core" ./tests + ${pythonWithTestDeps}/bin/python -m pytest -m "not impure and with_core" ./tests touch $out ''; - clan-pytest = runCommand "clan-pytest" { } '' - echo ${clan-pytest-without-core} - echo ${clan-pytest-with-core} - touch $out - ''; + # Utility to check for leftover debugging breakpoints in the codebase check-for-breakpoints = runCommand "breakpoints" { } '' if grep --include \*.py -Rq "breakpoint()" ${source}; then echo "breakpoint() found in ${source}:" @@ -170,18 +165,13 @@ python3.pkgs.buildPythonApplication { ''; }; + # Additional pass-through attributes passthru.nixpkgs = nixpkgs'; - passthru.checkPython = checkPython; - - passthru.devDependencies = [ - rope - setuptools - wheel - ] ++ pytestDependencies; - - passthru.pytestDependencies = pytestDependencies; + passthru.testDependencies = testDependencies; + passthru.pythonWithTestDeps = pythonWithTestDeps; passthru.runtimeDependencies = runtimeDependencies; + # Install shell completions for bash and fish using the argcomplete package postInstall = '' cp -r ${nixpkgs'} $out/${python3.sitePackages}/clan_cli/nixpkgs installShellCompletion --bash --name clan \ @@ -189,13 +179,17 @@ python3.pkgs.buildPythonApplication { installShellCompletion --fish --name clan.fish \ <(${argcomplete}/bin/register-python-argcomplete --shell fish clan) ''; - # Don't leak python packages into a devshell. - # It can be very confusing if you `nix run` then load the cli from the devshell instead. + + # Clean up after the package to avoid leaking python packages into a devshell postFixup = '' rm $out/nix-support/propagated-build-inputs ''; + + # Run a basic check to ensure the application is executable checkPhase = '' PYTHONPATH= $out/bin/clan --help ''; + + # Specify the main program for this package meta.mainProgram = "clan"; } diff --git a/pkgs/clan-cli/shell.nix b/pkgs/clan-cli/shell.nix index e239355c1..f0ca91b54 100644 --- a/pkgs/clan-cli/shell.nix +++ b/pkgs/clan-cli/shell.nix @@ -1,59 +1,29 @@ { nix-unit, clan-cli, - system, mkShell, - writeScriptBin, - openssh, ruff, python3, }: let - checkScript = writeScriptBin "check" '' - nix build .#checks.${system}.{treefmt,clan-pytest} -L "$@" - ''; - - pythonWithDeps = python3.withPackages ( - ps: clan-cli.propagatedBuildInputs ++ clan-cli.devDependencies ++ [ ps.pip ] - ); + devshellTestDeps = + clan-cli.passthru.testDependencies + ++ (with python3.pkgs; [ + rope + setuptools + wheel + pip + ]); in mkShell { - packages = [ + buildInputs = [ nix-unit - openssh ruff - clan-cli.checkPython - ]; + ] ++ devshellTestDeps; shellHook = '' - tmp_path=$(realpath ./.direnv) - - repo_root=$(realpath .) - mkdir -p "$tmp_path/python/${pythonWithDeps.sitePackages}" - - # Install the package in editable mode - # This allows executing `clan` from within the dev-shell using the current - # version of the code and its dependencies. - ${pythonWithDeps.interpreter} -m pip install \ - --quiet \ - --disable-pip-version-check \ - --no-index \ - --no-build-isolation \ - --prefix "$tmp_path/python" \ - --editable $repo_root + export PATH=$(pwd)/bin:$PATH ln -sfT ${clan-cli.nixpkgs} clan_cli/nixpkgs - - export PATH="$tmp_path/python/bin:${checkScript}/bin:$PATH" - export PYTHONPATH="$repo_root:$tmp_path/python/${pythonWithDeps.sitePackages}:" - - export XDG_DATA_DIRS="$tmp_path/share''${XDG_DATA_DIRS:+:$XDG_DATA_DIRS}" - export fish_complete_path="$tmp_path/share/fish/vendor_completions.d''${fish_complete_path:+:$fish_complete_path}" - mkdir -p \ - $tmp_path/share/fish/vendor_completions.d \ - $tmp_path/share/bash-completion/completions \ - $tmp_path/share/zsh/site-functions - register-python-argcomplete --shell fish clan > $tmp_path/share/fish/vendor_completions.d/clan.fish - register-python-argcomplete --shell bash clan > $tmp_path/share/bash-completion/completions/clan ''; } diff --git a/pkgs/clan-cli/tests/conftest.py b/pkgs/clan-cli/tests/conftest.py index da275fe0a..5ec8735fc 100644 --- a/pkgs/clan-cli/tests/conftest.py +++ b/pkgs/clan-cli/tests/conftest.py @@ -5,11 +5,13 @@ from pathlib import Path import pytest +sys.path.append(os.path.join(os.path.dirname(__file__), "helpers")) +sys.path.append(str(Path(__file__).parent.parent)) # Also add clan_cli to PYTHONPATH + + from clan_cli.custom_logger import setup_logging from clan_cli.nix import nix_shell -sys.path.append(os.path.join(os.path.dirname(__file__), "helpers")) - pytest_plugins = [ "temporary_dir", "root", diff --git a/pkgs/clan-cli/tests/test_vms_cli.py b/pkgs/clan-cli/tests/test_vms_cli.py index 0110cf977..47dce1c4e 100644 --- a/pkgs/clan-cli/tests/test_vms_cli.py +++ b/pkgs/clan-cli/tests/test_vms_cli.py @@ -12,8 +12,8 @@ from fixtures_flakes import FlakeForTest, generate_flake from root import CLAN_CORE from clan_cli.dirs import vm_state_dir -from qemu.qga import QgaSession -from qemu.qmp import QEMUMonitorProtocol +from clan_cli.qemu.qga import QgaSession +from clan_cli.qemu.qmp import QEMUMonitorProtocol if TYPE_CHECKING: from age_keys import KeyPair diff --git a/pkgs/clan-vm-manager/.envrc b/pkgs/clan-vm-manager/.envrc index 76d5ff725..3a9c84ade 100644 --- a/pkgs/clan-vm-manager/.envrc +++ b/pkgs/clan-vm-manager/.envrc @@ -1,4 +1,5 @@ -source_up +# See comment in clan-cli/.envrc +# source_up watch_file flake-module.nix default.nix diff --git a/pkgs/clan-vm-manager/clan_vm_manager/__main__.py b/pkgs/clan-vm-manager/clan_vm_manager/__main__.py new file mode 100644 index 000000000..daf509ab7 --- /dev/null +++ b/pkgs/clan-vm-manager/clan_vm_manager/__main__.py @@ -0,0 +1,6 @@ +import sys + +from . import main + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pkgs/clan-vm-manager/default.nix b/pkgs/clan-vm-manager/default.nix index 521c6276f..32ec52323 100644 --- a/pkgs/clan-vm-manager/default.nix +++ b/pkgs/clan-vm-manager/default.nix @@ -12,7 +12,11 @@ clan-cli, makeDesktopItem, libadwaita, - weston, + pytest, # Testing framework + pytest-cov, # Generate coverage reports + pytest-subprocess, # fake the real subprocess behavior to make your tests more independent. + pytest-xdist, # Run tests in parallel on multiple cores + pytest-timeout, # Add timeouts to your tests }: let source = ./.; @@ -24,6 +28,36 @@ let startupWMClass = "clan"; mimeTypes = [ "x-scheme-handler/clan" ]; }; + + # Dependencies that are directly used in the project but nor from internal python packages + externalPythonDeps = [ + pygobject3 + pygobject-stubs + gtk4 + libadwaita + gnome.adwaita-icon-theme + ]; + + # Deps including python packages from the local project + allPythonDeps = [ (python3.pkgs.toPythonModule clan-cli) ] ++ externalPythonDeps; + + # Runtime binary dependencies required by the application + runtimeDependencies = [ ]; + + # Dependencies required for running tests + testDependencies = + runtimeDependencies + ++ allPythonDeps + ++ [ + pytest # Testing framework + pytest-cov # Generate coverage reports + pytest-subprocess # fake the real subprocess behavior to make your tests more independent. + pytest-xdist # Run tests in parallel on multiple cores + pytest-timeout # Add timeouts to your tests + ]; + + # Setup Python environment with all dependencies for running tests + pythonWithTestDeps = python3.withPackages (_ps: testDependencies); in python3.pkgs.buildPythonApplication rec { name = "clan-vm-manager"; @@ -36,6 +70,7 @@ python3.pkgs.buildPythonApplication rec { "--unset LD_LIBRARY_PATH" ]; + # Deps needed only at build time nativeBuildInputs = [ setuptools copyDesktopItems @@ -43,50 +78,28 @@ python3.pkgs.buildPythonApplication rec { gobject-introspection ]; - buildInputs = [ - gtk4 - libadwaita - gnome.adwaita-icon-theme - ]; - - # We need to propagate the build inputs to nix fmt / treefmt - propagatedBuildInputs = [ - (python3.pkgs.toPythonModule clan-cli) - passthru.externalPythonDeps - ]; - - checkPython = python3.withPackages (_ps: clan-cli.passthru.pytestDependencies); - - devDependencies = [ - checkPython - weston - ] ++ nativeBuildInputs ++ buildInputs ++ propagatedBuildInputs; - - passthru.checkPython = checkPython; - passthru.devDependencies = devDependencies; + # The necessity of setting buildInputs and propagatedBuildInputs to the + # same values for your Python package within Nix largely stems from ensuring + # that all necessary dependencies are consistently available both + # at build time and runtime, + buildInputs = allPythonDeps ++ runtimeDependencies; + propagatedBuildInputs = allPythonDeps ++ runtimeDependencies; # also re-expose dependencies so we test them in CI passthru = { - inherit desktop-file; - # Keep external dependencies in a separate lists to refer to thm elsewhere - # This helps avoiding issues like dev-shells accidentally depending on - # nix derivations of local packages. - externalPythonDeps = [ - pygobject3 - pygobject-stubs - ]; tests = { clan-vm-manager-pytest = - runCommand "clan-vm-manager-pytest" { nativeBuildInputs = devDependencies; } + runCommand "clan-vm-manager-pytest" { inherit buildInputs propagatedBuildInputs nativeBuildInputs; } '' cp -r ${source} ./src chmod +w -R ./src cd ./src export NIX_STATE_DIR=$TMPDIR/nix IN_NIX_SANDBOX=1 - ${checkPython}/bin/python -m pytest -m "not impure" ./tests + ${pythonWithTestDeps}/bin/python -m pytest -s -m "not impure" ./tests touch $out ''; + clan-vm-manager-no-breakpoints = runCommand "clan-vm-manager-no-breakpoints" { } '' if grep --include \*.py -Rq "breakpoint()" ${source}; then echo "breakpoint() found in ${source}:" @@ -98,6 +111,12 @@ python3.pkgs.buildPythonApplication rec { }; }; + # Additional pass-through attributes + passthru.desktop-file = desktop-file; + passthru.externalPythonDeps = externalPythonDeps; + passthru.testDependencies = testDependencies; + passthru.runtimeDependencies = runtimeDependencies; + # Don't leak python packages into a devshell. # It can be very confusing if you `nix run` than load the cli from the devshell instead. postFixup = '' diff --git a/pkgs/clan-vm-manager/flake-module.nix b/pkgs/clan-vm-manager/flake-module.nix index 06de8e234..fe3d4548b 100644 --- a/pkgs/clan-vm-manager/flake-module.nix +++ b/pkgs/clan-vm-manager/flake-module.nix @@ -4,7 +4,7 @@ { config, pkgs, ... }: { devShells.clan-vm-manager = pkgs.callPackage ./shell.nix { - inherit (config.packages) clan-cli clan-vm-manager; + inherit (config.packages) clan-vm-manager; }; packages.clan-vm-manager = pkgs.python3.pkgs.callPackage ./default.nix { inherit (config.packages) clan-cli; diff --git a/pkgs/clan-vm-manager/pyproject.toml b/pkgs/clan-vm-manager/pyproject.toml index bdacae4c6..aea2ba473 100644 --- a/pkgs/clan-vm-manager/pyproject.toml +++ b/pkgs/clan-vm-manager/pyproject.toml @@ -30,10 +30,6 @@ disallow_untyped_calls = true disallow_untyped_defs = true no_implicit_optional = true -[[tool.mypy.overrides]] -module = "clan_cli.*" -ignore_missing_imports = true - [tool.ruff] target-version = "py311" line-length = 88 diff --git a/pkgs/clan-vm-manager/shell.nix b/pkgs/clan-vm-manager/shell.nix index 6d1d8565f..48b8d10ac 100644 --- a/pkgs/clan-vm-manager/shell.nix +++ b/pkgs/clan-vm-manager/shell.nix @@ -1,84 +1,46 @@ { lib, - runCommand, - makeWrapper, stdenv, clan-vm-manager, - gdb, - gtk4, - libadwaita, - clan-cli, mkShell, ruff, desktop-file-utils, xdg-utils, mypy, python3, - python3Packages, + gtk4, + libadwaita, }: -mkShell ( - let - pygdb = - runCommand "pygdb" - { - buildInputs = [ - gdb - python3 - makeWrapper - ]; - } - '' - mkdir -p "$out/bin" - makeWrapper "${gdb}/bin/gdb" "$out/bin/pygdb" \ - --add-flags '-ex "source ${python3}/share/gdb/libpython.py"' - ''; - in - rec { - inherit (clan-vm-manager) propagatedBuildInputs buildInputs; - linuxOnlyPackages = lib.optionals stdenv.isLinux [ - xdg-utils - pygdb - ]; - - # To debug clan-vm-manger execute pygdb --args python ./bin/clan-vm-manager - packages = [ - ruff - desktop-file-utils +let + devshellTestDeps = + clan-vm-manager.testDependencies + ++ (with python3.pkgs; [ + rope mypy - python3Packages.ipdb - gtk4.dev + ipdb + setuptools + wheel + pip + ]); +in +mkShell { + inherit (clan-vm-manager) nativeBuildInputs; + buildInputs = + [ + ruff + gtk4.dev # has the demo called 'gtk4-widget-factory' libadwaita.devdoc # has the demo called 'adwaita-1-demo' - ] ++ clan-vm-manager.devDependencies ++ linuxOnlyPackages; + ] + ++ devshellTestDeps - PYTHONBREAKPOINT = "ipdb.set_trace"; + # Dependencies for testing for linux hosts + ++ (lib.optionals stdenv.isLinux [ + xdg-utils # install desktop files + desktop-file-utils # verify desktop files + ]); - shellHook = '' - ln -sfT ${clan-cli.nixpkgs} ../clan-cli/clan_cli/nixpkgs - - # prepend clan-cli for development - export PYTHONPATH=../clan-cli:$PYTHONPATH - - - if ! command -v xdg-mime &> /dev/null; then - echo "Warning: 'xdg-mime' is not available. The desktop file cannot be installed." - fi - - # install desktop file - set -eou pipefail - DESKTOP_FILE_NAME=org.clan.vm-manager.desktop - DESKTOP_DST=~/.local/share/applications/$DESKTOP_FILE_NAME - DESKTOP_SRC=${clan-vm-manager}/share/applications/$DESKTOP_FILE_NAME - UI_BIN="${clan-vm-manager}/bin/clan-vm-manager" - - cp -f $DESKTOP_SRC $DESKTOP_DST - sleep 2 - sed -i "s|Exec=.*clan-vm-manager|Exec=$UI_BIN|" $DESKTOP_DST - xdg-mime default $DESKTOP_FILE_NAME x-scheme-handler/clan - echo "==== Validating desktop file installation ====" - set -x - desktop-file-validate $DESKTOP_DST - set +xeou pipefail - ''; - } -) + shellHook = '' + export PATH=$(pwd)/bin:$PATH + ''; +} diff --git a/pkgs/clan-vm-manager/tests/test_join.py b/pkgs/clan-vm-manager/tests/test_join.py new file mode 100644 index 000000000..fff6de20c --- /dev/null +++ b/pkgs/clan-vm-manager/tests/test_join.py @@ -0,0 +1,8 @@ +import time + +from wayland import GtkProc + + +def test_open(app: GtkProc) -> None: + time.sleep(0.5) + assert app.poll() is None diff --git a/pkgs/clan-vm-manager/tests/wayland.py b/pkgs/clan-vm-manager/tests/wayland.py index a9150dd29..1156b666d 100644 --- a/pkgs/clan-vm-manager/tests/wayland.py +++ b/pkgs/clan-vm-manager/tests/wayland.py @@ -1,5 +1,7 @@ +import sys from collections.abc import Generator from subprocess import Popen +from typing import NewType import pytest @@ -7,18 +9,19 @@ import pytest @pytest.fixture(scope="session") def wayland_compositor() -> Generator[Popen, None, None]: # Start the Wayland compositor (e.g., Weston) - compositor = Popen(["weston", "--backend=headless-backend.so"]) + # compositor = Popen(["weston", "--backend=headless-backend.so"]) + compositor = Popen(["weston"]) yield compositor # Cleanup: Terminate the compositor compositor.terminate() +GtkProc = NewType("GtkProc", Popen) + + @pytest.fixture(scope="function") -def gtk_app(wayland_compositor: Popen) -> Generator[Popen, None, None]: - # Assuming your GTK4 app can be started via a command line - # It's important to ensure it uses the Wayland session initiated by the fixture - env = {"GDK_BACKEND": "wayland"} - app = Popen(["clan-vm-manager"], env=env) - yield app +def app() -> Generator[GtkProc, None, None]: + rapp = Popen([sys.executable, "-m", "clan_vm_manager"], text=True) + yield GtkProc(rapp) # Cleanup: Terminate your application - app.terminate() + rapp.terminate()