##// END OF EJS Templates
ImportDenier: implement modern interface
Nikita Kniazev -
Show More
1 NO CONTENT: new file 100644
@@ -0,0 +1,11 b''
1 import importlib
2 import pytest
3 from IPython.external.qt_loaders import ID
4
5
6 def test_import_denier():
7 ID.forbid("ipython_denied_module")
8 with pytest.raises(ImportError, match="disabled by IPython"):
9 import ipython_denied_module
10 with pytest.raises(ImportError, match="disabled by IPython"):
11 importlib.import_module("ipython_denied_module")
@@ -1,401 +1,400 b''
1 1 """
2 2 This module contains factory functions that attempt
3 3 to return Qt submodules from the various python Qt bindings.
4 4
5 5 It also protects against double-importing Qt with different
6 6 bindings, which is unstable and likely to crash
7 7
8 8 This is used primarily by qt and qt_for_kernel, and shouldn't
9 9 be accessed directly from the outside
10 10 """
11 import importlib.abc
11 12 import sys
12 13 import types
13 14 from functools import partial, lru_cache
14 15 import operator
15 16
16 17 from IPython.utils.version import check_version
17 18
18 19 # ### Available APIs.
19 20 # Qt6
20 21 QT_API_PYQT6 = "pyqt6"
21 22 QT_API_PYSIDE6 = "pyside6"
22 23
23 24 # Qt5
24 25 QT_API_PYQT5 = 'pyqt5'
25 26 QT_API_PYSIDE2 = 'pyside2'
26 27
27 28 # Qt4
28 29 QT_API_PYQT = "pyqt" # Force version 2
29 30 QT_API_PYQTv1 = "pyqtv1" # Force version 2
30 31 QT_API_PYSIDE = "pyside"
31 32
32 33 QT_API_PYQT_DEFAULT = "pyqtdefault" # use system default for version 1 vs. 2
33 34
34 35 api_to_module = {
35 36 # Qt6
36 37 QT_API_PYQT6: "PyQt6",
37 38 QT_API_PYSIDE6: "PySide6",
38 39 # Qt5
39 40 QT_API_PYQT5: "PyQt5",
40 41 QT_API_PYSIDE2: "PySide2",
41 42 # Qt4
42 43 QT_API_PYSIDE: "PySide",
43 44 QT_API_PYQT: "PyQt4",
44 45 QT_API_PYQTv1: "PyQt4",
45 46 # default
46 47 QT_API_PYQT_DEFAULT: "PyQt6",
47 48 }
48 49
49 50
50 class ImportDenier(object):
51 class ImportDenier(importlib.abc.MetaPathFinder):
51 52 """Import Hook that will guard against bad Qt imports
52 53 once IPython commits to a specific binding
53 54 """
54 55
55 56 def __init__(self):
56 57 self.__forbidden = set()
57 58
58 59 def forbid(self, module_name):
59 60 sys.modules.pop(module_name, None)
60 61 self.__forbidden.add(module_name)
61 62
62 def find_module(self, fullname, path=None):
63 def find_spec(self, fullname, path, target=None):
63 64 if path:
64 65 return
65 66 if fullname in self.__forbidden:
66 return self
67
68 def load_module(self, fullname):
69 raise ImportError("""
67 raise ImportError(
68 """
70 69 Importing %s disabled by IPython, which has
71 70 already imported an Incompatible QT Binding: %s
72 71 """ % (fullname, loaded_api()))
73 72
74 73
75 74 ID = ImportDenier()
76 75 sys.meta_path.insert(0, ID)
77 76
78 77
79 78 def commit_api(api):
80 79 """Commit to a particular API, and trigger ImportErrors on subsequent
81 80 dangerous imports"""
82 81 modules = set(api_to_module.values())
83 82
84 83 modules.remove(api_to_module[api])
85 84 for mod in modules:
86 85 ID.forbid(mod)
87 86
88 87
89 88 def loaded_api():
90 89 """Return which API is loaded, if any
91 90
92 91 If this returns anything besides None,
93 92 importing any other Qt binding is unsafe.
94 93
95 94 Returns
96 95 -------
97 96 None, 'pyside6', 'pyqt6', 'pyside2', 'pyside', 'pyqt', 'pyqt5', 'pyqtv1'
98 97 """
99 98 if sys.modules.get("PyQt6.QtCore"):
100 99 return QT_API_PYQT6
101 100 elif sys.modules.get("PySide6.QtCore"):
102 101 return QT_API_PYSIDE6
103 102 elif sys.modules.get("PyQt5.QtCore"):
104 103 return QT_API_PYQT5
105 104 elif sys.modules.get("PySide2.QtCore"):
106 105 return QT_API_PYSIDE2
107 106 elif sys.modules.get("PyQt4.QtCore"):
108 107 if qtapi_version() == 2:
109 108 return QT_API_PYQT
110 109 else:
111 110 return QT_API_PYQTv1
112 111 elif sys.modules.get("PySide.QtCore"):
113 112 return QT_API_PYSIDE
114 113
115 114 return None
116 115
117 116
118 117 def has_binding(api):
119 118 """Safely check for PyQt4/5, PySide or PySide2, without importing submodules
120 119
121 120 Parameters
122 121 ----------
123 122 api : str [ 'pyqtv1' | 'pyqt' | 'pyqt5' | 'pyside' | 'pyside2' | 'pyqtdefault']
124 123 Which module to check for
125 124
126 125 Returns
127 126 -------
128 127 True if the relevant module appears to be importable
129 128 """
130 129 module_name = api_to_module[api]
131 130 from importlib.util import find_spec
132 131
133 132 required = ['QtCore', 'QtGui', 'QtSvg']
134 133 if api in (QT_API_PYQT5, QT_API_PYSIDE2, QT_API_PYQT6, QT_API_PYSIDE6):
135 134 # QT5 requires QtWidgets too
136 135 required.append('QtWidgets')
137 136
138 137 for submod in required:
139 138 try:
140 139 spec = find_spec('%s.%s' % (module_name, submod))
141 140 except ImportError:
142 141 # Package (e.g. PyQt5) not found
143 142 return False
144 143 else:
145 144 if spec is None:
146 145 # Submodule (e.g. PyQt5.QtCore) not found
147 146 return False
148 147
149 148 if api == QT_API_PYSIDE:
150 149 # We can also safely check PySide version
151 150 import PySide
152 151 return check_version(PySide.__version__, '1.0.3')
153 152
154 153 return True
155 154
156 155
157 156 def qtapi_version():
158 157 """Return which QString API has been set, if any
159 158
160 159 Returns
161 160 -------
162 161 The QString API version (1 or 2), or None if not set
163 162 """
164 163 try:
165 164 import sip
166 165 except ImportError:
167 166 # as of PyQt5 5.11, sip is no longer available as a top-level
168 167 # module and needs to be imported from the PyQt5 namespace
169 168 try:
170 169 from PyQt5 import sip
171 170 except ImportError:
172 171 return
173 172 try:
174 173 return sip.getapi('QString')
175 174 except ValueError:
176 175 return
177 176
178 177
179 178 def can_import(api):
180 179 """Safely query whether an API is importable, without importing it"""
181 180 if not has_binding(api):
182 181 return False
183 182
184 183 current = loaded_api()
185 184 if api == QT_API_PYQT_DEFAULT:
186 185 return current in [QT_API_PYQT6, None]
187 186 else:
188 187 return current in [api, None]
189 188
190 189
191 190 def import_pyqt4(version=2):
192 191 """
193 192 Import PyQt4
194 193
195 194 Parameters
196 195 ----------
197 196 version : 1, 2, or None
198 197 Which QString/QVariant API to use. Set to None to use the system
199 198 default
200 199
201 200 ImportErrors raised within this function are non-recoverable
202 201 """
203 202 # The new-style string API (version=2) automatically
204 203 # converts QStrings to Unicode Python strings. Also, automatically unpacks
205 204 # QVariants to their underlying objects.
206 205 import sip
207 206
208 207 if version is not None:
209 208 sip.setapi('QString', version)
210 209 sip.setapi('QVariant', version)
211 210
212 211 from PyQt4 import QtGui, QtCore, QtSvg
213 212
214 213 if not check_version(QtCore.PYQT_VERSION_STR, '4.7'):
215 214 raise ImportError("IPython requires PyQt4 >= 4.7, found %s" %
216 215 QtCore.PYQT_VERSION_STR)
217 216
218 217 # Alias PyQt-specific functions for PySide compatibility.
219 218 QtCore.Signal = QtCore.pyqtSignal
220 219 QtCore.Slot = QtCore.pyqtSlot
221 220
222 221 # query for the API version (in case version == None)
223 222 version = sip.getapi('QString')
224 223 api = QT_API_PYQTv1 if version == 1 else QT_API_PYQT
225 224 return QtCore, QtGui, QtSvg, api
226 225
227 226
228 227 def import_pyqt5():
229 228 """
230 229 Import PyQt5
231 230
232 231 ImportErrors raised within this function are non-recoverable
233 232 """
234 233
235 234 from PyQt5 import QtCore, QtSvg, QtWidgets, QtGui
236 235
237 236 # Alias PyQt-specific functions for PySide compatibility.
238 237 QtCore.Signal = QtCore.pyqtSignal
239 238 QtCore.Slot = QtCore.pyqtSlot
240 239
241 240 # Join QtGui and QtWidgets for Qt4 compatibility.
242 241 QtGuiCompat = types.ModuleType('QtGuiCompat')
243 242 QtGuiCompat.__dict__.update(QtGui.__dict__)
244 243 QtGuiCompat.__dict__.update(QtWidgets.__dict__)
245 244
246 245 api = QT_API_PYQT5
247 246 return QtCore, QtGuiCompat, QtSvg, api
248 247
249 248
250 249 def import_pyqt6():
251 250 """
252 251 Import PyQt6
253 252
254 253 ImportErrors raised within this function are non-recoverable
255 254 """
256 255
257 256 from PyQt6 import QtCore, QtSvg, QtWidgets, QtGui
258 257
259 258 # Alias PyQt-specific functions for PySide compatibility.
260 259 QtCore.Signal = QtCore.pyqtSignal
261 260 QtCore.Slot = QtCore.pyqtSlot
262 261
263 262 # Join QtGui and QtWidgets for Qt4 compatibility.
264 263 QtGuiCompat = types.ModuleType("QtGuiCompat")
265 264 QtGuiCompat.__dict__.update(QtGui.__dict__)
266 265 QtGuiCompat.__dict__.update(QtWidgets.__dict__)
267 266
268 267 api = QT_API_PYQT6
269 268 return QtCore, QtGuiCompat, QtSvg, api
270 269
271 270
272 271 def import_pyside():
273 272 """
274 273 Import PySide
275 274
276 275 ImportErrors raised within this function are non-recoverable
277 276 """
278 277 from PySide import QtGui, QtCore, QtSvg
279 278 return QtCore, QtGui, QtSvg, QT_API_PYSIDE
280 279
281 280 def import_pyside2():
282 281 """
283 282 Import PySide2
284 283
285 284 ImportErrors raised within this function are non-recoverable
286 285 """
287 286 from PySide2 import QtGui, QtCore, QtSvg, QtWidgets, QtPrintSupport
288 287
289 288 # Join QtGui and QtWidgets for Qt4 compatibility.
290 289 QtGuiCompat = types.ModuleType('QtGuiCompat')
291 290 QtGuiCompat.__dict__.update(QtGui.__dict__)
292 291 QtGuiCompat.__dict__.update(QtWidgets.__dict__)
293 292 QtGuiCompat.__dict__.update(QtPrintSupport.__dict__)
294 293
295 294 return QtCore, QtGuiCompat, QtSvg, QT_API_PYSIDE2
296 295
297 296
298 297 def import_pyside6():
299 298 """
300 299 Import PySide6
301 300
302 301 ImportErrors raised within this function are non-recoverable
303 302 """
304 303 from PySide6 import QtGui, QtCore, QtSvg, QtWidgets, QtPrintSupport
305 304
306 305 # Join QtGui and QtWidgets for Qt4 compatibility.
307 306 QtGuiCompat = types.ModuleType("QtGuiCompat")
308 307 QtGuiCompat.__dict__.update(QtGui.__dict__)
309 308 QtGuiCompat.__dict__.update(QtWidgets.__dict__)
310 309 QtGuiCompat.__dict__.update(QtPrintSupport.__dict__)
311 310
312 311 return QtCore, QtGuiCompat, QtSvg, QT_API_PYSIDE6
313 312
314 313
315 314 def load_qt(api_options):
316 315 """
317 316 Attempt to import Qt, given a preference list
318 317 of permissible bindings
319 318
320 319 It is safe to call this function multiple times.
321 320
322 321 Parameters
323 322 ----------
324 323 api_options: List of strings
325 324 The order of APIs to try. Valid items are 'pyside', 'pyside2',
326 325 'pyqt', 'pyqt5', 'pyqtv1' and 'pyqtdefault'
327 326
328 327 Returns
329 328 -------
330 329
331 330 A tuple of QtCore, QtGui, QtSvg, QT_API
332 331 The first three are the Qt modules. The last is the
333 332 string indicating which module was loaded.
334 333
335 334 Raises
336 335 ------
337 336 ImportError, if it isn't possible to import any requested
338 337 bindings (either because they aren't installed, or because
339 338 an incompatible library has already been installed)
340 339 """
341 340 loaders = {
342 341 # Qt6
343 342 QT_API_PYQT6: import_pyqt6,
344 343 QT_API_PYSIDE6: import_pyside6,
345 344 # Qt5
346 345 QT_API_PYQT5: import_pyqt5,
347 346 QT_API_PYSIDE2: import_pyside2,
348 347 # Qt4
349 348 QT_API_PYSIDE: import_pyside,
350 349 QT_API_PYQT: import_pyqt4,
351 350 QT_API_PYQTv1: partial(import_pyqt4, version=1),
352 351 # default
353 352 QT_API_PYQT_DEFAULT: import_pyqt6,
354 353 }
355 354
356 355 for api in api_options:
357 356
358 357 if api not in loaders:
359 358 raise RuntimeError(
360 359 "Invalid Qt API %r, valid values are: %s" %
361 360 (api, ", ".join(["%r" % k for k in loaders.keys()])))
362 361
363 362 if not can_import(api):
364 363 continue
365 364
366 365 #cannot safely recover from an ImportError during this
367 366 result = loaders[api]()
368 367 api = result[-1] # changed if api = QT_API_PYQT_DEFAULT
369 368 commit_api(api)
370 369 return result
371 370 else:
372 371 raise ImportError("""
373 372 Could not load requested Qt binding. Please ensure that
374 373 PyQt4 >= 4.7, PyQt5, PySide >= 1.0.3 or PySide2 is available,
375 374 and only one is imported per session.
376 375
377 376 Currently-imported Qt library: %r
378 377 PyQt4 available (requires QtCore, QtGui, QtSvg): %s
379 378 PyQt5 available (requires QtCore, QtGui, QtSvg, QtWidgets): %s
380 379 PySide >= 1.0.3 installed: %s
381 380 PySide2 installed: %s
382 381 Tried to load: %r
383 382 """ % (loaded_api(),
384 383 has_binding(QT_API_PYQT),
385 384 has_binding(QT_API_PYQT5),
386 385 has_binding(QT_API_PYSIDE),
387 386 has_binding(QT_API_PYSIDE2),
388 387 api_options))
389 388
390 389
391 390 def enum_factory(QT_API, QtCore):
392 391 """Construct an enum helper to account for PyQt5 <-> PyQt6 changes."""
393 392
394 393 @lru_cache(None)
395 394 def _enum(name):
396 395 # foo.bar.Enum.Entry (PyQt6) <=> foo.bar.Entry (non-PyQt6).
397 396 return operator.attrgetter(
398 397 name if QT_API == QT_API_PYQT6 else name.rpartition(".")[0]
399 398 )(sys.modules[QtCore.__package__])
400 399
401 400 return _enum
General Comments 0
You need to be logged in to leave comments. Login now