Skip to content

Commit 45620ad

Browse files
committed
Improve headlessness detection for backend selection.
We currently check the $DISPLAY environment variable to autodetect whether we should auto-pick a non-interactive backend on Linux, but that variable can be set to an "invalid" value. A realistic use case is for example a tmux session started interactively inheriting an initially valid $DISPLAY, but to which one later reconnects e.g. via ssh, at which point $DISPLAY becomes invalid. Before this PR, something like ``` DISPLAY=:123 MPLBACKEND= MATPLOTLIBRC=/dev/null python -c 'import pylab' ``` (where we unset matplotlibrc to force backend autoselection) would crash when we select qt and qt fails to initialize as $DISPLAY is invalid (qt unconditionally abort()s via qFatal() in that case). With this PR, we correctly autoselect a non-interactive backend.
1 parent 3d725f6 commit 45620ad

File tree

5 files changed

+85
-23
lines changed

5 files changed

+85
-23
lines changed

lib/matplotlib/backend_bases.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2755,10 +2755,15 @@ def show(self):
27552755
warning in `.Figure.show`.
27562756
"""
27572757
# This should be overridden in GUI backends.
2758-
if cbook._get_running_interactive_framework() != "headless":
2759-
raise NonGuiException(
2760-
f"Matplotlib is currently using {get_backend()}, which is "
2761-
f"a non-GUI backend, so cannot show the figure.")
2758+
if sys.platform == "linux" and not os.environ.get("DISPLAY"):
2759+
# We cannot check _get_running_interactive_framework() ==
2760+
# "headless" because that would also suppress the warning when
2761+
# $DISPLAY exists but is invalid, which is more likely an error and
2762+
# thus warrants a warning.
2763+
return
2764+
raise NonGuiException(
2765+
f"Matplotlib is currently using {get_backend()}, which is a "
2766+
f"non-GUI backend, so cannot show the figure.")
27622767

27632768
def destroy(self):
27642769
pass

lib/matplotlib/backends/backend_qt5.py

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,7 @@
66
import sys
77
import traceback
88

9-
import matplotlib
10-
9+
import matplotlib as mpl
1110
from matplotlib import backend_tools, cbook
1211
from matplotlib._pylab_helpers import Gcf
1312
from matplotlib.backend_bases import (
@@ -113,11 +112,8 @@ def _create_qApp():
113112
is_x11_build = False
114113
else:
115114
is_x11_build = hasattr(QtGui, "QX11Info")
116-
if is_x11_build:
117-
display = os.environ.get('DISPLAY')
118-
if display is None or not re.search(r':\d', display):
119-
raise RuntimeError('Invalid DISPLAY variable')
120-
115+
if is_x11_build and not mpl._c_internal_utils.display_is_valid():
116+
raise RuntimeError('Invalid DISPLAY variable')
121117
try:
122118
QtWidgets.QApplication.setAttribute(
123119
QtCore.Qt.AA_EnableHighDpiScaling)
@@ -574,7 +570,7 @@ def __init__(self, canvas, num):
574570

575571
self.window.setCentralWidget(self.canvas)
576572

577-
if matplotlib.is_interactive():
573+
if mpl.is_interactive():
578574
self.window.show()
579575
self.canvas.draw_idle()
580576

@@ -601,9 +597,9 @@ def _widgetclosed(self):
601597
def _get_toolbar(self, canvas, parent):
602598
# must be inited after the window, drawingArea and figure
603599
# attrs are set
604-
if matplotlib.rcParams['toolbar'] == 'toolbar2':
600+
if mpl.rcParams['toolbar'] == 'toolbar2':
605601
toolbar = NavigationToolbar2QT(canvas, parent, True)
606-
elif matplotlib.rcParams['toolbar'] == 'toolmanager':
602+
elif mpl.rcParams['toolbar'] == 'toolmanager':
607603
toolbar = ToolbarQt(self.toolmanager, self.window)
608604
else:
609605
toolbar = None
@@ -619,7 +615,7 @@ def resize(self, width, height):
619615

620616
def show(self):
621617
self.window.show()
622-
if matplotlib.rcParams['figure.raise_window']:
618+
if mpl.rcParams['figure.raise_window']:
623619
self.window.activateWindow()
624620
self.window.raise_()
625621

@@ -792,8 +788,7 @@ def save_figure(self, *args):
792788
sorted_filetypes = sorted(filetypes.items())
793789
default_filetype = self.canvas.get_default_filetype()
794790

795-
startpath = os.path.expanduser(
796-
matplotlib.rcParams['savefig.directory'])
791+
startpath = os.path.expanduser(mpl.rcParams['savefig.directory'])
797792
start = os.path.join(startpath, self.canvas.get_default_filename())
798793
filters = []
799794
selectedFilter = None
@@ -811,8 +806,7 @@ def save_figure(self, *args):
811806
if fname:
812807
# Save dir for next time, unless empty str (i.e., use cwd).
813808
if startpath != "":
814-
matplotlib.rcParams['savefig.directory'] = (
815-
os.path.dirname(fname))
809+
mpl.rcParams['savefig.directory'] = os.path.dirname(fname)
816810
try:
817811
self.canvas.figure.savefig(fname)
818812
except Exception as e:

lib/matplotlib/cbook/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,8 @@ def _get_running_interactive_framework():
7373
if 'matplotlib.backends._macosx' in sys.modules:
7474
if sys.modules["matplotlib.backends._macosx"].event_loop_is_running():
7575
return "macosx"
76-
if sys.platform.startswith("linux") and not os.environ.get("DISPLAY"):
76+
if (sys.platform.startswith("linux")
77+
and not _c_internal_utils.display_is_valid()):
7778
return "headless"
7879
return None
7980

setupext.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -349,8 +349,10 @@ def get_extensions(self):
349349
# c_internal_utils
350350
ext = Extension(
351351
"matplotlib._c_internal_utils", ["src/_c_internal_utils.c"],
352-
libraries=({"win32": ["ole32", "shell32", "user32"]}
353-
.get(sys.platform, [])))
352+
libraries=({
353+
"linux": ["dl"],
354+
"win32": ["ole32", "shell32", "user32"],
355+
}.get(sys.platform, [])))
354356
yield ext
355357
# contour
356358
ext = Extension(

src/_c_internal_utils.c

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,65 @@
11
#define PY_SSIZE_T_CLEAN
22
#include <Python.h>
3+
#ifdef __linux__
4+
#include <dlfcn.h>
5+
#endif
36
#ifdef _WIN32
47
#include <Objbase.h>
58
#include <Shobjidl.h>
69
#include <Windows.h>
710
#endif
811

12+
static PyObject* mpl_display_is_valid(PyObject* module)
13+
{
14+
#ifdef __linux__
15+
void* libX11;
16+
// The getenv check is redundant but helps performance as it is much faster
17+
// than dlopen().
18+
if (getenv("DISPLAY")
19+
&& (libX11 = dlopen("libX11.so", RTLD_LAZY))) {
20+
struct Display* display = NULL;
21+
struct Display* (* XOpenDisplay)(char const*) =
22+
dlsym(libX11, "XOpenDisplay");
23+
int (* XCloseDisplay)(struct Display*) =
24+
dlsym(libX11, "XCloseDisplay");
25+
if (XOpenDisplay && XCloseDisplay
26+
&& (display = XOpenDisplay(NULL))) {
27+
XCloseDisplay(display);
28+
}
29+
if (dlclose(libX11)) {
30+
PyErr_SetString(PyExc_RuntimeError, dlerror());
31+
return NULL;
32+
}
33+
if (display) {
34+
Py_RETURN_TRUE;
35+
}
36+
}
37+
void* libwayland_client;
38+
if (getenv("WAYLAND_DISPLAY")
39+
&& (libwayland_client = dlopen("libwayland-client.so", RTLD_LAZY))) {
40+
struct wl_display* display = NULL;
41+
struct wl_display* (* wl_display_connect)(char const*) =
42+
dlsym(libwayland_client, "wl_display_connect");
43+
int (* wl_display_disconnect)(struct wl_display*) =
44+
dlsym(libwayland_client, "wl_display_disconnect");
45+
if (wl_display_connect && wl_display_disconnect
46+
&& (display = wl_display_connect(NULL))) {
47+
wl_display_disconnect(display);
48+
}
49+
if (dlclose(libwayland_client)) {
50+
PyErr_SetString(PyExc_RuntimeError, dlerror());
51+
return NULL;
52+
}
53+
if (display) {
54+
Py_RETURN_TRUE;
55+
}
56+
}
57+
Py_RETURN_FALSE;
58+
#else
59+
Py_RETURN_TRUE;
60+
#endif
61+
}
62+
963
static PyObject* mpl_GetCurrentProcessExplicitAppUserModelID(PyObject* module)
1064
{
1165
#ifdef _WIN32
@@ -66,6 +120,12 @@ static PyObject* mpl_SetForegroundWindow(PyObject* module, PyObject *arg)
66120
}
67121

68122
static PyMethodDef functions[] = {
123+
{"display_is_valid", (PyCFunction)mpl_display_is_valid, METH_NOARGS,
124+
"display_is_valid()\n--\n\n"
125+
"Check whether the current X11 or Wayland display is valid.\n\n"
126+
"On Linux, returns True if either $DISPLAY is set and XOpenDisplay(NULL)\n"
127+
"succeeds, or $WAYLAND_DISPLAY is set and wl_display_connect(NULL)\n"
128+
"succeeds. On other platforms, always returns True."},
69129
{"Win32_GetCurrentProcessExplicitAppUserModelID",
70130
(PyCFunction)mpl_GetCurrentProcessExplicitAppUserModelID, METH_NOARGS,
71131
"Win32_GetCurrentProcessExplicitAppUserModelID()\n--\n\n"
@@ -83,7 +143,7 @@ static PyMethodDef functions[] = {
83143
"always returns None."},
84144
{"Win32_SetForegroundWindow",
85145
(PyCFunction)mpl_SetForegroundWindow, METH_O,
86-
"Win32_SetForegroundWindow(hwnd)\n--\n\n"
146+
"Win32_SetForegroundWindow(hwnd, /)\n--\n\n"
87147
"Wrapper for Windows' SetForegroundWindow. On non-Windows platforms, \n"
88148
"a no-op."},
89149
{NULL, NULL}}; // sentinel.

0 commit comments

Comments
 (0)