zmc
2023-12-22 9fdbf60165db0400c2e8e6be2dc6e88138ac719a
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
#-----------------------------------------------------------------------------
# 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
 
    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
 
    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)