File: test_qt.py

package info (click to toggle)
pyinstaller 6.18.0%2Bds-3
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 11,824 kB
  • sloc: python: 41,828; ansic: 12,123; makefile: 171; sh: 131; xml: 19
file content (743 lines) | stat: -rw-r--r-- 28,239 bytes parent folder | download | duplicates (2)
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
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
#-----------------------------------------------------------------------------
# Copyright (c) 2005-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)
#-----------------------------------------------------------------------------

import os

import pytest

from PyInstaller import isolated
from PyInstaller.compat import is_win, is_darwin, is_linux
from PyInstaller.utils.hooks import check_requirement, can_import_module
from PyInstaller.utils.hooks.qt import get_qt_library_info
from PyInstaller.utils.tests import importorskip, requires, skipif, onedir_only


def qt_param(qt_flavor, *args, **kwargs):
    """
    A Qt flavour to be used in @pytest.mark.parametrize(). Implicitly skips the test if said flavor is not installed.
    """
    p = pytest.param(qt_flavor, *args, **kwargs)
    return pytest.param(*p.values, marks=(requires(qt_flavor),) + p.marks, id=p.id)


# Parametrize test to run the same basic code on both Python Qt libraries.
_QT_PY_PACKAGES = ['PyQt5', 'PyQt6', 'PySide2', 'PySide6']
QtPyLibs = pytest.mark.parametrize('QtPyLib', [qt_param(i) for i in _QT_PY_PACKAGES])

# macOS .app bundles, produced by the ``--windowed`` flag, invoke a unique code path that sometimes causes failures in
# Qt applications. So build with ``--windowed`` option, which will build and run both POSIX build and .app bundle.
USE_WINDOWED_KWARG = dict(pyi_args=['--windowed']) if is_darwin else {}


# We need to ensure that all QtLibraryInfo structures in Qt hook utils are initialized at this point, before the actual
# tests start. This is to prevent test-order-dependent behavior and potential issues, and applies to all platforms.
#
# Some tests (e.g., test_import::test_import_pyqt5_uic_port) may modify search path to fake PyQt5 module, and if that
# test is the point of initialization for the corresponding QtLibraryInfo structure (triggered by hooks' access to
# .version attribute), the structure ends up with invalid data for subsequent tests as well.
#
# Former solution to this problem was clearing QtLibraryInfo.version at the end of pyi_builder() fixture, which triggers
# re-initialization in each test. But as the content of QtLibraryInfo should be immutable (save for the test with fake
# module), it seems better to pre-initialize the structures in order to ensure predictable behavior.
def _ensure_qt_library_info_is_initialized():
    for pkg in _QT_PY_PACKAGES:
        try:
            info = get_qt_library_info(pkg)
            assert info.version  # trigger initialization
        except Exception:
            pass


_ensure_qt_library_info_is_initialized()


@QtPyLibs
def test_Qt_QtWidgets(pyi_builder, QtPyLib):
    pyi_builder.test_source(
        f"""
        import sys

        from {QtPyLib}.QtWidgets import QApplication, QWidget
        from {QtPyLib}.QtCore import QTimer

        is_qt6 = '{QtPyLib}' in {{'PySide6', 'PyQt6'}}

        app = QApplication(sys.argv)
        window = QWidget()
        window.setWindowTitle('Hello world!')
        window.show()

        # Exit Qt when the main loop becomes idle.
        QTimer.singleShot(0, app.exit)

        if is_qt6:
            # Qt6: exec_() is deprecated in PySide6 and removed from PyQt6 in favor of exec()
            res = app.exec()
        else:
            res = app.exec_()
        sys.exit(res)
        """, **USE_WINDOWED_KWARG
    )


@QtPyLibs
def test_Qt_QtQml(pyi_builder, QtPyLib):
    # Qt6 6.6.3 split Qt Quick Controls 2 styles into separate shared libraries, and both PySide6 6.6.3 and PyQt6 6.6.3
    # PyPI wheels failed to account for that. Skip this test if running with affected version.
    if QtPyLib == 'PyQt6':
        # With PyQt6, the shared libraries are missing on Windows and Linux, but not on macOS.
        if not is_darwin and check_requirement('PyQt6-Qt6 == 6.6.3'):
            pytest.skip('PyQt6-Qt6 6.6.3 is missing shared libraries required by Qt Quick Controls 2.')
    if QtPyLib == 'PySide6':
        # With PySide6, all OSes seem to be affected.
        if check_requirement('PySide6-Essentials == 6.6.3'):
            pytest.skip('PySide6-Essentials 6.6.3 is missing shared libraries required by Qt Quick Controls 2.')

    pyi_builder.test_source(
        f"""
        import sys

        from {QtPyLib}.QtGui import QGuiApplication
        from {QtPyLib}.QtQml import QQmlApplicationEngine
        from {QtPyLib}.QtCore import QTimer, QUrl

        is_qt6 = '{QtPyLib}' in {{'PyQt6', 'PySide6'}}

        # Select a style via the `command line
        # <https://doc.qt.io/qt-5/qtquickcontrols2-styles.html#command-line-argument>`_,
        # as PyQt5 currently does not `support https://riverbankcomputing.com/pipermail/pyqt/2018-March/040180.html>`_
        # ``QQuickStyle``. Using this style with the QML below helps to verify that all QML files are packaged; see
        # https://github.com/pyinstaller/pyinstaller/issues/3711.
        #
        # In Qt5, the style name is lower case ('imagine'), whereas in Qt6, it is capitalized ('Imagine')
        app = QGuiApplication(sys.argv + ['-style', 'Imagine' if is_qt6 else 'imagine'])
        engine = QQmlApplicationEngine()
        engine.loadData(b'''
            import QtQuick 2.11
            import QtQuick.Controls 2.4

            ApplicationWindow {{
                visible: true
                ProgressBar {{value: 0.6}}
            }}
            ''', QUrl())

        if not engine.rootObjects():
            sys.exit(-1)

        # Exit Qt when the main loop becomes idle.
        QTimer.singleShot(0, app.exit)

        if is_qt6:
            # Qt6: exec_() is deprecated in PySide6 and removed from PyQt6 in favor of exec()
            res = app.exec()
        else:
            res = app.exec_()
        del engine
        sys.exit(res)
        """, **USE_WINDOWED_KWARG
    )


@QtPyLibs
def test_Qt_QtNetwork_SSL_support(pyi_builder, QtPyLib):
    # Skip the test if QtNetwork does not support SSL (e.g., due to lack of compatible OpenSSL shared library on the
    # test system). Starting with Qt 6.1, different backends provide TLS functionality, so explicitly check if
    # 'openssl' backend is available.
    @isolated.decorate
    def check_openssl_support(package):
        import sys
        import importlib

        QtCore = importlib.import_module('.QtCore', package)
        QtNetwork = importlib.import_module('.QtNetwork', package)

        # We must initialize QCoreApplication before using QtNetwork
        app = QtCore.QCoreApplication(sys.argv)  # noqa: F841

        if not QtNetwork.QSslSocket.supportsSsl():
            return False

        # For Qt >= 6.1, check if `openssl` TLS backend is available
        try:
            qt_version = QtCore.QLibraryInfo.version().segments()
        except AttributeError:
            qt_version = []  # Qt <= 5.8

        if qt_version < [6, 1]:
            return True  # TLS backends not implemented yet

        return 'openssl' in QtNetwork.QSslSocket.availableBackends()

    if not check_openssl_support(QtPyLib):
        pytest.skip('QtNetwork does not use OpenSSL.')

    pyi_builder.test_source(
        f"""
        import sys
        from {QtPyLib}.QtCore import QCoreApplication, QLibraryInfo
        from {QtPyLib}.QtNetwork import QSslSocket

        app = QCoreApplication(sys.argv)

        # Make sure SSL is supported
        assert QSslSocket.supportsSsl(), "SSL not supported!"

        # Display OpenSSL info
        print(
            f"OpenSSL build version: {{QSslSocket.sslLibraryBuildVersionNumber():X}} "
            f"({{QSslSocket.sslLibraryBuildVersionString()}})"
        )
        print(
            f"OpenSSL run-time version: {{QSslSocket.sslLibraryVersionNumber():X}} "
            f"({{QSslSocket.sslLibraryVersionString()}})"
        )

        # Obtain Qt version
        try:
            qt_version = QLibraryInfo.version().segments()
        except AttributeError:
            qt_version = []  # Qt <= 5.8

        # If Qt supports TLS backends (>= 6.1), make sure OpenSSL backend is available.
        if qt_version >= [6, 1]:
            print(f"Active TLS backend: {{QSslSocket.activeBackend()}}")
            print(f"Available TLS backends: {{QSslSocket.availableBackends()}}")
            assert 'openssl' in QSslSocket.availableBackends(), "OpenSSL TLS backend not available!"
        """, **USE_WINDOWED_KWARG
    )


@QtPyLibs
def test_Qt_QTranslate(pyi_builder, QtPyLib):
    pyi_builder.test_source(
        f"""
        import sys
        from {QtPyLib}.QtWidgets import QApplication
        from {QtPyLib}.QtCore import QTranslator, QLocale, QLibraryInfo

        # Initialize Qt default translations
        app = QApplication(sys.argv)
        translator = QTranslator()
        locale = QLocale('de_DE')
        if hasattr(QLibraryInfo, 'path'):
            # Qt6
            translation_path = QLibraryInfo.path(QLibraryInfo.LibraryPath.TranslationsPath)
        else:
            # Qt5
            translation_path = QLibraryInfo.location(QLibraryInfo.TranslationsPath)

        print('Qt locale path: %s' % translation_path)

        if translator.load(locale, "qtbase_", directory=translation_path):
            print('Qt locale %s loaded.' % locale.name())
        else:
            print('Qt locale %s not found!' % locale.name())
            assert False
        """
    )


@QtPyLibs
def test_Qt_Ui_file(pyi_builder, data_dir, QtPyLib):
    pyi_builder.test_source(
        f"""
        import os
        import sys

        import {QtPyLib}.QtQuickWidgets  # Used instead of hiddenimports

        from {QtPyLib}.QtWidgets import QApplication, QWidget
        from {QtPyLib}.QtCore import QTimer

        is_qt6 = '{QtPyLib}' in {{'PyQt6', 'PySide6'}}
        is_pyqt = '{QtPyLib}' in {{'PyQt5', 'PyQt6'}}

        app = QApplication(sys.argv)

        # In Qt6, QtQuick supports multiple render APIs and automatically selects one.
        # However, QtQuickWidgets.QQuickWidget that is used by the test UI file supports only OpenGL,
        # so we need to explicitly select it via QQuickWindow.setGraphicsApi() call.
        if is_qt6:
            try:
                # This seems to be unsupported on macOS version of PySide6 at the time of writing (6.1.0)
                from {QtPyLib}.QtQuick import QQuickWindow, QSGRendererInterface
                QQuickWindow.setGraphicsApi(QSGRendererInterface.GraphicsApi.OpenGL)
            except Exception:
                pass

        # Load the UI
        ui_file = os.path.join(os.path.dirname(__file__), 'gui.ui')
        if is_pyqt:
            # Use PyQt.uic
            from {QtPyLib} import uic
            window = QWidget()
            uic.loadUi(ui_file, window)
        else:
            # Use PySide.QtUiTools.QUiLoader
            from {QtPyLib}.QtUiTools import QUiLoader
            loader = QUiLoader()
            window = loader.load(ui_file)
        window.show()

        # Exit Qt when the main loop becomes idle.
        QTimer.singleShot(0, app.exit)

        # Run the main loop
        if is_qt6:
            # Qt6: exec_() is deprecated in PySide6 and removed from PyQt6 in favor of exec()
            res = app.exec()
        else:
            res = app.exec_()
        sys.exit(res)
        """,
        # Collect the .ui file into top-level application directory.
        pyi_args=['--add-data', f"{data_dir / 'gui.ui'}:."],
    )


# Test that the ``PyQt5.Qt`` module works by importing something from it.
#
# NOTE: the ``PyQt5.Qt`` consolidating module is specific to PyQt5. It is not present in either PySide2 nor PySide6,
# and its consolidating behavior has been removed in PyQt6.
#
# The Qt Bluetooth API (which any import to ``PyQt5.Qt`` implicitly imports) is not compatible with Windows Server
# 2012 R2, the OS Appveyor runs. Specifically, running on Server 2012 causes the test to display an error in
# `a dialog box <https://github.com/mindfulness-at-the-computer/mindfulness-at-the-computer/issues/234>`_.
# The alternative of using a newer Appveyor OS `fails <https://github.com/pyinstaller/pyinstaller/pull/3563>`_.
# Therefore, skip this test on Appveyor by testing for one of its `environment variables
# <https://www.appveyor.com/docs/environment-variables/>`_.
@skipif(os.environ.get('APPVEYOR') == 'True', reason='The Appveyor OS is incompatible with PyQt.Qt.')
@requires('PyQt5')
@pytest.mark.skipif(
    check_requirement('PyQt5 == 5.11.3') and is_darwin,
    reason='This version of the macOS wheel does not include QWebEngine.'
)
def test_PyQt5_Qt(pyi_builder):
    pyi_builder.test_source('from PyQt5.Qt import QLibraryInfo', **USE_WINDOWED_KWARG)


# QtWebEngine tests


# On linux systems with glibc >= 2.34, QtWebEngine helper process crashes with SIGSEGV due to use of `clone3` syscall,
# which is incompatible with chromium sandbox (see QTBUG-96214). The issue was fixed in Qt5 5.15.7, however even the
# latest PyPI wheels of PySide2 (5.15.2.1) and PyQt5/PyQtWebEngine (5.15.6) still seem to ship Qt5 5.15.2 (which was
# probably last publicly available linux build from the Qt itself). If we encounter incompatible combination of
# glibc and Qt5 (for example, using PyPI wheels under Ubuntu 22.04), we disable the sandbox, which allows us to perform
# basic functionality test.
def _disable_qtwebengine_sandbox(qt_flavor):
    if is_linux:
        import platform

        # Check glibc version
        libc_name, libc_version = platform.libc_ver()
        if libc_name != 'glibc':
            return False
        try:
            libc_version = [int(v) for v in libc_version.split('.')]
        except Exception:
            return False
        if libc_version < [2, 34]:
            return False

        # Check Qt version
        qt_info = get_qt_library_info(qt_flavor)
        if qt_info.version and qt_info.version >= [5, 15, 7]:
            return False

        # Incompatible glibc and Qt5 version
        return True

    return False


# Run the the QtWebEngineWidgets test for chosen Qt-based package flavor.
def _test_Qt_QtWebEngineWidgets(pyi_builder, qt_flavor):
    disable_sandbox = _disable_qtwebengine_sandbox(qt_flavor)
    pyi_builder.test_source(
        f"""
        import sys

        # Disable QtWebEngine/chromium sanbox, if necessary
        if {disable_sandbox}:
            import os
            os.environ['QTWEBENGINE_DISABLE_SANDBOX'] = '1'

        from {qt_flavor}.QtWidgets import QApplication
        from {qt_flavor}.QtWebEngineWidgets import QWebEngineView
        from {qt_flavor}.QtCore import QTimer

        is_qt6 = '{qt_flavor}' in {{'PyQt6', 'PySide6'}}

        # Web page to display
        WEB_PAGE_HTML = '''
            <!doctype html>
            <html lang="en">
                <head>
                    <meta charset="utf-8">
                    <title>Test web page</title>
                </head>
                <body>
                    <p>This is a test web page with internationalised characters.</p>
                    <p>HЯ⾀ÄÉÖÜ</p>
                </body>
            </html>
        '''

        app = QApplication(sys.argv)

        class JSResultTester:

            CODE = "1+1"
            EXPECTED = 2

            def __init__(self):
                self.result = None

            def setup(self, view):
                # Need to explicitly pass 0 as world id due to
                # https://bugreports.qt.io/browse/PYSIDE-643
                view.page().runJavaScript(self.CODE, 0, self.store)

                view.page().loadFinished.connect(
                    # Display the web page for one second after it loads.
                    lambda ok: QTimer.singleShot(1000, self.verify_and_quit))

            def store(self, res):
                self.result = res

            def verify_and_quit(self):
                # Make sure the renderer process is alive.
                print("Checking the result of renderer process...", file=sys.stderr)
                if self.result != self.EXPECTED:
                    raise ValueError(
                        f"JS result is {{self.result!r}} but expected {{self.EXPECTED!r}}. "
                        "Is the QtWebEngine renderer process running properly?")
                print("Exiting application's main loop...", file=sys.stderr)
                app.quit()

        view = QWebEngineView()
        view.setHtml(WEB_PAGE_HTML)
        view.show()

        js_result_tester = JSResultTester()
        js_result_tester.setup(view)

        print("Entering application's main loop...", file=sys.stderr)
        if is_qt6:
            # Qt6: exec_() is deprecated in PySide6 and removed from PyQt6 in favor of exec()
            res = app.exec()
        else:
            res = app.exec_()
        print("Exited application's main loop!", file=sys.stderr)

        print("Calling sys.exit()...", file=sys.stderr)
        sys.exit(res)
        """, **USE_WINDOWED_KWARG
    )


# Run the the QtWebEngineQuick test for chosen Qt-based package flavor.
def _test_Qt_QtWebEngineQuick(pyi_builder, qt_flavor):
    disable_sandbox = _disable_qtwebengine_sandbox(qt_flavor)
    pyi_builder.test_source(
        f"""
        import sys

        # Disable QtWebEngine/chromium sanbox, if necessary
        if {disable_sandbox}:
            import os
            os.environ['QTWEBENGINE_DISABLE_SANDBOX'] = '1'

        from {qt_flavor}.QtGui import QGuiApplication
        from {qt_flavor}.QtQml import QQmlApplicationEngine

        is_qt6 = '{qt_flavor}' in {{'PyQt6', 'PySide6'}}

        if is_qt6:
            from {qt_flavor}.QtWebEngineQuick import QtWebEngineQuick
        else:
            from {qt_flavor}.QtWebEngine import QtWebEngine as QtWebEngineQuick

        # Must be called before QGuiApplication is instantiated!
        QtWebEngineQuick.initialize()

        app = QGuiApplication(sys.argv)
        engine = QQmlApplicationEngine()
        engine.loadData(b'''
            import QtQuick 2.0
            import QtQuick.Window 2.0
            import QtWebEngine 1.0

            Window {{
                visible: true
                WebEngineView {{
                    id: view
                    anchors.fill: parent
                    Component.onCompleted: () => {{
                        console.info("Loading HTML...")
                        loadHtml('
                            <!doctype html>
                            <html lang="en">
                                <head>
                                    <meta charset="utf-8">
                                    <title>Test web page</title>
                                </head>
                                <body>
                                    <p>This is a test web page.</p>
                                </body>
                            </html>
                        ')
                    }}
                    onLoadingChanged: (loadRequest) => {{
                        console.info("Web page loading status changed: " + loadRequest.status)
                        if (loadRequest.status !== WebEngineView.LoadStartedStatus) {{
                            console.info("Page loading finished; running shutdown timer (" + timer.interval + " ms)!")
                            timer.running = true
                        }}
                    }}
                }}
                Timer {{
                    id: timer
                    interval: 1000
                    running: false
                    repeat: false
                    onTriggered: () => {{
                        console.info("Shutdown timer triggered; exiting application's main loop...")
                        Qt.quit()
                    }}
                }}
            }}
        ''')

        if not engine.rootObjects():
            raise RuntimeError("No root objects loaded from QML!")

        print("Entering application's main loop...", file=sys.stderr)
        if is_qt6:
            # Qt6: exec_() is deprecated in PySide6 and removed from PyQt6 in favor of exec()
            res = app.exec()
        else:
            res = app.exec_()
        print("Exited application's main loop!", file=sys.stderr)

        del engine

        print("Calling sys.exit()...", file=sys.stderr)
        sys.exit(res)
        """, **USE_WINDOWED_KWARG
    )


@requires('PyQt5')
@requires('PyQtWebEngine')
def test_Qt_QtWebEngineWidgets_PyQt5(pyi_builder):
    _test_Qt_QtWebEngineWidgets(pyi_builder, 'PyQt5')


@requires('PyQt5')
@requires('PyQtWebEngine')
def test_Qt_QtWebEngineQuick_PyQt5(pyi_builder):
    _test_Qt_QtWebEngineQuick(pyi_builder, 'PyQt5')


@requires('PySide2')
def test_Qt_QtWebEngineWidgets_PySide2(pyi_builder):
    _test_Qt_QtWebEngineWidgets(pyi_builder, 'PySide2')


@requires('PySide2')
def test_Qt_QtWebEngineQuick_PySide2(pyi_builder):
    _test_Qt_QtWebEngineQuick(pyi_builder, 'PySide2')


@requires('PyQt6 >= 6.2.2')
@requires('PyQt6-WebEngine')  # NOTE: base Qt6 must be 6.2.2 or newer, QtWebEngine can be older
@pytest.mark.flaky(
    # Attempt to mitigate issues with QtWebEngine 6.10.1
    condition=check_requirement('PyQt6-WebEngine-Qt6 == 6.10.1'),
    reruns=1,
)
def test_Qt_QtWebEngineWidgets_PyQt6(pyi_builder):
    _test_Qt_QtWebEngineWidgets(pyi_builder, 'PyQt6')


@requires('PyQt6 >= 6.2.2')
@requires('PyQt6-WebEngine')  # NOTE: base Qt6 must be 6.2.2 or newer, QtWebEngine can be older
@pytest.mark.skipif(
    check_requirement('PyQt6-Qt6 == 6.6.0'),
    reason='PyQt6 6.6.0 PyPI wheels are missing Qt6WebChannelQuick shared library.'
)
@pytest.mark.skipif(
    check_requirement('PyQt6-Qt6 == 6.6.3') and is_win,
    reason='PyQt6 6.6.3 PyPI wheels for Windows are missing Qt6WebChannelQuick shared library.'
)
@pytest.mark.flaky(
    # The generated .app bundle seems to sporadically freeze during shutdown on GHA macos-14 runners.
    # Attempt to mitigate issues with QtWebEngine 6.10.1
    condition=is_darwin or check_requirement('PyQt6-WebEngine-Qt6 == 6.10.1'),
    reruns=1,
)
def test_Qt_QtWebEngineQuick_PyQt6(pyi_builder):
    _test_Qt_QtWebEngineQuick(pyi_builder, 'PyQt6')


@requires('PySide6 >= 6.2.2')
@pytest.mark.skipif(
    check_requirement('PySide6 == 6.5.0') and is_win,
    reason='PySide6 6.5.0 PyPI wheels for Windows are missing opengl32sw.dll.'
)
@pytest.mark.flaky(
    # Attempt to mitigate issues with QtWebEngine 6.10.1
    condition=check_requirement('PySide6 == 6.10.1'),
)
def test_Qt_QtWebEngineWidgets_PySide6(pyi_builder):
    _test_Qt_QtWebEngineWidgets(pyi_builder, 'PySide6')


@requires('PySide6 >= 6.2.2')
@pytest.mark.skipif(
    check_requirement('PySide6 == 6.5.0') and is_win,
    reason='PySide6 6.5.0 PyPI wheels for Windows are missing opengl32sw.dll.'
)
@pytest.mark.flaky(
    # The generated .app bundle seems to sporadically freeze during shutdown on GHA macos-14 runners.
    # Attempt to mitigate issues with QtWebEngine 6.10.1
    condition=is_darwin or check_requirement('PySide6 == 6.10.1'),
    reruns=1,
)
def test_Qt_QtWebEngineQuick_PySide6(pyi_builder):
    _test_Qt_QtWebEngineQuick(pyi_builder, 'PySide6')


# QtMultimedia test that triggers error when the module's plugins are missing (#7352).
@QtPyLibs
def test_Qt_QtMultimedia_player_init(pyi_builder, QtPyLib):
    # QtMultimedia in PyQt6-Qt6 6.7.1 does not seem to be compatible with PyQt6 6.7.0 (the PyQt6-Qt6 6.7.1 update was
    # pushed on Jun 2 2024 without PyQt6 itself being updated).
    if QtPyLib == 'PyQt6':
        if check_requirement('PyQt6-Qt6 == 6.7.1') and check_requirement('PyQt6 == 6.7.0'):
            pytest.skip('QtMultimedia is broken under PyQt6 6.7.0 and PyQt6-Qt6 6.7.1.')

    pyi_builder.test_source(
        f"""
        import sys

        from {QtPyLib} import QtCore, QtMultimedia

        app = QtCore.QCoreApplication(sys.argv)
        player = QtMultimedia.QMediaPlayer(app)
        """, **USE_WINDOWED_KWARG
    )


# QtMultimedia test that also uses PySide's true_property, which triggers hidden dependency on QtMultimediaWidgets
# python module.
# See:
# https://github.com/pyinstaller/pyinstaller/pull/6496#issuecomment-1011098019
# https://github.com/qtproject/pyside-pyside-setup/blob/5.15.2/sources/shiboken2/shibokenmodule/files.dir/shibokensupport/signature/mapping.py#L577-L586
# https://github.com/qtproject/pyside-pyside-setup/blob/v6.2.2.1/sources/shiboken6/shibokenmodule/files.dir/shibokensupport/signature/mapping.py#L614-L627
@pytest.mark.parametrize('QtPyLib', [
    qt_param('PySide2'),
    qt_param('PySide6'),
])
def test_Qt_QtMultimedia_with_true_property(pyi_builder, QtPyLib):
    pyi_builder.test_source(
        f"""
        import sys
        from {QtPyLib} import QtCore, QtMultimedia
        from __feature__ import true_property

        app = QtCore.QCoreApplication(sys.argv)
        """, **USE_WINDOWED_KWARG
    )


# In PySide6 >= 6.4.0, we need to collect `PySide6.support.deprecated` module for logical operators between Qt key and
# key modifier enums to work. See #7249.
@requires('PySide6')
def test_Qt_PySide6_key_enums(pyi_builder):
    pyi_builder.test_source(
        """
        from PySide6 import QtCore
        key = QtCore.Qt.AltModifier | QtCore.Qt.Key_D
        """
    )


# Basic import tests for all Qt-bindings-provided modules. Each module should be importable on its own, which requires a
# corresponding hook that performs recursive analysis of the module in order to collect all of its dependencies.
#
# Due to the sheer amount of tests, they are ran only in onedir mode.


# Helper that lists all Qt* modules from a Qt-based package. Ran isolated to prevent import affecting the main process.
@isolated.decorate
def _list_all_qt_submodules(package_name):
    import importlib
    import pkgutil

    try:
        package = importlib.import_module(package_name)
    except Exception:
        return []

    return sorted([
        module_info.name for module_info in pkgutil.iter_modules(package.__path__) if module_info.name.startswith("Qt")
    ])


def _test_qt_bindings_import(bindings, module, pyi_builder_onedir):
    # Check if particular module is importable. This guards against errors if a module is unavailable in particular
    # version of the bindings, or if it is provided by an extra package that is not installed.
    modname = bindings + "." + module
    if not can_import_module(modname):
        pytest.skip(f"Module '{modname}' cannot be imported.")
    # Basic import test
    # The import of the tested module is preceeded by import of the QtCore. This seems to prevent segfaults on macOS
    # with certain modules in the frozen test (QtSensors in PySide2 and PyQt5, QtWebEngine* in PySide6, etc.). The
    # segfaults occur when trying to resolve bundle identifier, which may be related to PyInstaller failing to
    # preserve the .framework bundle structure for Qt shared libraries (and importing QtCore somehow works around
    # that). Since QtCore is practically a dependency of all other Qt modules, its import does not affect the results
    # of the test much.
    pyi_builder_onedir.test_source(f"""
        import {bindings}.QtCore
        import {modname}
        """)


@importorskip('PySide2')
@pytest.mark.parametrize('module', _list_all_qt_submodules('PySide2'))
@onedir_only
def test_qt_module_import_PySide2(module, pyi_builder):
    _test_qt_bindings_import("PySide2", module, pyi_builder)


@importorskip('PySide6')
@pytest.mark.parametrize('module', _list_all_qt_submodules('PySide6'))
@onedir_only
def test_qt_module_import_PySide6(module, pyi_builder):
    _test_qt_bindings_import("PySide6", module, pyi_builder)


@importorskip('PyQt5')
@pytest.mark.parametrize('module', _list_all_qt_submodules('PyQt5'))
@onedir_only
def test_qt_module_import_PyQt5(module, pyi_builder):
    _test_qt_bindings_import("PyQt5", module, pyi_builder)


@importorskip('PyQt6')
@pytest.mark.parametrize('module', _list_all_qt_submodules('PyQt6'))
@onedir_only
def test_qt_module_import_PyQt6(module, pyi_builder):
    _test_qt_bindings_import("PyQt6", module, pyi_builder)