hook-matplotlib.backends.py 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  1. #-----------------------------------------------------------------------------
  2. # Copyright (c) 2013-2023, PyInstaller Development Team.
  3. #
  4. # Distributed under the terms of the GNU General Public License (version 2
  5. # or later) with exception for distributing the bootloader.
  6. #
  7. # The full license is in the file COPYING.txt, distributed with this software.
  8. #
  9. # SPDX-License-Identifier: (GPL-2.0-or-later WITH Bootloader-exception)
  10. #-----------------------------------------------------------------------------
  11. from PyInstaller.compat import is_darwin
  12. from PyInstaller.utils.hooks import logger, get_hook_config
  13. from PyInstaller import isolated
  14. @isolated.decorate
  15. def _get_configured_default_backend():
  16. """
  17. Return the configured default matplotlib backend name, if available as matplotlib.rcParams['backend'] (or overridden
  18. by MPLBACKEND environment variable. If the value of matplotlib.rcParams['backend'] corresponds to the auto-sentinel
  19. object, returns None
  20. """
  21. import matplotlib
  22. # matplotlib.rcParams overrides the __getitem__ implementation and attempts to determine and load the default
  23. # backend using pyplot.switch_backend(). Therefore, use dict.__getitem__().
  24. val = dict.__getitem__(matplotlib.rcParams, 'backend')
  25. if isinstance(val, str):
  26. return val
  27. return None
  28. @isolated.decorate
  29. def _list_available_mpl_backends():
  30. """
  31. Returns the names of all available matplotlib backends.
  32. """
  33. import matplotlib
  34. return matplotlib.rcsetup.all_backends
  35. @isolated.decorate
  36. def _check_mpl_backend_importable(module_name):
  37. """
  38. Attempts to import the given module name (matplotlib backend module).
  39. Exceptions are propagated to caller.
  40. """
  41. __import__(module_name)
  42. # Bytecode scanning
  43. def _recursive_scan_code_objects_for_mpl_use(co):
  44. """
  45. Recursively scan the bytecode for occurrences of matplotlib.use() or mpl.use() calls with const arguments, and
  46. collect those arguments into list of used matplotlib backend names.
  47. """
  48. from PyInstaller.depend.bytecode import any_alias, recursive_function_calls
  49. mpl_use_names = {
  50. *any_alias("matplotlib.use"),
  51. *any_alias("mpl.use"), # matplotlib is commonly aliased as mpl
  52. }
  53. backends = []
  54. for calls in recursive_function_calls(co).values():
  55. for name, args in calls:
  56. # matplotlib.use(backend) or matplotlib.use(backend, force)
  57. # We support only literal arguments. Similarly, kwargs are
  58. # not supported.
  59. if len(args) not in {1, 2} or not isinstance(args[0], str):
  60. continue
  61. if name in mpl_use_names:
  62. backends.append(args[0])
  63. return backends
  64. def _backend_module_name(name):
  65. """
  66. Converts matplotlib backend name to its corresponding module name.
  67. Equivalent to matplotlib.cbook._backend_module_name().
  68. """
  69. if name.startswith("module://"):
  70. return name[9:]
  71. return f"matplotlib.backends.backend_{name.lower()}"
  72. def _autodetect_used_backends(hook_api):
  73. """
  74. Returns a list of automatically-discovered matplotlib backends in use, or the name of the default matplotlib
  75. backend. Implements the 'auto' backend selection method.
  76. """
  77. # Scan the code for matplotlib.use()
  78. modulegraph = hook_api.analysis.graph
  79. mpl_code_objs = modulegraph.get_code_using("matplotlib")
  80. used_backends = []
  81. for name, co in mpl_code_objs.items():
  82. co_backends = _recursive_scan_code_objects_for_mpl_use(co)
  83. if co_backends:
  84. logger.info(
  85. "Discovered Matplotlib backend(s) via `matplotlib.use()` call in module %r: %r", name, co_backends
  86. )
  87. used_backends += co_backends
  88. # Deduplicate and sort the list of used backends before displaying it.
  89. used_backends = sorted(set(used_backends))
  90. if used_backends:
  91. HOOK_CONFIG_DOCS = 'https://pyinstaller.org/en/stable/hooks-config.html#matplotlib-hooks'
  92. logger.info(
  93. "The following Matplotlib backends were discovered by scanning for `matplotlib.use()` calls: %r. If your "
  94. "backend of choice is not in this list, either add a `matplotlib.use()` call to your code, or configure "
  95. "the backend collection via hook options (see: %s).", used_backends, HOOK_CONFIG_DOCS
  96. )
  97. return used_backends
  98. # Determine the default matplotlib backend.
  99. #
  100. # Ideally, this would be done by calling ``matplotlib.get_backend()``. However, that function tries to switch to the
  101. # default backend (calling ``matplotlib.pyplot.switch_backend()``), which seems to occasionally fail on our linux CI
  102. # with an error and, on other occasions, returns the headless Agg backend instead of the GUI one (even with display
  103. # server running). Furthermore, using ``matplotlib.get_backend()`` returns headless 'Agg' when display server is
  104. # unavailable, which is not ideal for automated builds.
  105. #
  106. # Therefore, we try to emulate ``matplotlib.get_backend()`` ourselves. First, we try to obtain the configured
  107. # default backend from settings (rcparams and/or MPLBACKEND environment variable). If that is unavailable, we try to
  108. # find the first importable GUI-based backend, using the same list as matplotlib.pyplot.switch_backend() uses for
  109. # automatic backend selection. The difference is that we only test whether the backend module is importable, without
  110. # trying to switch to it.
  111. default_backend = _get_configured_default_backend() # isolated sub-process
  112. if default_backend:
  113. logger.info("Found configured default matplotlib backend: %s", default_backend)
  114. return [default_backend]
  115. # `QtAgg` supersedes `Qt5Agg`; however, we keep `Qt5Agg` in the candidate list to support older versions of
  116. # matplotlib that do not have `QtAgg`.
  117. candidates = ["QtAgg", "Qt5Agg", "Gtk4Agg", "Gtk3Agg", "TkAgg", "WxAgg"]
  118. if is_darwin:
  119. candidates = ["MacOSX"] + candidates
  120. logger.info("Trying determine the default backend as first importable candidate from the list: %r", candidates)
  121. for candidate in candidates:
  122. try:
  123. module_name = _backend_module_name(candidate)
  124. _check_mpl_backend_importable(module_name) # NOTE: uses an isolated sub-process.
  125. except Exception:
  126. continue
  127. return [candidate]
  128. # Fall back to headless Agg backend
  129. logger.info("None of the backend candidates could be imported; falling back to headless Agg!")
  130. return ['Agg']
  131. def _collect_all_importable_backends(hook_api):
  132. """
  133. Returns a list of all importable matplotlib backends. Implements the 'all' backend selection method.
  134. """
  135. # List of the human-readable names of all available backends.
  136. backend_names = _list_available_mpl_backends() # NOTE: retrieved in an isolated sub-process.
  137. logger.info("All available matplotlib backends: %r", backend_names)
  138. # Try to import the module(s).
  139. importable_backends = []
  140. # List of backends to exclude; Qt4 is not supported by PyInstaller anymore.
  141. exclude_backends = {'Qt4Agg', 'Qt4Cairo'}
  142. # Ignore "CocoaAgg" on OSes other than macOS; attempting to import it on other OSes halts the current
  143. # (sub)process without printing output or raising exceptions, preventing reliable detection. Apply the
  144. # same logic for the (newer) "MacOSX" backend.
  145. if not is_darwin:
  146. exclude_backends |= {'CocoaAgg', 'MacOSX'}
  147. # For safety, attempt to import each backend in an isolated sub-process.
  148. for backend_name in backend_names:
  149. if backend_name in exclude_backends:
  150. logger.info(' Matplotlib backend %r: excluded', backend_name)
  151. continue
  152. try:
  153. module_name = _backend_module_name(backend_name)
  154. _check_mpl_backend_importable(module_name) # NOTE: uses an isolated sub-process.
  155. except Exception:
  156. # Backend is not importable, for whatever reason.
  157. logger.info(' Matplotlib backend %r: ignored due to import error', backend_name)
  158. continue
  159. logger.info(' Matplotlib backend %r: added', backend_name)
  160. importable_backends.append(backend_name)
  161. return importable_backends
  162. def hook(hook_api):
  163. # Backend collection setting
  164. backends_method = get_hook_config(hook_api, 'matplotlib', 'backends')
  165. if backends_method is None:
  166. backends_method = 'auto' # default method
  167. # Select backend(s)
  168. if backends_method == 'auto':
  169. logger.info("Matplotlib backend selection method: automatic discovery of used backends")
  170. backend_names = _autodetect_used_backends(hook_api)
  171. elif backends_method == 'all':
  172. logger.info("Matplotlib backend selection method: collection of all importable backends")
  173. backend_names = _collect_all_importable_backends(hook_api)
  174. else:
  175. logger.info("Matplotlib backend selection method: user-provided name(s)")
  176. if isinstance(backends_method, str):
  177. backend_names = [backends_method]
  178. else:
  179. assert isinstance(backends_method, list), "User-provided backend name(s) must be either a string or a list!"
  180. backend_names = backends_method
  181. # Deduplicate and sort the list of selected backends before displaying it.
  182. backend_names = sorted(set(backend_names))
  183. logger.info("Selected matplotlib backends: %r", backend_names)
  184. # Set module names as hiddenimports
  185. module_names = [_backend_module_name(backend) for backend in backend_names] # backend name -> module name
  186. hook_api.add_imports(*module_names)