File: base_toolkit.py

package info (click to toggle)
python-pyface 8.0.0-5
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 13,944 kB
  • sloc: python: 54,107; makefile: 82
file content (298 lines) | stat: -rw-r--r-- 11,018 bytes parent folder | download
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
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
# (C) Copyright 2005-2023 Enthought, Inc., Austin, TX
# All rights reserved.
#
# This software is provided without warranty under the terms of the BSD
# license included in LICENSE.txt and may be redistributed only under
# the conditions described in the aforementioned license. The license
# is also available online at http://www.enthought.com/licenses/BSD.txt
#
# Thanks for using Enthought open source!
""" Common toolkit loading utilities and classes

This module provides common code for ETS packages that need to do GUI toolkit
discovery and loading.  The common patterns that ETS has settled on are that
where different GUI toolkits require alternative implementations of features
the toolkit is expected to provide a callable object which takes a relative
module path and an object name, separated by a colon and return the toolkit's
implementation of that object (usually this is a class, but it could be
anything).  The assumption is that this is implemented by objects in
sub-modules of the toolkit, but plugin authors are free to use whatever methods
they like.

Which toolkit to use is specified via the :py:mod:`traits.etsconfig.etsconfig`
package, but if this is not explicitly set by an application at startup or via
environment variables, there needs to be a way of discovering and loading any
available working toolkit implementations.  The default mechanism is via the
now-standard :py:mod:`importlib_metadata` and :py:mod:`setuptools`
"entry point" system.

This module provides three things:

- a function :py:func:`import_toolkit` that attempts to find and load a toolkit
  entry point for a specified toolkit name

- a function :py:func:`find_toolkit` that attempts to find a toolkit entry
  point that works

- a class :py:class:`Toolkit` class that implements the standard logic for
  finding toolkit objects.

These are done in a library-agnostic way so that the same tools can be used
not just for different pyface backends, but also for TraitsUI and ETS
libraries where we need to switch between different GUI toolkit
implementations.

Note that there is no requirement for new toolkit implementations to use this
:py:class:`Toolkit` implementation, but they should be compatible with it.

Default toolkit loading logic
-----------------------------

The :py:func:`find_toolkit` function uses the following logic when attempting
to load toolkits:

- if ETSConfig.toolkit is set, try to load a plugin with a matching name.
  If it succeeds, we are good, and if it fails then we error out.

- after that, we try every 'pyface.toolkit' plugin we can find.  If one
  succeeds, we consider ourselves good, and set the ETSConfig.toolkit
  appropriately.  The order is configurable, and by default will try to load
  the `qt` toolkit first, `wx` next, then all others in arbitrary order,
  and `null` last.

- finally, if all else fails, we try to load the null toolkit.
"""

import logging
import os
import sys

try:
    # Starting Python 3.8, importlib.metadata is available in the Python
    # standard library and starting Python 3.10, the "select" interface is
    # available on EntryPoints.
    import importlib.metadata as importlib_metadata
except ImportError:
    import importlib_metadata

from traits.api import HasTraits, List, ReadOnly, Str
from traits.etsconfig.api import ETSConfig

logger = logging.getLogger(__name__)


TOOLKIT_PRIORITIES = {"qt": -2, "wx": -1, "null": float("inf")}
default_priorities = lambda plugin: TOOLKIT_PRIORITIES.get(plugin.name, 0)


class Toolkit(HasTraits):
    """ A basic toolkit implementation for use by specific toolkits.

    This implementation uses pathname mangling to find modules and objects in
    those modules.  If an object can't be found, the toolkit will return a
    class that raises NotImplementedError when it is instantiated.
    """

    #: The name of the package (eg. pyface)
    package = ReadOnly

    #: The name of the toolkit
    toolkit = ReadOnly

    #: The packages to look in for implementations.
    packages = List(Str)

    def __init__(self, package, toolkit, *packages, **traits):
        super().__init__(
            package=package, toolkit=toolkit, packages=list(packages), **traits
        )

    def __call__(self, name):
        """ Return the toolkit specific object with the given name.

        Parameters
        ----------
        name : str
            The name consists of the relative module path and the object name
            separated by a colon.
        """
        from importlib import import_module

        mname, oname = name.split(":")
        if not mname.startswith("."):
            mname = "." + mname

        for package in self.packages:
            try:
                module = import_module(mname, package)
            except ImportError as exc:
                # is the error while trying to import package mname or not?
                if all(
                    part not in exc.args[0]
                    for part in mname.split(".")
                    if part
                ):
                    # something else went wrong - let the exception be raised
                    raise

                # Ignore *ANY* errors unless a debug ENV variable is set.
                if "ETS_DEBUG" in os.environ:
                    # Attempt to only skip errors in importing the backend modules.
                    # The idea here is that this only happens when the last entry in
                    # the traceback's stack frame mentions the toolkit in question.
                    import traceback

                    frames = traceback.extract_tb(sys.exc_info()[2])
                    filename, lineno, function, text = frames[-1]
                    if package not in filename:
                        raise
            else:
                obj = getattr(module, oname, None)
                if obj is not None:
                    return obj

        toolkit = self.toolkit

        class Unimplemented(object):
            """ An unimplemented toolkit object

            This is returned if an object isn't implemented by the selected
            toolkit.  It raises an exception if it is ever instantiated.
            """

            def __init__(self, *args, **kwargs):
                msg = "the %s %s backend doesn't implement %s"
                raise NotImplementedError(msg % (toolkit, package, name))

        return Unimplemented


def import_toolkit(toolkit_name, entry_point="pyface.toolkits"):
    """ Attempt to import an toolkit specified by an entry point.

    Parameters
    ----------
    toolkit_name : str
        The name of the toolkit we would like to load.
    entry_point : str
        The name of the entry point that holds our toolkits.

    Returns
    -------
    toolkit_object : Callable
        A callable object that implements the Toolkit interface.

    Raises
    ------
    RuntimeError
        If no toolkit is found, or if the toolkit cannot be loaded for some
        reason.
    """

    # This compatibility layer can be removed when we drop support for
    # Python < 3.10. Ref https://github.com/enthought/pyface/issues/999.
    all_entry_points = importlib_metadata.entry_points()
    if hasattr(all_entry_points, "select"):
        entry_point_group = all_entry_points.select(group=entry_point)
    else:
        entry_point_group = all_entry_points[entry_point]

    plugins = [
        plugin for plugin in entry_point_group if plugin.name == toolkit_name
    ]
    if len(plugins) == 0:
        msg = "No {} plugin found for toolkit {}"
        msg = msg.format(entry_point, toolkit_name)
        logger.debug(msg)
        raise RuntimeError(msg)
    elif len(plugins) > 1:
        msg = "multiple %r plugins found for toolkit %r: %s"
        module_names = []
        for plugin in plugins:
            module_names.append(plugin.value.split(":")[0])
        module_names = ", ".join(module_names)
        logger.warning(msg, entry_point, toolkit_name, module_names)

    toolkit_exception = None
    for plugin in plugins:
        try:
            toolkit_object = plugin.load()
            return toolkit_object
        except (ImportError, AttributeError) as exc:
            msg = "Could not load plugin %r from %r"
            module_name = plugin.value.split(":")[0]
            logger.info(msg, plugin.name, module_name)
            logger.debug(exc, exc_info=True)
            toolkit_exception = exc

    msg = "No {} plugin could be loaded for {}"
    msg = msg.format(entry_point, toolkit_name)
    logger.info(msg)
    raise RuntimeError(msg) from toolkit_exception


def find_toolkit(
    entry_point="pyface.toolkits",
    toolkits=None,
    priorities=default_priorities,
):
    """ Find a toolkit that works.

    If ETSConfig is set, then attempt to find a matching toolkit.  Otherwise
    try every plugin for the entry_point until one works.  The ordering of the
    plugins is supplied via the priorities function which should be suitable
    for use as a sorting key function.  If all else fails, explicitly try to
    load the "null" toolkit backend.  If that fails, give up.

    Parameters
    ----------
    entry_point : str
        The name of the entry point that holds our toolkits.
    toolkits : collection of strings
        Only consider toolkits which match the given strings, ignore other
        ones.
    priorities : Callable
        A callable function that returns an priority for each plugin.

    Returns
    -------
    toolkit : Toolkit
        A callable object that implements the Toolkit interface.

    Raises
    ------
    RuntimeError
        If no ETSConfig.toolkit is set but the toolkit cannot be loaded for
        some reason.
    """
    if ETSConfig.toolkit:
        return import_toolkit(ETSConfig.toolkit, entry_point)

    # This compatibility layer can be removed when we drop support for
    # Python < 3.10. Ref https://github.com/enthought/pyface/issues/999.
    all_entry_points = importlib_metadata.entry_points()
    if hasattr(all_entry_points, "select"):
        entry_points = [
            plugin for plugin in all_entry_points.select(group=entry_point)
            if toolkits is None or plugin.name in toolkits
        ]
    else:
        entry_points = [
            plugin for plugin in all_entry_points[entry_point]
            if toolkits is None or plugin.name in toolkits
        ]

    for plugin in sorted(entry_points, key=priorities):
        try:
            with ETSConfig.provisional_toolkit(plugin.name):
                toolkit = plugin.load()
                return toolkit
        except (ImportError, AttributeError, RuntimeError) as exc:
            msg = "Could not load %s plugin %r from %r"
            module_name = plugin.value.split(":")[0]
            logger.info(msg, entry_point, plugin.name, module_name)
            logger.debug(exc, exc_info=True)

    # if all else fails, try to import the null toolkit.
    with ETSConfig.provisional_toolkit("null"):
        return import_toolkit("null", entry_point)