225 lines
9.3 KiB
Python
225 lines
9.3 KiB
Python
|
#-----------------------------------------------------------------------------
|
||
|
# Copyright (c) 2013-2023, PyInstaller Development Team.
|
||
|
#
|
||
|
# Distributed under the terms of the GNU General Public License (version 2
|
||
|
# or later) with exception for distributing the bootloader.
|
||
|
#
|
||
|
# The full license is in the file COPYING.txt, distributed with this software.
|
||
|
#
|
||
|
# SPDX-License-Identifier: (GPL-2.0-or-later WITH Bootloader-exception)
|
||
|
#-----------------------------------------------------------------------------
|
||
|
|
||
|
from PyInstaller.compat import is_darwin
|
||
|
from PyInstaller.utils.hooks import logger, get_hook_config
|
||
|
from PyInstaller import isolated
|
||
|
|
||
|
|
||
|
@isolated.decorate
|
||
|
def _get_configured_default_backend():
|
||
|
"""
|
||
|
Return the configured default matplotlib backend name, if available as matplotlib.rcParams['backend'] (or overridden
|
||
|
by MPLBACKEND environment variable. If the value of matplotlib.rcParams['backend'] corresponds to the auto-sentinel
|
||
|
object, returns None
|
||
|
"""
|
||
|
import matplotlib
|
||
|
# matplotlib.rcParams overrides the __getitem__ implementation and attempts to determine and load the default
|
||
|
# backend using pyplot.switch_backend(). Therefore, use dict.__getitem__().
|
||
|
val = dict.__getitem__(matplotlib.rcParams, 'backend')
|
||
|
if isinstance(val, str):
|
||
|
return val
|
||
|
return None
|
||
|
|
||
|
|
||
|
@isolated.decorate
|
||
|
def _list_available_mpl_backends():
|
||
|
"""
|
||
|
Returns the names of all available matplotlib backends.
|
||
|
"""
|
||
|
import matplotlib
|
||
|
return matplotlib.rcsetup.all_backends
|
||
|
|
||
|
|
||
|
@isolated.decorate
|
||
|
def _check_mpl_backend_importable(module_name):
|
||
|
"""
|
||
|
Attempts to import the given module name (matplotlib backend module).
|
||
|
|
||
|
Exceptions are propagated to caller.
|
||
|
"""
|
||
|
__import__(module_name)
|
||
|
|
||
|
|
||
|
# Bytecode scanning
|
||
|
def _recursive_scan_code_objects_for_mpl_use(co):
|
||
|
"""
|
||
|
Recursively scan the bytecode for occurrences of matplotlib.use() or mpl.use() calls with const arguments, and
|
||
|
collect those arguments into list of used matplotlib backend names.
|
||
|
"""
|
||
|
|
||
|
from PyInstaller.depend.bytecode import any_alias, recursive_function_calls
|
||
|
|
||
|
mpl_use_names = {
|
||
|
*any_alias("matplotlib.use"),
|
||
|
*any_alias("mpl.use"), # matplotlib is commonly aliased as mpl
|
||
|
}
|
||
|
|
||
|
backends = []
|
||
|
for calls in recursive_function_calls(co).values():
|
||
|
for name, args in calls:
|
||
|
# matplotlib.use(backend) or matplotlib.use(backend, force)
|
||
|
# We support only literal arguments. Similarly, kwargs are
|
||
|
# not supported.
|
||
|
if len(args) not in {1, 2} or not isinstance(args[0], str):
|
||
|
continue
|
||
|
if name in mpl_use_names:
|
||
|
backends.append(args[0])
|
||
|
|
||
|
return backends
|
||
|
|
||
|
|
||
|
def _backend_module_name(name):
|
||
|
"""
|
||
|
Converts matplotlib backend name to its corresponding module name.
|
||
|
|
||
|
Equivalent to matplotlib.cbook._backend_module_name().
|
||
|
"""
|
||
|
if name.startswith("module://"):
|
||
|
return name[9:]
|
||
|
return f"matplotlib.backends.backend_{name.lower()}"
|
||
|
|
||
|
|
||
|
def _autodetect_used_backends(hook_api):
|
||
|
"""
|
||
|
Returns a list of automatically-discovered matplotlib backends in use, or the name of the default matplotlib
|
||
|
backend. Implements the 'auto' backend selection method.
|
||
|
"""
|
||
|
# Scan the code for matplotlib.use()
|
||
|
modulegraph = hook_api.analysis.graph
|
||
|
mpl_code_objs = modulegraph.get_code_using("matplotlib")
|
||
|
used_backends = []
|
||
|
for name, co in mpl_code_objs.items():
|
||
|
co_backends = _recursive_scan_code_objects_for_mpl_use(co)
|
||
|
if co_backends:
|
||
|
logger.info(
|
||
|
"Discovered Matplotlib backend(s) via `matplotlib.use()` call in module %r: %r", name, co_backends
|
||
|
)
|
||
|
used_backends += co_backends
|
||
|
|
||
|
# Deduplicate and sort the list of used backends before displaying it.
|
||
|
used_backends = sorted(set(used_backends))
|
||
|
|
||
|
if used_backends:
|
||
|
HOOK_CONFIG_DOCS = 'https://pyinstaller.org/en/stable/hooks-config.html#matplotlib-hooks'
|
||
|
logger.info(
|
||
|
"The following Matplotlib backends were discovered by scanning for `matplotlib.use()` calls: %r. If your "
|
||
|
"backend of choice is not in this list, either add a `matplotlib.use()` call to your code, or configure "
|
||
|
"the backend collection via hook options (see: %s).", used_backends, HOOK_CONFIG_DOCS
|
||
|
)
|
||
|
return used_backends
|
||
|
|
||
|
# Determine the default matplotlib backend.
|
||
|
#
|
||
|
# Ideally, this would be done by calling ``matplotlib.get_backend()``. However, that function tries to switch to the
|
||
|
# default backend (calling ``matplotlib.pyplot.switch_backend()``), which seems to occasionally fail on our linux CI
|
||
|
# with an error and, on other occasions, returns the headless Agg backend instead of the GUI one (even with display
|
||
|
# server running). Furthermore, using ``matplotlib.get_backend()`` returns headless 'Agg' when display server is
|
||
|
# unavailable, which is not ideal for automated builds.
|
||
|
#
|
||
|
# Therefore, we try to emulate ``matplotlib.get_backend()`` ourselves. First, we try to obtain the configured
|
||
|
# default backend from settings (rcparams and/or MPLBACKEND environment variable). If that is unavailable, we try to
|
||
|
# find the first importable GUI-based backend, using the same list as matplotlib.pyplot.switch_backend() uses for
|
||
|
# automatic backend selection. The difference is that we only test whether the backend module is importable, without
|
||
|
# trying to switch to it.
|
||
|
default_backend = _get_configured_default_backend() # isolated sub-process
|
||
|
if default_backend:
|
||
|
logger.info("Found configured default matplotlib backend: %s", default_backend)
|
||
|
return [default_backend]
|
||
|
|
||
|
candidates = ["Qt5Agg", "Gtk3Agg", "TkAgg", "WxAgg"]
|
||
|
if is_darwin:
|
||
|
candidates = ["MacOSX"] + candidates
|
||
|
logger.info("Trying determine the default backend as first importable candidate from the list: %r", candidates)
|
||
|
|
||
|
for candidate in candidates:
|
||
|
try:
|
||
|
module_name = _backend_module_name(candidate)
|
||
|
_check_mpl_backend_importable(module_name) # NOTE: uses an isolated sub-process.
|
||
|
except Exception:
|
||
|
continue
|
||
|
return [candidate]
|
||
|
|
||
|
# Fall back to headless Agg backend
|
||
|
logger.info("None of the backend candidates could be imported; falling back to headless Agg!")
|
||
|
return ['Agg']
|
||
|
|
||
|
|
||
|
def _collect_all_importable_backends(hook_api):
|
||
|
"""
|
||
|
Returns a list of all importable matplotlib backends. Implements the 'all' backend selection method.
|
||
|
"""
|
||
|
# List of the human-readable names of all available backends.
|
||
|
backend_names = _list_available_mpl_backends() # NOTE: retrieved in an isolated sub-process.
|
||
|
logger.info("All available matplotlib backends: %r", backend_names)
|
||
|
|
||
|
# Try to import the module(s).
|
||
|
importable_backends = []
|
||
|
|
||
|
# List of backends to exclude; Qt4 is not supported by PyInstaller anymore.
|
||
|
exclude_backends = {'Qt4Agg', 'Qt4Cairo'}
|
||
|
|
||
|
# Ignore "CocoaAgg" on OSes other than Mac OS; attempting to import it on other OSes halts the current
|
||
|
# (sub)process without printing output or raising exceptions, preventing reliable detection. Apply the
|
||
|
# same logic for the (newer) "MacOSX" backend.
|
||
|
if not is_darwin:
|
||
|
exclude_backends |= {'CocoaAgg', 'MacOSX'}
|
||
|
|
||
|
# For safety, attempt to import each backend in an isolated sub-process.
|
||
|
for backend_name in backend_names:
|
||
|
if backend_name in exclude_backends:
|
||
|
logger.info(' Matplotlib backend %r: excluded', backend_name)
|
||
|
continue
|
||
|
|
||
|
try:
|
||
|
module_name = _backend_module_name(backend_name)
|
||
|
_check_mpl_backend_importable(module_name) # NOTE: uses an isolated sub-process.
|
||
|
except Exception:
|
||
|
# Backend is not importable, for whatever reason.
|
||
|
logger.info(' Matplotlib backend %r: ignored due to import error', backend_name)
|
||
|
continue
|
||
|
|
||
|
logger.info(' Matplotlib backend %r: added', backend_name)
|
||
|
importable_backends.append(backend_name)
|
||
|
|
||
|
return importable_backends
|
||
|
|
||
|
|
||
|
def hook(hook_api):
|
||
|
# Backend collection setting
|
||
|
backends_method = get_hook_config(hook_api, 'matplotlib', 'backends')
|
||
|
if backends_method is None:
|
||
|
backends_method = 'auto' # default method
|
||
|
|
||
|
# Select backend(s)
|
||
|
if backends_method == 'auto':
|
||
|
logger.info("Matplotlib backend selection method: automatic discovery of used backends")
|
||
|
backend_names = _autodetect_used_backends(hook_api)
|
||
|
elif backends_method == 'all':
|
||
|
logger.info("Matplotlib backend selection method: collection of all importable backends")
|
||
|
backend_names = _collect_all_importable_backends(hook_api)
|
||
|
else:
|
||
|
logger.info("Matplotlib backend selection method: user-provided name(s)")
|
||
|
if isinstance(backends_method, str):
|
||
|
backend_names = [backends_method]
|
||
|
else:
|
||
|
assert isinstance(backends_method, list), "User-provided backend name(s) must be either a string or a list!"
|
||
|
backend_names = backends_method
|
||
|
|
||
|
# Deduplicate and sort the list of selected backends before displaying it.
|
||
|
backend_names = sorted(set(backend_names))
|
||
|
|
||
|
logger.info("Selected matplotlib backends: %r", backend_names)
|
||
|
|
||
|
# Set module names as hiddenimports
|
||
|
module_names = [_backend_module_name(backend) for backend in backend_names] # backend name -> module name
|
||
|
hook_api.add_imports(*module_names)
|