diff --git a/require-ess/tools/driver.makefile b/require-ess/tools/driver.makefile index c439a9b1760de3461db0707b263dbd8ea6b4f906..eb811fa269c9dc2e392ccd7f6af75037c85e4910 100644 --- a/require-ess/tools/driver.makefile +++ b/require-ess/tools/driver.makefile @@ -401,13 +401,14 @@ else ifeq ($(shell echo "${LIBVERSION}" | grep -v -E "^$(VERSIONREGEX)\$$"),) install:: build - @test ! -d ${MODULE_LOCATION}/lib/${T_A} || \ - (echo -e "Error: ${MODULE_LOCATION}/lib/${T_A} already exists.\nNote: If you really want to overwrite then uninstall first."; false) + $(if $(wildcard ${MODULE_LOCATION}/lib/${T_A}),$(error ${MODULE_LOCATION}/lib/${T_A} already exists. If you really want to overwrite then uninstall first.)) + else install:: build - @test ! -d ${MODULE_LOCATION}/lib/${T_A} || \ - (echo -e "Warning: Re-installing ${MODULE_LOCATION}/lib/${T_A}"; \ - $(RMDIR) ${MODULE_LOCATION}/lib/${T_A}) + $(if $(wildcard ${MODULE_LOCATION}/lib/${T_A}),\ + $(warning Re-installing ${MODULE_LOCATION}/lib/${T_A})\ + $(RMDIR) ${MODULE_LOCATION}/lib/${T_A}\ + ) endif install build debug:: O.${EPICSVERSION}_${T_A} diff --git a/tests/conftest.py b/tests/conftest.py index 7fdf70eba5fdda19367bfd27456624e5dca2b40c..4ff2cb9a5e54a8a6cc5894c115f41864a8fa7b9d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,11 +10,27 @@ from git import Repo class Wrapper: def __init__(self, root_path: Path, name=None, include_dbd=True, **kwargs): + test_env = os.environ.copy() + + assert "EPICS_BASE" in test_env + assert "E3_REQUIRE_VERSION" in test_env + + e3_require_config = ( + Path(os.environ.get("EPICS_BASE")) + / "require" + / os.environ.get("E3_REQUIRE_VERSION") + / "configure" + ) + + assert e3_require_config.is_dir() + if name is None: name = "test_mod_" + "".join(choice(ascii_lowercase) for _ in range(16)) self.path = root_path / f"e3-{name}" self.name = name + self.version = "0.0.0+0" + module_path = ( name if "E3_MODULE_SRC_PATH" not in kwargs else kwargs["E3_MODULE_SRC_PATH"] ) @@ -24,33 +40,36 @@ class Wrapper: self.config_dir = self.path / "configure" self.config_dir.mkdir() - self.config_module = self.path / "CONFIG_MODULE" + self.config_module = self.config_dir / "CONFIG_MODULE" self.config_module.touch() - self.makefile = self.path / f"{name}.Makefile" - + self.makefile = self.path / "Makefile" makefile_contents = f""" TOP:=$(CURDIR) E3_MODULE_NAME:={name} -E3_MODULE_VERSION?=0.0.0+0 +E3_MODULE_VERSION:={self.version} E3_MODULE_SRC_PATH:={module_path} E3_MODULE_MAKEFILE:={name}.Makefile -include $(TOP)/CONFIG_MODULE +include $(TOP)/configure/CONFIG_MODULE +-include $(TOP)/configure/CONFIG_MODULE.local + +REQUIRE_CONFIG:={e3_require_config} include $(REQUIRE_CONFIG)/CONFIG include $(REQUIRE_CONFIG)/RULES_SITEMODS """ - with open(self.path / "Makefile", "w") as f: - f.write(makefile_contents) + (self.makefile).write_text(makefile_contents) + self.module_makefile = self.path / f"{name}.Makefile" module_makefile_contents = """ where_am_I := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) include $(E3_REQUIRE_TOOLS)/driver.makefile + +EXCLUDE_ARCHS+=debug """ - with open(self.path / f"{name}.Makefile", "w") as f: - f.write(module_makefile_contents) + (self.module_makefile).write_text(module_makefile_contents) if include_dbd: self.add_file("test.dbd") @@ -71,8 +90,8 @@ include $(E3_REQUIRE_TOOLS)/driver.makefile with open(self.config_module, "a") as f: f.write(f"export {makefile_var} {modifier}= {value}\n") - def add_var_to_makefile(self, makefile_var: str, value: str, modifier="+"): - with open(self.makefile, "a") as f: + def add_var_to_module_makefile(self, makefile_var: str, value: str, modifier="+"): + with open(self.module_makefile, "a") as f: f.write(f"{makefile_var} {modifier}= {value}\n") def get_env_var(self, var: str): @@ -88,42 +107,28 @@ include $(E3_REQUIRE_TOOLS)/driver.makefile self, target: str, *args, - module_name: str = "", module_version: str = "", cell_path: str = "", ): """Attempt to run `make <target> <args>` for the current wrapper.""" - test_env = os.environ.copy() - - assert "EPICS_BASE" in test_env - assert Path(test_env["EPICS_BASE"]).is_dir - assert "E3_REQUIRE_VERSION" in test_env - assert test_env["E3_REQUIRE_VERSION"] - - e3_require_location = ( - Path(os.environ.get("EPICS_BASE")) - / "require" - / os.environ.get("E3_REQUIRE_VERSION") - ) - assert e3_require_location.is_dir() - test_env["E3_REQUIRE_LOCATION"] = e3_require_location + # We should not install in the global environment during tests + if target == "install": + target = "cellinstall" - require_config = f"{e3_require_location}/configure" - test_env["REQUIRE_CONFIG"] = require_config + if target == "uninstall": + target = "celluninstall" - if module_name: - test_env["E3_MODULE_NAME"] = module_name + args = list(args) if module_version: - test_env["E3_MODULE_VERSION"] = module_version + args.append(f"E3_MODULE_VERSION={module_version}") if cell_path: self.write_dot_local_data("CONFIG_CELL", {"E3_CELL_PATH": cell_path}) - make_cmd = ["make", "-C", self.path, target] + list(args) + make_cmd = ["make", "-C", self.path, target] + args p = subprocess.Popen( make_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, - env=test_env, encoding="utf-8", ) outs, errs = p.communicate() diff --git a/tests/test_build.py b/tests/test_build.py index fa12115a7b65f6d66359dbc6459b3a3d6dc2b400..8f8f774ff54933965aaca4f123a0156cd5452416 100644 --- a/tests/test_build.py +++ b/tests/test_build.py @@ -4,13 +4,12 @@ from pathlib import Path import pytest +from .conftest import Wrapper from .utils import run_ioc_get_output -MODULE_VERSION = "0.0.0+0" -MODULE_VERSION_NO_BUILD = "0.0.0" - RE_MISSING_FILE = "No rule to make target [`']{filename}'" RE_MISSING_VERSION = "Module '{module}' version '{version}' does not exist." +RE_MODULE_VERSION_EXISTS = "Error .*{module}/{version}.* already exists" def create_patch_file(path, desc): @@ -30,7 +29,7 @@ index 1806ff6..8701832 100644 f.write(patch_file_contents.format(desc=desc)) -def test_patch(wrapper): +def test_patch(wrapper: Wrapper): db_path = wrapper.module_dir / "database.db" db_file_contents = """record(ai, "TEST") { @@ -41,35 +40,32 @@ def test_patch(wrapper): patch_dir = wrapper.path / "patch" create_patch_file(patch_dir / "apply.p0.patch", "OK") - create_patch_file(patch_dir / MODULE_VERSION / "do-not-apply.p0.patch", "Bad") + create_patch_file(patch_dir / wrapper.version / "do-not-apply.p0.patch", "Bad") - rc, _, _ = wrapper.run_make("patch", module_version=MODULE_VERSION) + rc, _, _ = wrapper.run_make("patch") assert rc == 0 with open(db_path, "r") as f: db_contents = f.read() assert 'field(DESC, "OK")' in db_contents assert "Bad" not in db_contents - rc, _, _ = wrapper.run_make("build", module_version=MODULE_VERSION) + rc, _, _ = wrapper.run_make("build") assert rc == 0 assert any((wrapper.module_dir).glob("O.*")) - rc, _, _ = wrapper.run_make("cellinstall", module_version=MODULE_VERSION) + rc, _, _ = wrapper.run_make("cellinstall") assert rc == 0 assert any((wrapper.path / "cellMods").glob("**/*.db")) -def test_local_module(wrapper): - rc, outs, _ = wrapper.run_make( - "init", - module_version=MODULE_VERSION, - ) +def test_local_module(wrapper: Wrapper): + rc, outs, _ = wrapper.run_make("init") assert rc == 0 assert "You are in the local source mode" in outs -def test_missing_dbd_file(wrapper): - wrapper.add_var_to_makefile("DBDS", "nonexistent.dbd") +def test_missing_dbd_file(wrapper: Wrapper): + wrapper.add_var_to_module_makefile("DBDS", "nonexistent.dbd") rc, _, errs = wrapper.run_make("build") assert rc == 2 @@ -79,8 +75,8 @@ def test_missing_dbd_file(wrapper): ) -def test_missing_source_file(wrapper): - wrapper.add_var_to_makefile("SOURCES", "nonexistent.c") +def test_missing_source_file(wrapper: Wrapper): + wrapper.add_var_to_module_makefile("SOURCES", "nonexistent.c") rc, _, errs = wrapper.run_make("build") assert rc == 2 @@ -90,7 +86,7 @@ def test_missing_source_file(wrapper): ) -def test_missing_requirement(wrapper): +def test_missing_requirement(wrapper: Wrapper): wrapper.add_var_to_config_module("FOO_DEP_VERSION", "bar") rc, _, errs = wrapper.run_make("build") @@ -119,14 +115,14 @@ def test_missing_dependent_version(wrappers): ) -def test_header_install_location(wrapper): +def test_header_install_location(wrapper: Wrapper): subdir = wrapper.module_dir / "db" / "subdir" subdir.mkdir(parents=True) extensions = ["h", "hpp", "hxx", "hh"] for ext in extensions: - wrapper.add_var_to_makefile("HEADERS", f"db/subdir/header.{ext}") - wrapper.add_var_to_makefile("KEEP_HEADER_SUBDIRS", "db") + wrapper.add_var_to_module_makefile("HEADERS", f"db/subdir/header.{ext}") + wrapper.add_var_to_module_makefile("KEEP_HEADER_SUBDIRS", "db") for ext in extensions: (subdir / f"header.{ext}").touch() @@ -444,10 +440,10 @@ def test_recursive_header_include(wrappers): wrapper_b.add_var_to_config_module(f"{wrapper_c.name}_DEP_VERSION", module_version) wrapper_a.add_var_to_config_module(f"{wrapper_b.name}_DEP_VERSION", module_version) - wrapper_c.add_var_to_makefile("HEADERS", f"{wrapper_c.name}.h") + wrapper_c.add_var_to_module_makefile("HEADERS", f"{wrapper_c.name}.h") (wrapper_c.module_dir / f"{wrapper_c.name}.h").touch() - wrapper_a.add_var_to_makefile("SOURCES", f"{wrapper_a.name}.c") + wrapper_a.add_var_to_module_makefile("SOURCES", f"{wrapper_a.name}.c") with open(wrapper_a.module_dir / f"{wrapper_a.name}.c", "w") as f: f.write(f'#include "{wrapper_c.name}.h"') @@ -471,8 +467,8 @@ def test_recursive_header_include(wrappers): assert f"Loaded {wrapper_c.name} version {module_version}" in outs -def test_updated_template_files(wrapper): - wrapper.add_var_to_makefile("SUBS", "x.substitutions") +def test_updated_template_files(wrapper: Wrapper): + wrapper.add_var_to_module_makefile("SUBS", "x.substitutions") substitution_file = wrapper.module_dir / "x.substitutions" substitution_file.write_text("file x.template {pattern {x} {y}}") @@ -494,12 +490,12 @@ def test_updated_template_files(wrapper): assert db_file.read_text() == "record(ai, updated) {}" -def test_expand_db_files(wrapper): +def test_expand_db_files(wrapper: Wrapper): """Test that the automated template/substitution file expansion works.""" - wrapper.add_var_to_makefile("TMPS", "templates/a.template") - wrapper.add_var_to_makefile("SUBS", "b.substitutions") - wrapper.add_var_to_makefile("USR_DBFLAGS", "-I templates") + wrapper.add_var_to_module_makefile("TMPS", "templates/a.template") + wrapper.add_var_to_module_makefile("SUBS", "b.substitutions") + wrapper.add_var_to_module_makefile("USR_DBFLAGS", "-I templates") template_dir = wrapper.module_dir / "templates" template_dir.mkdir() @@ -540,10 +536,10 @@ def test_expand_db_files(wrapper): ("foo-bar foo-baz baz baz-qux", "EXCLUDE_ARCHS=foo", ["baz", "baz-qux"]), ], ) -def test_arch_filter(wrapper, installed_archs, param, expected): +def test_arch_filter(wrapper: Wrapper, installed_archs, param, expected): arch_regex = re.compile(r"Pass 2: T_A =\s*([^\s]+)") - wrapper.add_var_to_makefile( + wrapper.add_var_to_module_makefile( "CROSS_COMPILER_TARGET_ARCHS", installed_archs, modifier="" ) @@ -558,12 +554,12 @@ def test_arch_filter(wrapper, installed_archs, param, expected): @pytest.mark.parametrize("archs,failing_arch", [("foo bar", "foo"), ("foo bar", "bar")]) -def test_build_fails_if_nth_architecture_fails(wrapper, archs, failing_arch): +def test_build_fails_if_nth_architecture_fails(wrapper: Wrapper, archs, failing_arch): # LIBOBJS is determined in part based on configuration data coming from # $(CONFIG)/os/CONFIG.Common.$(T_A); since our architectures do not actually # exist, we need to manually define these /before/ driver.makefile is included. - makefile_content = wrapper.makefile.read_text() - with open(wrapper.makefile, "w") as f: + makefile_content = wrapper.module_makefile.read_text() + with open(wrapper.module_makefile, "w") as f: f.write( f"""ifeq ($(T_A),{failing_arch}) LIBOBJS = nonexistent_{failing_arch}.o @@ -576,10 +572,12 @@ def test_build_fails_if_nth_architecture_fails(wrapper, archs, failing_arch): # Skip the host architecture, we are not testing it. host_arch = os.getenv("EPICS_HOST_ARCH") - wrapper.add_var_to_makefile("EXCLUDE_ARCHS", host_arch) + wrapper.add_var_to_module_makefile("EXCLUDE_ARCHS", host_arch) - wrapper.add_var_to_makefile("CROSS_COMPILER_TARGET_ARCHS", archs, modifier="") - wrapper.add_var_to_makefile("SOURCES", "-none-") + wrapper.add_var_to_module_makefile( + "CROSS_COMPILER_TARGET_ARCHS", archs, modifier="" + ) + wrapper.add_var_to_module_makefile("SOURCES", "-none-") rc, _, errs = wrapper.run_make("build") assert rc == 2 @@ -587,3 +585,52 @@ def test_build_fails_if_nth_architecture_fails(wrapper, archs, failing_arch): RE_MISSING_FILE.format(filename=re.escape(f"nonexistent_{failing_arch}.o")), errs, ) + + +def test_double_install_fails(wrapper: Wrapper): + RE_ERR_MOD_VER_EXISTS = ".*{module}/{version}.* already exists" + + rc, *_ = wrapper.run_make("install") + assert rc == 0 + + rc, _, errs = wrapper.run_make("install") + assert rc == 2 + assert re.search( + RE_ERR_MOD_VER_EXISTS.format( + module=re.escape(wrapper.name), version=re.escape(wrapper.version) + ), + errs, + ) + + +def test_double_install_test_version_succeeds(wrapper: Wrapper): + RE_WARN_MOD_VER_EXISTS = "Re-installing .*{module}/{version}.*" + + test_version = "test" + wrapper.write_dot_local_data("CONFIG_MODULE", {"E3_MODULE_VERSION": test_version}) + cell_path = wrapper.get_env_var("E3_MODULES_INSTALL_LOCATION") + + wrapper.add_file("header.h") + + rc, _, errs = wrapper.run_make("install") + assert rc == 0 + assert not re.search( + RE_WARN_MOD_VER_EXISTS.format( + module=re.escape(wrapper.name), version=test_version + ), + errs, + ) + + assert not (Path(cell_path) / "include" / "header.h").is_file() + + wrapper.add_var_to_module_makefile("HEADERS", "header.h") + + rc, _, errs = wrapper.run_make("install") + assert rc == 0 + assert re.search( + RE_WARN_MOD_VER_EXISTS.format( + module=re.escape(wrapper.name), version=test_version + ), + errs, + ) + assert (Path(cell_path) / "include" / "header.h").is_file() diff --git a/tests/test_e3.py b/tests/test_e3.py index 4918681dd553c2a434711cadcc7c2e57027d7d40..b14024309428de1c59568b67a0de119e77f6866c 100644 --- a/tests/test_e3.py +++ b/tests/test_e3.py @@ -1,3 +1,6 @@ +from .conftest import Wrapper + + def test_loc(wrappers): wrapper = wrappers.get(E3_MODULE_SRC_PATH="test-loc") rc, _, errs = wrapper.run_make("build") @@ -5,28 +8,16 @@ def test_loc(wrappers): assert 'Local source mode "-loc" has been deprecated' in errs -def test_sitelibs(wrappers): - # Overwrite the default makefile - wrapper = wrappers.get() - with open(wrapper.path / "Makefile", "w") as f: - f.write( - f""" -TOP:=$(CURDIR) - -E3_MODULE_NAME:={wrapper.name} - -include $(REQUIRE_CONFIG)/RULES_E3 -include $(REQUIRE_CONFIG)/RULES_CELL -include $(REQUIRE_CONFIG)/DEFINES_FT -include $(REQUIRE_CONFIG)/RULES_PATCH -include $(REQUIRE_CONFIG)/RULES_TEST - -include $(REQUIRE_CONFIG)/RULES_DKMS -include $(REQUIRE_CONFIG)/RULES_VARS +def test_sitelibs(wrapper: Wrapper): + makefile_contents = wrapper.makefile.read_text() -include $(REQUIRE_CONFIG)/RULES_DEV -""" + wrapper.makefile.write_text( + makefile_contents.replace( + "include $(REQUIRE_CONFIG)/RULES_SITEMODS", + "include $(REQUIRE_CONFIG)/RULES_E3", ) + ) + rc, _, errs = wrapper.run_make("build") assert rc == 2 assert "RULES_E3 should only be loaded from RULES_SITEMODS" in errs @@ -36,6 +27,15 @@ def test_incorrect_module_name(wrappers): module_name = "ADCore" wrapper = wrappers.get(name=module_name) - rc, _, errs = wrapper.run_make("build", module_name=module_name) + rc, _, errs = wrapper.run_make("build") assert rc == 2 assert f"E3_MODULE_NAME '{module_name}' is not valid" in errs + + +def test_add_missing_build_number(wrapper: Wrapper): + version = "1.2.3" + wrapper.write_dot_local_data("CONFIG_MODULE", {"E3_MODULE_VERSION": version}) + + rc, outs, _ = wrapper.run_make("vars") + assert rc == 0 + assert f"E3_MODULE_VERSION = {version}+0" in outs.splitlines() diff --git a/tests/test_expand_dbd.py b/tests/test_expand_dbd.py new file mode 100644 index 0000000000000000000000000000000000000000..eae44dee4933da57f30e07c7f2130ff15f553cc5 --- /dev/null +++ b/tests/test_expand_dbd.py @@ -0,0 +1,60 @@ +import os +import subprocess +from pathlib import Path + +import pytest + + +@pytest.fixture +def expanddbdtcl(): + require_path = Path(os.environ.get("E3_REQUIRE_LOCATION")) + expanddbdtcl = require_path / "tools" / "expandDBD.tcl" + assert expanddbdtcl.is_file() and os.access(expanddbdtcl, os.X_OK) + return expanddbdtcl + + +def test_missing_dbd_file(tmpdir, expanddbdtcl): + dbd_file = Path(tmpdir) / "tmp.dbd" + dbd_file.write_text("include not_a_file.dbd") + result = subprocess.run( + [expanddbdtcl, dbd_file], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", + ) + assert result.returncode == 1 + assert "file not_a_file.dbd not found" in result.stderr + + +def test_include_dbd_file(tmpdir, expanddbdtcl): + dbd_a = Path(tmpdir) / "a.dbd" + dbd_a.write_text("include b.dbd") + + dbd_b = Path(tmpdir) / "b.dbd" + dbd_b.write_text("content") + + result = subprocess.run( + [expanddbdtcl, "-I", str(tmpdir), dbd_a], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", + ) + assert result.returncode == 0 + assert "content" == result.stdout.strip("\n") + + +def test_skip_repeated_includes(tmpdir, expanddbdtcl): + dbd_a = Path(tmpdir) / "a.dbd" + dbd_a.write_text("include b.dbd\ninclude b.dbd") + + dbd_b = Path(tmpdir) / "b.dbd" + dbd_b.touch() + + result = subprocess.run( + [expanddbdtcl, "-I", str(tmpdir), dbd_a], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", + ) + assert result.returncode == 0 + assert "Info: skipping duplicate file b.dbd included from" in result.stderr diff --git a/tests/test_fixture.py b/tests/test_fixture.py new file mode 100644 index 0000000000000000000000000000000000000000..2e61921b3e3c4772f1a4184a2d52ec48f4be77cb --- /dev/null +++ b/tests/test_fixture.py @@ -0,0 +1,8 @@ +import subprocess + +from .conftest import Wrapper + + +def test_can_run_make_in_wrapper_directory(wrapper: Wrapper): + results = subprocess.run(["make", "-C", wrapper.path, "vars"]) + assert results.returncode == 0 diff --git a/tests/test_versions.py b/tests/test_versions.py index 22522b62944c9ccb0429732440684c1e603fb81c..dcec1f4e7f2b638b0767f8dfd6639033dcaeb819 100644 --- a/tests/test_versions.py +++ b/tests/test_versions.py @@ -2,6 +2,7 @@ import re import pytest +from .conftest import Wrapper from .utils import run_ioc_get_output RE_MODULE_LOADED = "Loaded {module} version {version}" @@ -14,27 +15,27 @@ RE_MODULE_NOT_LOADED = "Module {module} (not available|version {required} not av # If nothing is installed, nothing should be loaded ("", "", []), # Test versions can be loaded - ("test", "test", ["test", "0.0.1"]), + ("test", "test", ["test", "0.0.1+0"]), # Numeric versions should be prioritized over test versions - ("", "0.0.1+0", ["test", "0.0.1"]), - ("", "0.0.1+0", ["0.0.1", "test"]), + ("", "0.0.1+0", ["test", "0.0.1+0"]), + ("", "0.0.1+0", ["0.0.1+0", "test"]), # Highest build number should be loaded if version is unspecified - ("", "0.0.1+7", ["0.0.1", "0.0.1+7", "0.0.1+3"]), + ("", "0.0.1+7", ["0.0.1+0", "0.0.1+7", "0.0.1+3"]), # Only load the given build number if it is specified ("0.0.1+0", "", ["0.0.1+1"]), # If no build number is specified, load the highest build number - ("0.0.1", "0.0.1+4", ["0.1.0", "0.0.1+4", "0.0.1"]), + ("0.0.1", "0.0.1+4", ["0.1.0+0", "0.0.1+4", "0.0.1+0"]), # Build number 0 means load that version exactly ("0.0.1+0", "0.0.1+0", ["0.0.1+0"]), - ("0.0.1+0", "0.0.1+0", ["0.0.1", "0.0.1+1", "1.0.0"]), + ("0.0.1+0", "0.0.1+0", ["0.0.1+0", "0.0.1+1", "1.0.0+0"]), # 1-test counts as a test version, as does 1.0 - ("", "0.0.1+0", ["0.0.1", "1-test"]), - ("", "0.0.1+0", ["0.0.1", "1.0"]), + ("", "0.0.1+0", ["0.0.1+0", "1-test"]), + ("", "0.0.1+0", ["0.0.1+0", "1.0"]), # Numeric version should be prioritised over "higher" test version - ("", "0.1.0+0", ["0.1.0", "1.0.0-rc1"]), + ("", "0.1.0+0", ["0.1.0+0", "1.0.0-rc1"]), ], ) -def test_version(wrapper, requested, expected, installed): +def test_version(wrapper: Wrapper, requested, expected, installed): for version in installed: returncode, _, _ = wrapper.run_make( "clean",