Skip to content

Commit 6e1edea

Browse files
authored
test: force headless Matplotlib backend for local + CI test runs (#197)
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).
1 parent c3eaff6 commit 6e1edea

3 files changed

Lines changed: 32 additions & 5 deletions

File tree

tests/base/test_transforms3d_plot.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,12 @@ def test_plot(self):
7474
plt.close("all")
7575

7676
@pytest.mark.skipif(
77-
os.environ.get("CI") == "true"
77+
plt.get_backend().lower() == "agg"
78+
or os.environ.get("CI") == "true"
7879
or (sys.platform.startswith("darwin") and sys.version_info < (3, 11)),
79-
reason="no display in CI / tkinter bug on mac",
80+
reason="animation wait=True busy-loop never terminates under the "
81+
"non-interactive Agg backend (no event loop to deregister the "
82+
"timer callback); needs a real display",
8083
)
8184
def test_animate(self):
8285
tranimate(transl(1, 2, 3), repeat=False, wait=True)

tests/conftest.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import os
2+
3+
# Force a non-interactive Matplotlib backend for the whole test session,
4+
# before any test module or package code gets a chance to import
5+
# matplotlib.pyplot. Several modules (geom2d, geom3d, spline, animate)
6+
# import pyplot at module load time, so this has to happen here, in
7+
# conftest.py, which pytest guarantees to load before collecting tests.
8+
#
9+
# CI already sets MPLBACKEND=Agg via the workflow env, so this mainly
10+
# fixes local runs, which otherwise use the platform's interactive
11+
# backend and pop up real windows / can hang on plt.pause(). setdefault
12+
# (not a hard override) leaves an escape hatch: run with
13+
# MPLBACKEND=MacOSX pytest ... to actually see a plot when you want to.
14+
os.environ.setdefault("MPLBACKEND", "Agg")

tests/test_spline.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,11 @@ def test_evaluation(self):
2828
nt.assert_almost_equal(spline(0).A, self.control_poses[0].A)
2929
nt.assert_almost_equal(spline(1).A, self.control_poses[-1].A)
3030

31-
@pytest.mark.skipif(os.environ.get("CI") == "true", reason="no display in CI")
31+
@pytest.mark.skipif(
32+
plt.get_backend().lower() == "agg" or os.environ.get("CI") == "true",
33+
reason="animate=True busy-waits on a timer callback that never "
34+
"fires under the non-interactive Agg backend",
35+
)
3236
def test_visualize(self):
3337
spline = BSplineSE3(self.control_poses)
3438
spline.visualize(
@@ -69,7 +73,11 @@ def test_small_delta_t(self):
6973
np.linspace(0, InterpSplineSE3._e, len(self.waypoints)), self.waypoints
7074
)
7175

72-
@pytest.mark.skipif(os.environ.get("CI") == "true", reason="no display in CI")
76+
@pytest.mark.skipif(
77+
plt.get_backend().lower() == "agg" or os.environ.get("CI") == "true",
78+
reason="animate=True busy-waits on a timer callback that never "
79+
"fires under the non-interactive Agg backend",
80+
)
7381
def test_visualize(self):
7482
spline = InterpSplineSE3(self.times, self.waypoints)
7583
spline.visualize(
@@ -110,7 +118,9 @@ def test_spline_fit(self):
110118

111119
assert fit.max_angular_error() < np.deg2rad(5.0)
112120
assert fit.max_angular_error() < 0.1
113-
if os.environ.get("CI") != "true":
121+
# animate=True busy-waits on a timer callback that never fires
122+
# under the non-interactive Agg backend
123+
if plt.get_backend().lower() != "agg" and os.environ.get("CI") != "true":
114124
spline.visualize(
115125
sample_times=np.linspace(0, self.time_horizon, 100),
116126
animate=True,

0 commit comments

Comments
 (0)