From 2d55c994d5cbea28189d3e69637d87d5c28d2b35 Mon Sep 17 00:00:00 2001 From: Peter Corke Date: Mon, 27 Jul 2026 14:26:41 +1000 Subject: [PATCH] test: force headless Matplotlib backend for the whole test session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Local `pytest` runs were popping up real windows and, for a few tests, hanging outright. Two separate problems, one root cause: 1. CI sets MPLBACKEND=Agg via the workflow env, but nothing did that for local runs. Several modules (geom2d, geom3d, spline, animate) import matplotlib.pyplot at module load time, so whatever backend is active when they're first imported sticks for the whole session. Locally that's the platform's interactive backend, hence the popups. Fix: tests/conftest.py sets os.environ.setdefault("MPLBACKEND", "Agg") before anything else runs. conftest.py is guaranteed to load before pytest imports any test module, so this closes the gap for local runs without touching the CI workflow (now redundant there, not conflicting). setdefault(), not a hard override, keeps an escape hatch: `MPLBACKEND=MacOSX pytest ...` still shows you a real plot when you actually want one. 2. Forcing Agg everywhere surfaced a latent bug: Twist/pose animation tests use tranimate(..., wait=True), which busy-loops on `plt.pause()` waiting for FuncAnimation's timer callback to deregister itself. That deregistration is driven by the GUI event loop, which Agg doesn't have — so under Agg the loop never exits. Confirmed with a hard-alarm timeout: the affected test consumed a full 12s budget with zero output, versus completing normally under a real backend. These tests were already skipped in CI (via an `os.environ.get( "CI") == "true"` check) for a related but distinct reason — no display in the CI runner. That check happened to also mask this hang, but only in CI; locally, before this change, it ran fine because a real backend really does pump the event loop. Once Agg is forced locally too, the same hang would trigger there. Fix: broadened the three affected skip conditions (test_animate in test_transforms3d_plot.py; the two animate=True visualize tests and the inline guard in test_spline.py) to additionally skip whenever plt.get_backend() == "agg", which is the actual root cause rather than the CI-env-var proxy for it. Purely additive — no existing skip condition was removed, so CI behaviour is unchanged. Everything else (plain, non-animated plotting tests) now safely runs headless both locally and in CI, which is strictly more coverage than before. Verified: full suite passes locally with CI unset (339 passed, 3 skipped, 1.6s, no windows, no hangs) and with CI=true set (338 passed, 4 skipped, matching prior CI behaviour). --- tests/base/test_transforms3d_plot.py | 7 +++++-- tests/conftest.py | 14 ++++++++++++++ tests/test_spline.py | 16 +++++++++++++--- 3 files changed, 32 insertions(+), 5 deletions(-) create mode 100644 tests/conftest.py diff --git a/tests/base/test_transforms3d_plot.py b/tests/base/test_transforms3d_plot.py index 1f05806e..d18d9300 100755 --- a/tests/base/test_transforms3d_plot.py +++ b/tests/base/test_transforms3d_plot.py @@ -74,9 +74,12 @@ def test_plot(self): plt.close("all") @pytest.mark.skipif( - os.environ.get("CI") == "true" + plt.get_backend().lower() == "agg" + or os.environ.get("CI") == "true" or (sys.platform.startswith("darwin") and sys.version_info < (3, 11)), - reason="no display in CI / tkinter bug on mac", + reason="animation wait=True busy-loop never terminates under the " + "non-interactive Agg backend (no event loop to deregister the " + "timer callback); needs a real display", ) def test_animate(self): tranimate(transl(1, 2, 3), repeat=False, wait=True) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..5ee09ecc --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,14 @@ +import os + +# Force a non-interactive Matplotlib backend for the whole test session, +# before any test module or package code gets a chance to import +# matplotlib.pyplot. Several modules (geom2d, geom3d, spline, animate) +# import pyplot at module load time, so this has to happen here, in +# conftest.py, which pytest guarantees to load before collecting tests. +# +# CI already sets MPLBACKEND=Agg via the workflow env, so this mainly +# fixes local runs, which otherwise use the platform's interactive +# backend and pop up real windows / can hang on plt.pause(). setdefault +# (not a hard override) leaves an escape hatch: run with +# MPLBACKEND=MacOSX pytest ... to actually see a plot when you want to. +os.environ.setdefault("MPLBACKEND", "Agg") diff --git a/tests/test_spline.py b/tests/test_spline.py index 1a8ae0e8..e1fb56c9 100644 --- a/tests/test_spline.py +++ b/tests/test_spline.py @@ -28,7 +28,11 @@ def test_evaluation(self): nt.assert_almost_equal(spline(0).A, self.control_poses[0].A) nt.assert_almost_equal(spline(1).A, self.control_poses[-1].A) - @pytest.mark.skipif(os.environ.get("CI") == "true", reason="no display in CI") + @pytest.mark.skipif( + plt.get_backend().lower() == "agg" or os.environ.get("CI") == "true", + reason="animate=True busy-waits on a timer callback that never " + "fires under the non-interactive Agg backend", + ) def test_visualize(self): spline = BSplineSE3(self.control_poses) spline.visualize( @@ -69,7 +73,11 @@ def test_small_delta_t(self): np.linspace(0, InterpSplineSE3._e, len(self.waypoints)), self.waypoints ) - @pytest.mark.skipif(os.environ.get("CI") == "true", reason="no display in CI") + @pytest.mark.skipif( + plt.get_backend().lower() == "agg" or os.environ.get("CI") == "true", + reason="animate=True busy-waits on a timer callback that never " + "fires under the non-interactive Agg backend", + ) def test_visualize(self): spline = InterpSplineSE3(self.times, self.waypoints) spline.visualize( @@ -110,7 +118,9 @@ def test_spline_fit(self): assert fit.max_angular_error() < np.deg2rad(5.0) assert fit.max_angular_error() < 0.1 - if os.environ.get("CI") != "true": + # animate=True busy-waits on a timer callback that never fires + # under the non-interactive Agg backend + if plt.get_backend().lower() != "agg" and os.environ.get("CI") != "true": spline.visualize( sample_times=np.linspace(0, self.time_horizon, 100), animate=True,