diff --git a/Dockerfile-py2.7 b/Dockerfile-py2.7 index a8e59fda..cba92e49 100644 --- a/Dockerfile-py2.7 +++ b/Dockerfile-py2.7 @@ -25,11 +25,16 @@ ENV DISPLAY :99 WORKDIR /workspace/Qt.py ENTRYPOINT cp -r /Qt.py /workspace && \ - python build_caveats_tests.py && \ - Xvfb :99 -screen 0 1024x768x16 2>/dev/null & \ - sleep 3 && \ + python build_caveats.py && \ + python build_membership.py && \ + Xvfb :99 -screen 0 1024x768x16 2>/dev/null & \ + while ! ps aux | grep -q '[0]:00 Xvfb :99 -screen 0 1024x768x16'; \ + do echo "Waiting for Xvfb..."; sleep 1; done && \ + ps aux | grep Xvfb && \ nosetests \ --verbose \ --with-process-isolation \ --with-doctest \ - --exe + --exe \ + test*.py \ + examples/*/*.py diff --git a/Dockerfile-py3.5 b/Dockerfile-py3.5 index 89bedfa4..07e0ec98 100644 --- a/Dockerfile-py3.5 +++ b/Dockerfile-py3.5 @@ -25,11 +25,14 @@ ENV DISPLAY :99 WORKDIR /workspace/Qt.py ENTRYPOINT cp -r /Qt.py /workspace && \ - python3 build_caveats_tests.py && \ - Xvfb :99 -screen 0 1024x768x16 2>/dev/null & \ - sleep 3 && \ + python3 build_caveats.py && \ + Xvfb :99 -screen 0 1024x768x16 2>/dev/null & \ + while ! ps aux | grep -q '[0]:00 Xvfb :99 -screen 0 1024x768x16'; \ + do echo "Waiting for Xvfb..."; sleep 1; done && \ nosetests \ --verbose \ --with-process-isolation \ --with-doctest \ - --exe + --exe \ + test*.py \ + examples/*/*.py diff --git a/Qt.py b/Qt.py index 8cc6a569..4ee34d3e 100644 --- a/Qt.py +++ b/Qt.py @@ -71,6 +71,7 @@ def _pyqt4(): PyQt4.QtCore.QStringListModel = PyQt4.QtGui.QStringListModel PyQt4.QtCore.QItemSelectionModel = PyQt4.QtGui.QItemSelectionModel PyQt4.QtCore.QSortFilterProxyModel = PyQt4.QtGui.QSortFilterProxyModel + PyQt4.QtCore.QAbstractProxyModel = PyQt4.QtGui.QAbstractProxyModel try: from PyQt4 import QtWebKit @@ -117,6 +118,7 @@ def _pyside(): PySide.QtCore.QStringListModel = PySide.QtGui.QStringListModel PySide.QtCore.QItemSelection = PySide.QtGui.QItemSelection PySide.QtCore.QItemSelectionModel = PySide.QtGui.QItemSelectionModel + PySide.QtCore.QAbstractProxyModel = PySide.QtGui.QAbstractProxyModel try: from PySide import QtWebKit diff --git a/README.md b/README.md index 3b9d8604..d60244d6 100644 --- a/README.md +++ b/README.md @@ -79,13 +79,13 @@ app.exec_() All members of `Qt` stem directly from those available via PySide2, along with these additional members. -| Attribute | Type | Value -|:------------------------|:-------|:------------ -| `__binding__` | `str` | A string reference to binding currently in use -| `__qt_version__` | `str` | Reference to version of Qt, such as Qt 5.6.1 -| `__binding_version__` | `str` | Reference to version of binding, such as PySide 1.2.6 -| `__wrapper_version__` | `str` | Version of this project -| `load_ui()` | `func` | Minimal wrapper of PyQt4.loadUi and PySide equivalent +| Attribute | Returns | Description +|:------------------------|:----------|:------------ +| `__binding__` | `str` | A string reference to binding currently in use +| `__qt_version__` | `str` | Reference to version of Qt, such as Qt 5.6.1 +| `__binding_version__` | `str` | Reference to version of binding, such as PySide 1.2.6 +| `__wrapper_version__` | `str` | Version of this project +| `load_ui(fname=str)` | `QObject` | Minimal wrapper of PyQt4.loadUi and PySide equivalent
@@ -136,12 +136,12 @@ import sys import Qt app = QtWidgets.QApplication(sys.argv) -ui = Qt.load_ui("my.ui") +ui = Qt.load_ui(fname="my.ui") ui.show() app.exec_() ``` -Please note, for maximum compatibility, only pass the argument of the filename to the `load_ui` function. +Please note, `load_ui` has only one argument, whereas the PyQt and PySide equivalent has more. See [here](https://github.com/mottosso/Qt.py/pull/81) for details - in a nutshell, those arguments differ between PyQt and PySide in incompatible ways.
diff --git a/build_caveats_tests.py b/build_caveats.py old mode 100755 new mode 100644 similarity index 100% rename from build_caveats_tests.py rename to build_caveats.py diff --git a/build_examples.py b/build_examples.py new file mode 100644 index 00000000..234df7d7 --- /dev/null +++ b/build_examples.py @@ -0,0 +1,11 @@ +import os +import glob +import shutil + +# Copy example files into current working directory +for filepath in glob.glob('examples/*/*'): + filename = os.path.basename(filepath) + if filepath.endswith('.py'): + shutil.copyfile(filepath, 'test_'+filename) # Prepend 'test' to *.py + else: + shutil.copyfile(filepath, filename) diff --git a/build_membership.py b/build_membership.py new file mode 100644 index 00000000..b10b81d2 --- /dev/null +++ b/build_membership.py @@ -0,0 +1,333 @@ +import json + + +def build_membership(): + """Generate a .json file with all members of PySide2""" + + # NOTE: PySide2, as of this writing, is incomplete. + # In it's __all__ module is a module, `QtOpenGL` + # that does no exists. This causes `import *` to fail. + + from PySide2 import __all__ + __all__.remove("QtOpenGL") + + # These modules do not exist pre-Qt 5, + # so do not bother testing for them. + __all__.remove("QtSql") + __all__.remove("QtSvg") + + # These should be present in PySide2, + # but are not as of this writing. + for missing in ("QtWidgets", + "QtXml", + "QtHelp", + "QtPrintSupport"): + __all__.append(missing) + + # Why `import *`? + # + # PySide, and PyQt, perform magic that triggers via Python's + # import mechanism. If we try and sidestep it in any way, say + # by using `imp.load_module` or `__import__`, the mechanism + # will not trigger and the compiled libraries will not get loaded. + # + # Wildcard was the only way I could think of to import everything, + # without hardcoding the members, such as QtCore into the function. + from PySide2 import * + + # Serialise members + members = {} + for name, module in locals().copy().items(): + if name.startswith("_"): + continue + + if name in ("json", "members", "missing"): + continue + + members[name] = list(member for member in dir(module) + if not member.startswith("_")) + + # Write to disk + with open("reference_members.json", "w") as f: + json.dump(members, f, indent=4) + + +def build_tests(): + """Build membership tests + + Members only available in Qt 5 are excluded, along with member + exclusive to a paricular binding. + + """ + + header = """\ +# +# AUTOMATICALLY GENERATED MEMBERSHIP TEST, DO NOT MODIFY +# + +import os +import json + +with open("reference_members.json") as f: + reference_members = json.load(f) + +excluded = {excluded} + +""".format(excluded=json.dumps(excluded, indent=4)) + + test = """\ +def test_{binding}_members(): + os.environ["QT_PREFERRED_BINDING"] = "{Binding}" + + if "PyQt" in "{Binding}": + # PyQt4 and 5 performs some magic here + # that must take place before attempting + # to import with wildcard. + from Qt import Qt as _ + + if "PySide2" == "{Binding}": + # PySide2, as of this writing, doesn't include + # these modules in it's __all__ list; leaving + # the wildcard import below untrue. + from Qt import __all__ + for missing in ("QtWidgets", + "QtXml", + "QtHelp", + "QtPrintSupport"): + __all__.append(missing) + + from Qt import * + + if "PySide" == "{Binding}": + # Qt 4 bindings do not include QtWidgets + # in their __all__ list. And who knows what else. + # + # TODO: This needs a more robust implementation. + from Qt import QtWidgets + + target_members = dict() + for name, module in locals().copy().items(): + if name.startswith("_"): + continue + + target_members[name] = dir(module) + + missing = dict() + for module, members in reference_members.items(): + for member in members: + + # Ignore those that have no Qt 4-equivalent. + if member in excluded.get(module, []): + continue + + if member not in target_members.get(module, []): + if module not in missing: + missing[module] = [] + missing[module].append(member) + + message = "" + for module, members in missing.items(): + message += "\\n%s: \\n - %s" % (module, "\\n - ".join(members)) + + assert not missing, "{Binding} is missing members: %s" % message + +""" + + tests = list(test.format(Binding=binding, + binding=binding.lower()) + for binding in ["PyQt5", + "PyQt4", + "PySide"]) + + with open("test_membership.py", "w") as f: + contents = header + "\n".join(tests) + print(contents) # Preview content during tests + f.write(contents) + + +# Do not consider these members. +# +# Some of these are either: +# 1. Unique to a particular binding +# 2. Unique to Qt 5 +# 3. Not yet included in PySide2 +# +# TODO: Clearly mark which are which. (3) should +# eventually be removed from this dictionary. +excluded = { + "QtCore": [ + # missing from PySide + "Connection", + "QBasicMutex", + "QFileDevice", + "QItemSelectionRange", + "QJsonArray", + "QJsonDocument", + "QJsonParseError", + "QJsonValue", + "QMessageLogContext", + "QtInfoMsg", + "qInstallMessageHandler", + + # missing from PyQt4 + "ClassInfo", + "MetaFunction", + "QFactoryInterface", + "QSortFilterProxyModel", + "QStringListModel", + "QT_TRANSLATE_NOOP3", + "QT_TRANSLATE_NOOP_UTF8", + "__moduleShutdown", + "__version__", # (2) unique to PyQt + "__version_info__", # (2) unique to PyQt + "qAcos", + "qAsin", + "qAtan", + "qAtan2", + "qExp", + "qFabs", + "qFastCos", + "qFastSin", + "qFuzzyIsNull", + "qTan", + "qtTrId", + + # missing from PyQt5 + "SIGNAL", + "SLOT", + ], + + "QtGui": [ + # missing from PySide + "QGuiApplication", # (2) unique to Qt 5 + "QPagedPaintDevice", + "QSurface", + "QSurfaceFormat", + "QTouchDevice", + "QWindow", # (2) unique to Qt 5 + + # missing from PyQt4 + "QAccessibleEvent", + "QToolBarChangeEvent", + + # missing from PyQt5 + "QMatrix", + "QPyTextObject", + "QStringListModel", + ], + + "QtWebKit": [ + # missing from PyQt4 + "WebCore", + + # missing from PyQt5 + "__doc__", + "__file__", + "__name__", + "__package__", + ], + + "QtScript": [ + # missing from PyQt4 + "QScriptExtensionInterface", + "QScriptExtensionPlugin", + "QScriptProgram", + "QScriptable", + + # missing from PyQt5 + "QScriptClass", + "QScriptClassPropertyIterator", + "QScriptContext", + "QScriptContextInfo", + "QScriptEngine", + "QScriptEngineAgent", + "QScriptString", + "QScriptValue", + "QScriptValueIterator", + "__doc__", + "__file__", + "__name__", + "__package__", + ], + + "QtNetwork": [ + # missing from PyQt4 + "QIPv6Address", + ], + + "QtPrintSupport": [ + # PyQt4 + "QAbstractPrintDialog", + "QPageSetupDialog", + "QPrintDialog", + "QPrintEngine", + "QPrintPreviewDialog", + "QPrintPreviewWidget", + "QPrinter", + "QPrinterInfo", + ], + + "QtWidgets": [ + # PyQt4 + "QTileRules", + + # PyQt5 + "QGraphicsItemAnimation", + "QTileRules", + ], + + "QtHelp": [ + # PySide + "QHelpContentItem", + "QHelpContentModel", + "QHelpContentWidget", + "QHelpEngine", + "QHelpEngineCore", + "QHelpIndexModel", + "QHelpIndexWidget", + "QHelpSearchEngine", + "QHelpSearchQuery", + "QHelpSearchQueryWidget", + "QHelpSearchResultWidget", + ], + + "QtXml": [ + # PySide + "QDomAttr", + "QDomCDATASection", + "QDomCharacterData", + "QDomComment", + "QDomDocument", + "QDomDocumentFragment", + "QDomDocumentType", + "QDomElement", + "QDomEntity", + "QDomEntityReference", + "QDomImplementation", + "QDomNamedNodeMap", + "QDomNode", + "QDomNodeList", + "QDomNotation", + "QDomProcessingInstruction", + "QDomText", + "QXmlAttributes", + "QXmlContentHandler", + "QXmlDTDHandler", + "QXmlDeclHandler", + "QXmlDefaultHandler", + "QXmlEntityResolver", + "QXmlErrorHandler", + "QXmlInputSource", + "QXmlLexicalHandler", + "QXmlLocator", + "QXmlNamespaceSupport", + "QXmlParseException", + "QXmlReader", + "QXmlSimpleReader", + ], + +} + +if __name__ == '__main__': + build_membership() + build_tests() diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 00000000..1087732e --- /dev/null +++ b/examples/README.md @@ -0,0 +1,15 @@ +## Examples + +#### Purpose + +Sometimes a pull request or a feature request doesn't make it into Qt.py because it doesn't fit [the contribution guidelines](https://github.com/mottosso/Qt.py/blob/master/CONTRIBUTING.md). This is hopefully a good thing for the end product in the long term perspective, but we're always sad to see good code drift into oblivion and disappear when a pull request is turned down and closed. + +This part of the Qt.py project is a more loosely maintained (although tested) space, where we welcome example use of Qt.py to be shown off especially if it solves a problem Qt.py by itself doesn't solve out of the box. + +If you wish to contribute, make a pull request. Please put your example files in a sub-folder of `/examples`. If you also wish to have your example included in testing, make sure the function you wish to be executed during testing is named in such a way that it starts with `test`. For a working example of examples :wink:, see the `/examples/load_ui` folder. + +
+ +#### List of examples + +* [`load_ui`](https://github.com/mottosso/Qt.py/blob/master/examples/load_ui/README.md) - add base instance argument diff --git a/examples/__init__.py b/examples/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/load_ui/README.md b/examples/load_ui/README.md new file mode 100644 index 00000000..e7046cdd --- /dev/null +++ b/examples/load_ui/README.md @@ -0,0 +1,17 @@ +## `load_ui` examples + +#### Base instance as argument + +The `uic.loadUi` function of PyQt4 and PyQt5 as well as the `QtUiTools.QUiLoader().load` function of PySide/PySide2 are mapped to a convenience function in Qt.py called `load_ui`. This function only accepts the filename argument of the .ui file. + +A popular approach is to provide a base instance argument to PyQt's `uic.loadUi`, into which all widgets are loaded: + +```python +# PyQt4, PyQt5 +class MainWindow(QtWidgets.QWidget): + def __init__(self, parent=None): + QtWidgets.QWidget.__init__(self, parent) + uic.loadUi('uifile.ui', self) # Loads all widgets of uifile.ui into self +``` + +PySide does not support this out of the box, but it can be implemented in various ways. In the example in `baseinstance1.py`, a support function `setup_ui` is defined which wraps `load_ui` and provides this second base instance argument. In `baseinstance2.py`, another approach is used where `pysideuic` is required for PySide/PySide2 and `uic` is required for PyQt4/PyQt5. diff --git a/examples/load_ui/__init__.py b/examples/load_ui/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/load_ui/baseinstance1.py b/examples/load_ui/baseinstance1.py new file mode 100644 index 00000000..29e4edb9 --- /dev/null +++ b/examples/load_ui/baseinstance1.py @@ -0,0 +1,56 @@ +import sys +import os + +# Set preferred binding +os.environ['QT_PREFERRED_BINDING'] = os.pathsep.join(['PySide', 'PyQt4']) + +from Qt import QtWidgets, load_ui + + +def setup_ui(uifile, base_instance=None): + """Load a Qt Designer .ui file and returns an instance of the user interface + + Args: + uifile (str): Absolute path to .ui file + base_instance (QWidget): The widget into which UI widgets are loaded + + Returns: + QWidget: the base instance + + """ + ui = load_ui(uifile) # Qt.py mapped function + if not base_instance: + return ui + else: + for member in dir(ui): + if not member.startswith('__') and \ + member is not 'staticMetaObject': + setattr(base_instance, member, getattr(ui, member)) + return ui + + +class MainWindow(QtWidgets.QWidget): + """Load .ui file example, using setattr/getattr approach""" + def __init__(self, parent=None): + QtWidgets.QWidget.__init__(self, parent) + self.base_instance = setup_ui('qwidget.ui', self) + + +def test(): + """Example: load_ui with setup_ui wrapper""" + working_directory = os.path.dirname(__file__) + os.chdir(working_directory) + + app = QtWidgets.QApplication(sys.argv) + window = MainWindow() + + # Tests + assert isinstance(window, QtWidgets.QWidget) + assert isinstance(window.parent(), type(None)) + assert isinstance(window.base_instance, QtWidgets.QWidget) + assert isinstance(window.lineEdit, QtWidgets.QWidget) + assert window.lineEdit.text() == '' + window.lineEdit.setText('Hello') + assert window.lineEdit.text() == 'Hello' + + app.exit() diff --git a/examples/load_ui/baseinstance2.py b/examples/load_ui/baseinstance2.py new file mode 100644 index 00000000..b4e556c9 --- /dev/null +++ b/examples/load_ui/baseinstance2.py @@ -0,0 +1,135 @@ +import sys +import os + +# Set preferred binding, or Qt.py tests will fail which doesn't have pysideuic +os.environ['QT_PREFERRED_BINDING'] = 'PyQt4' + +from Qt import QtWidgets +from Qt import __binding__ + + +def load_ui_type(uifile): + """Pyside equivalent for the loadUiType function in PyQt. + + From the PyQt4 documentation: + Load a Qt Designer .ui file and return a tuple of the generated form + class and the Qt base class. These can then be used to create any + number of instances of the user interface without having to parse the + .ui file more than once. + + Note: + Pyside lacks the "loadUiType" command, so we have to convert the ui + file to py code in-memory first and then execute it in a special frame + to retrieve the form_class. + + Args: + uifile (str): Absolute path to .ui file + + + Returns: + tuple: the generated form class, the Qt base class + """ + import pysideuic + import xml.etree.ElementTree as ElementTree + from cStringIO import StringIO + + parsed = ElementTree.parse(uifile) + widget_class = parsed.find('widget').get('class') + form_class = parsed.find('class').text + + with open(uifile, 'r') as f: + o = StringIO() + frame = {} + + pysideuic.compileUi(f, o, indent=0) + pyc = compile(o.getvalue(), '', 'exec') + exec(pyc) in frame + + # Fetch the base_class and form class based on their type in + # the xml from designer + form_class = frame['Ui_%s' % form_class] + base_class = eval('QtWidgets.%s' % widget_class) + return form_class, base_class + + +def pyside_load_ui(uifile, base_instance=None): + """Provide PyQt4.uic.loadUi functionality to PySide + + Args: + uifile (str): Absolute path to .ui file + base_instance (QWidget): The widget into which UI widgets are loaded + + + Note: + pysideuic is required for this to work with PySide. + + This seems to work correctly in Maya as well as outside of it as + opposed to other implementations which involve overriding QUiLoader. + + Returns: + QWidget: the base instance + + """ + form_class, base_class = load_ui_type(uifile) + if not base_instance: + typeName = form_class.__name__ + finalType = type(typeName, + (form_class, base_class), + {}) + base_instance = finalType() + else: + if not isinstance(base_instance, base_class): + raise RuntimeError( + 'The base_instance passed to loadUi does not inherit from' + ' needed base type (%s)' % type(base_class)) + typeName = type(base_instance).__name__ + base_instance.__class__ = type(typeName, + (form_class, type(base_instance)), + {}) + base_instance.setupUi(base_instance) + return base_instance + + +def load_ui_wrapper(uifile, base_instance=None): + """Load a Qt Designer .ui file and returns an instance of the user interface + + Args: + uifile (str): Absolute path to .ui file + base_instance (QWidget): The widget into which UI widgets are loaded + + Returns: + function: pyside_load_ui or uic.loadUi + + """ + if 'PySide' in __binding__: + return pyside_load_ui(uifile, base_instance) + elif 'PyQt' in __binding__: + from Qt import uic + return uic.loadUi(uifile, base_instance) + + +class MainWindow(QtWidgets.QWidget): + """Load .ui file example, utilizing pysideuic and/or PyQt4.uic.loadUi""" + def __init__(self, parent=None): + QtWidgets.QWidget.__init__(self, parent) + self.base_instance = load_ui_wrapper('qwidget.ui', self) + + +def test(): + """Example: load_ui with custom uic.loadUi-like wrapper""" + working_directory = os.path.dirname(__file__) + os.chdir(working_directory) + + app = QtWidgets.QApplication(sys.argv) + window = MainWindow() + + # Tests + assert isinstance(window, QtWidgets.QWidget) + assert isinstance(window.parent(), type(None)) + assert isinstance(window.base_instance, QtWidgets.QWidget) + assert isinstance(window.lineEdit, QtWidgets.QWidget) + assert window.lineEdit.text() == '' + window.lineEdit.setText('Hello') + assert window.lineEdit.text() == 'Hello' + + app.exit() diff --git a/examples/load_ui/qwidget.ui b/examples/load_ui/qwidget.ui new file mode 100644 index 00000000..71a5d099 --- /dev/null +++ b/examples/load_ui/qwidget.ui @@ -0,0 +1,24 @@ + + + Form + + + + 0 + 0 + 235 + 149 + + + + Form + + + + + + + + + + diff --git a/tests.py b/tests.py index 5aa45a1d..7903e2f4 100644 --- a/tests.py +++ b/tests.py @@ -6,6 +6,7 @@ """ import os +import io import sys import imp import shutil @@ -24,6 +25,36 @@ def setup(): self.tempdir = tempfile.mkdtemp() + self.ui_qwidget = os.path.join(self.tempdir, "qwidget.ui") + + with io.open(self.ui_qwidget, "w", encoding="utf-8") as f: + f.write(u"""\ + + + Form + + + + 0 + 0 + 235 + 149 + + + + Form + + + + + + + + + + +""" +) def teardown(): @@ -143,6 +174,50 @@ def test_sip_api_qtpy(): % sip.getapi("QString")) +def test_pyside_load_ui_returntype(): + """load_ui returns an instance of QObject with PySide""" + + with pyside(): + import sys + from Qt import QtWidgets, QtCore, load_ui + app = QtWidgets.QApplication(sys.argv) + obj = load_ui(self.ui_qwidget) + assert isinstance(obj, QtCore.QObject) + + +def test_pyqt4_load_ui_returntype(): + """load_ui returns an instance of QObject with PyQt4""" + + with pyqt4(): + import sys + from Qt import QtWidgets, QtCore, load_ui + app = QtWidgets.QApplication(sys.argv) + obj = load_ui(self.ui_qwidget) + assert isinstance(obj, QtCore.QObject) + + +def test_pyside2_load_ui_returntype(): + """load_ui returns an instance of QObject with PySide2""" + + with pyside2(): + import sys + from Qt import QtWidgets, QtCore, load_ui + app = QtWidgets.QApplication(sys.argv) + obj = load_ui(self.ui_qwidget) + assert isinstance(obj, QtCore.QObject) + + +def test_pyqt5_load_ui_returntype(): + """load_ui returns an instance of QObject with PyQt5""" + + with pyqt5(): + import sys + from Qt import QtWidgets, QtCore, load_ui + app = QtWidgets.QApplication(sys.argv) + obj = load_ui(self.ui_qwidget) + assert isinstance(obj, QtCore.QObject) + + def test_vendoring(): """Qt.py may be bundled along with another library/project