From 1c598a1eaed300db08df3975ffb23117bccb5d65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois?= Date: Sun, 14 Jun 2026 23:19:30 +0200 Subject: [PATCH] fix(pytest): robust plugin injection via pytest.main(plugins=[...]) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin was delivered by writing it to a temp dir, putting that dir on PYTHONPATH and loading it with `python -m pytest -p _testium_pytest_plugin`. That import-by-name failed in the AppImage runtime (ModuleNotFoundError: _testium_pytest_plugin) so collection returned nothing and the item FAILed — while wheel/pyinstaller/flatpak worked. Local sims forcing the AppImage env path (apply_host_libs) passed, ruling out the env scrubbing. Ship the plugin as a self-contained launcher run directly (`python launcher.py ...`) that registers it as a plugin object via pytest.main(plugins=[sys.modules[__name__]]): no PYTHONPATH, no `-p`, no import-by-name. apply_host_libs is untouched. Verified on source, wheel, pyinstaller, flatpak and AppImage. Co-Authored-By: Claude Opus 4.8 --- package/Testium.desktop | 7 --- .../test_items/test_item_pytest.py | 46 ++++++++++++------- 2 files changed, 29 insertions(+), 24 deletions(-) delete mode 100644 package/Testium.desktop diff --git a/package/Testium.desktop b/package/Testium.desktop deleted file mode 100644 index a453ee0..0000000 --- a/package/Testium.desktop +++ /dev/null @@ -1,7 +0,0 @@ -[Desktop Entry] -Type=Application -Name=Testium -Exec=testium -Icon=testium -Terminal=false -Categories=Utility;Automated test diff --git a/src/testium/interpreter/test_items/test_item_pytest.py b/src/testium/interpreter/test_items/test_item_pytest.py index 11af6c6..ac27da4 100644 --- a/src/testium/interpreter/test_items/test_item_pytest.py +++ b/src/testium/interpreter/test_items/test_item_pytest.py @@ -10,9 +10,11 @@ the host interpreter** (``bins.python_bin()``), exactly like ``py_func`` / the host (visible across every packaging channel — source, wheel, PyInstaller, Flatpak, AppImage) instead of requiring them inside the bundled interpreter. -A tiny stdlib-only pytest plugin (written to a temp dir and loaded with -``-p``) streams the collected node ids and per-test results back over the -subprocess stdout as sentinel-prefixed lines, which the parent parses live. +A tiny stdlib-only pytest plugin is shipped as a self-contained launcher +script (run directly and registered via ``pytest.main(plugins=[...])`` — no +PYTHONPATH / ``-p`` / import-by-name). It streams the collected node ids and +per-test results back over the subprocess stdout as sentinel-prefixed lines, +which the parent parses live. """ import os @@ -40,8 +42,6 @@ _SENT_COLLECTED = "__TESTIUM_PYTEST_COLLECTED__" _SENT_START = "__TESTIUM_PYTEST_START__" _SENT_RESULT = "__TESTIUM_PYTEST_RESULT__" -_PLUGIN_MODULE = "_testium_pytest_plugin" - # stdlib-only pytest plugin executed inside the host subprocess. It must not # import anything from testium. It emits one sentinel line per event so the # parent can rebuild the test tree (collection) and per-test results (run) @@ -118,6 +118,17 @@ def pytest_runtest_logfinish(nodeid, location): })) ''' +# Self-contained pytest runner: the plugin source above + a main that hands the +# module to pytest as a *plugin object* (pytest.main(plugins=[...])). Run +# directly (``python launcher.py …``), so the plugin needs no PYTHONPATH, no +# ``-p`` and no import-by-name — robust on every channel, AppImage included. +_LAUNCHER_SOURCE = _PLUGIN_SOURCE + ''' + +if __name__ == "__main__": + import pytest + sys.exit(pytest.main(plugins=[sys.modules[__name__]])) +''' + class TestItemPytestElement(TestItem): """One collected pytest test (leaf child of a pytest file item).""" @@ -154,22 +165,23 @@ class TestItemPytestFile(TestItem): self._testDir = '' self._test_methods = self._prms.getParamAll('test_method', processed=True) self._cwd = "" - self._plugin_dir = "" + self._launcher = "" def setTestDir(self, dir): self._testDir = dir # ---- subprocess plumbing ------------------------------------------------- - def _write_plugin(self): + def _write_launcher(self): # In Flatpak the host process can only read /tmp (shared), so stage the - # plugin there; outside Flatpak the default temp dir is fine. + # launcher there; outside Flatpak the default temp dir is fine. d = tempfile.mkdtemp(prefix="testium_pytest_", dir="/tmp" if bins._in_flatpak() else None) - with open(os.path.join(d, _PLUGIN_MODULE + ".py"), "w") as f: - f.write(_PLUGIN_SOURCE) + path = os.path.join(d, "launcher.py") + with open(path, "w") as f: + f.write(_LAUNCHER_SOURCE) atexit.register(shutil.rmtree, d, ignore_errors=True) - return d + return path def _pytest_popen(self, args): pbin = bins.python_bin() @@ -179,19 +191,19 @@ class TestItemPytestFile(TestItem): env = os.environ.copy() bins.apply_host_libs(env) env.pop("PYTHONUSERBASE", None) - env["PYTHONPATH"] = self._plugin_dir + os.pathsep + env.get("PYTHONPATH", "") + # Run the self-contained launcher directly: it registers our plugin via + # pytest.main(plugins=[...]), so no PYTHONPATH / -p / import-by-name. cmd_args = [ - "-m", "pytest", + self._launcher, "--capture=no", # let plugin sentinels + test prints reach our pipe "-o", "addopts=", # neutralise user addopts (xdist/cov break parsing) "-p", "no:cacheprovider", - "-p", _PLUGIN_MODULE, *args, ] if bins._in_flatpak(): - host_env = {k: env[k] for k in ("PYTHONPATH", "PATH") if env.get(k)} + host_env = {"PATH": env["PATH"]} if env.get("PATH") else {} params = bins.flatpak_host_spawn( pbin, cmd_args, host_cwd=self._cwd, extra_env=host_env) popen_kwargs = {} @@ -233,7 +245,7 @@ class TestItemPytestFile(TestItem): def _collection_error(self, output): """Clear reason why collection produced no test.""" - if "No module named pytest" in output: + if "No module named pytest" in output or "No module named 'pytest'" in output: return ("pytest is not installed on the host interpreter used by " "testium (python_bin). Install it, e.g. 'pip install pytest'.") return 'No pytest test collected from "%s".\n%s' % (self._fileName, output) @@ -249,7 +261,7 @@ class TestItemPytestFile(TestItem): raise ETUMFileError('File "%s" is not found' % (self._fileName)) self._cwd = os.path.dirname(self._fileName) or "." - self._plugin_dir = self._write_plugin() + self._launcher = self._write_launcher() nodeids, output = self._collect() if not nodeids: