From c35daa5a275df08a2c3e641e76b42a0c205dcda2 Mon Sep 17 00:00:00 2001 From: Robie Basak Date: Sat, 5 Dec 2015 02:41:24 +0000 Subject: [PATCH 1/2] Add PythonItemModel This is a work in progress and is not ready to merge. See docs/models for documentation. --- docs/models | 120 +++++++++++++ docs/pymodel.py | 293 +++++++++++++++++++++++++++++++ pyotherside.pro | 1 + python.pri | 2 +- src/pyitemmodel_proxy.cpp | 232 ++++++++++++++++++++++++ src/pyitemmodel_proxy.h | 30 ++++ src/pyotherside_plugin.cpp | 2 + src/pyotherside_plugin.h | 1 + src/qpython_itemmodel.cpp | 350 +++++++++++++++++++++++++++++++++++++ src/qpython_itemmodel.h | 71 ++++++++ src/qpython_priv.cpp | 13 ++ src/src.pro | 4 + tests/tests.pro | 4 + 13 files changed, 1122 insertions(+), 1 deletion(-) create mode 100644 docs/models create mode 100644 docs/pymodel.py create mode 100644 src/pyitemmodel_proxy.cpp create mode 100644 src/pyitemmodel_proxy.h create mode 100644 src/qpython_itemmodel.cpp create mode 100644 src/qpython_itemmodel.h diff --git a/docs/models b/docs/models new file mode 100644 index 0000000..c7b24e7 --- /dev/null +++ b/docs/models @@ -0,0 +1,120 @@ +The PythonItemModel QML object provides the ability to implement a QML data +model in Python. This is useful because a Python backend can then +programatically manipulate the Python model directly from Python in a Pythonic +fashion, and the QML frontend can be implemented with a standard view in a +standard QML fashion. + +Right now the implementation only supports a limited set of read-only +operations from the QML end (so a view in QML can only read from the model) but +the architecture could quite easily be extended to support full read-write +models in future. + +Example: + + import io.thp.pyotherside 1.5 + + Rectangle { + ListView { + model: model + delegate: Row { + Text { text: model.display } + } + anchors.fill: parent + } + PythonItemModel { + id: model + } + Python { + id: py + Component.onCompleted: { + addImportPath(Qt.resolvedUrl('.')); + importModule('main', function() { + py.call('main.get_model', [], function(result) { + // this should change to: + // model.change_model(model_constructor); + // in future as a property is inappropriate + model.model = result; + }); + }); + } + } + } + +and then in main.py: + + import pymodel + + def get_model(bridge): + model = pymodel.ListModel(bridge) + return model + +Now if the Python code had kept the reference to model, it could just +manipulate the model like it would a list (with append, pop, insert, index +access, etc) and the QML view will change dynamically to match. + +Some details: + +* This is still a work in progress and the API between the QML PythonItemModel + object and Python will change, so please do not merge this yet. + +* I expect to rebase this branch before submission. + +* There end up being three "faces" in Python. Suggestions for better names + appreciated: + + 1. The Python data object that reflects the data and manipulated from Python + Pythonically. The availability of this object is the goal, and I'm + currently calling this the "Python Side". This is how a Python backend + speaks in Python about the model. + + 2. The Python data object that is passed through to the QML PythonItemModel + object that it can query for data as required. This is an API I've + invented that quite closely matches the AbstractItemModel Qt API and I'm + currently calling this the "Other Side". This is how QML speaks to Python + about the model. + + 3. The Python data object that is presented by the QML PythonItemModel and is + used to call the PythonItemModel back, for example to inform it when + changes. I'm currently calling this the "bridge". Again this is an API + I've invented. This is how Python speaks to QML about the model. + + 4. Symmetry suggests that there should be a fourth face. I suppose this is + the PythonItemModel object in QML itself. This is how a QML frontend + speaks in QML about the model. + +* I wonder if there will be a significant performance impact in putting the + model into Python. I guess I'll need to try it and see. + +* pymodel currently provides ListModel and SortedListModel. Other things like + hierarchical models and allowing users to write their own entirely in Python + are possible with the current C++ implementation. + +* In Qt, AbstractItemModel's API isn't thread-safe in that the model must not + be modified except from the UI thread, so that while a view is retrieving + data the data remains consistent. This requirement is passed on to the Python + model implementation. I have done this using the in_ui_thread decorator in my + example (which we should ship). + +* Currently in the Python API provided by PythonItemModel there are two ways of + representing a QModelIndex - using integer references and using lists of + (row, column) tuples. The latter seems more consistent (since + signal_dataChanged must use it) so the other method should be removed and the + C++ side adapted. + +* signal_dataChanged should be called emit_dataChanged. + +* The C++ end is a complete mess and needs significant cleaning up. + +* Naming conventions are all over the place since python_uses_underscores and + C++ uses camelCase. Consistency could be improved even if using both by + deciding when it is appropriate to use each. + +* pymodel.py should be called something else and perhaps just be made available + as part of the pyotherside module import. This needs build system thought + since pyotherside currently doesn't ship any Python. + +* I want to keep the use of pymodel.py optional, and maintain the API between + PythonItemModel and Python formally. Then pyotherside users will have maximum + flexbility in maintaining their own custom models written in Python. + +* Feedback appreciated! diff --git a/docs/pymodel.py b/docs/pymodel.py new file mode 100644 index 0000000..9897fe2 --- /dev/null +++ b/docs/pymodel.py @@ -0,0 +1,293 @@ +import concurrent.futures +import functools +import itertools +import threading +import time + +import pyotherside + + +def in_ui_thread(f): + """Decorator used by Model to force a method into the UI thread. + + In Qt, model implementations must only change their contents from the UI + thread so that views never see inconsistencies. + + pyotherside passes this responsibility to the Python implementation side of + any PythonItemModel object by providing the call_back_for_modification + bridge method that takes a callable and queues it to run from the UI + thread. + + This decorator works with class methods only. It assumes that the class + instance provides an attribute called _bridge that provides the + call_back_for_modification method as supplied by pyotherside, uses it to + switch to the UI thread to run the real function, blocks on the UI thread + until it is complete, and then returns the real function's result. If the + real function raises an exception then this is caught and re-raised in the + caller's thread. + """ + + def wrapper(self, *args, **kwargs): + # Use an concurrent.futures.Future to store the + # return-value-or-exception. I'm not sure if this use is strictly this + # is permitted according to the docs ("for testing only") but it + # appears to be standalone and usable for this purpose. Otherwise I'd + # just end up reimplementing the part of it that is needed here + # identically. + result_future = concurrent.futures.Future() + callback_done = threading.Event() + def callback(): + try: + inner_result = f(self, *args, **kwargs) + except Exception as exception: + result_future.set_exception(exception) + else: + result_future.set_result(inner_result) + callback_done.set() + self._bridge.call_back_for_modification(callback) + callback_done.wait() + return result_future.result() # this raises the exception if set + return wrapper + + +class FlatModelIndexIndex: + """Integer references to (row, column) tuples (an index of indexes). + + pyotherside's PythonItemModel requires the Python model implementation to + provide integer references to items to meet the requirement's of Qt's + QModelIndex in a QAbstractItemModel implementation. The alternative is to + use pointers which are awkward to reference count because Qt's API does not + allow provision of a destructor when it is done using them. + + The integer references are invalidated as soon as the model changes. We + re-use them to avoid overflow, both after invalidation and if an integer + reference for the same (row, column) is requested again. + """ + + def __init__(self): + self.invalidate() + + def invalidate(self): + self._map_forwards = {} + self._map_backwards = {} + self._key_counter = iter(itertools.count(0)) + + def _new_index_key(self): + return next(self._key_counter) + + def add(self, row, column): + try: + index_key = self._map_backwards[(row, column)] + except KeyError: + index_key = self._new_index_key() + self._map_forwards[index_key] = row, column + self._map_backwards[(row, column)] = index_key + else: + assert self._map_forwards[index_key] == (row, column) + return index_key + + def __getitem__(self, index_key): + return self._map_forwards[index_key] + + +class ListModelOtherSide: + """Implementation of pyotherside PythonItemModel Python interface""" + def __init__(self, container, index, role_names=None): + self._index = index + self._container = container + self._role_names = role_names + + def columnCount(self, parent): + return 1 if parent is None else 0 + + def rowCount(self, parent): + return len(self._container) if parent is None else 0 + + def parent(self, index_id): + return None + + def index(self, row, column, parent): + if parent is not None: + return None + else: + return row, column, self._index.add(row, column) + + def data(self, index_id, role): + row, column = self._index[index_id] + if self._role_names is None: + return self._container[row].get('display') + else: + return self._container[row].get(self._role_names[role]) + + def roleNames(self): + return self._role_names + + +class ListModelPythonSide: + """Implementation of Pythonic list-like container for ListModelOtherSide""" + def __init__(self, bridge, index): + self._bridge = bridge + self._index = index + self._data = [] + + def __len__(self): + return len(self._data) + + def __getitem__(self, i): + return {'display': self._data[i]} + + @in_ui_thread + def __setitem__(self, i, v): + self._data[i] = v + self._bridge.signal_dataChanged([(i, 0)], [(i, 0)]) + + @in_ui_thread + def append(self, v): + row = len(self._data) + self._bridge.beginInsertRows(None, row, row) + self._index.invalidate() + self._data.append(v) + self._bridge.endInsertRows() + + @in_ui_thread + def insert(self, i, v): + self._bridge.beginInsertRows(None, i, i) + self._index.invalidate() + self._data.insert(i, v) + self._bridge.endInsertRows() + + @in_ui_thread + def pop(self, i=None): + if i is None: + i = len(self._data) - 1 + self._bridge.beginRemoveRows(None, i, i) + self._index.invalidate() + self._data.pop(i) + self._bridge.endRemoveRows() + + +class SortedListModelPythonSide: + """Implementation of Pythonic sorted container for ListModelOtherSide""" + def __init__(self, bridge, index, key_func=lambda x:x, data_func=lambda x:{'display': x}): + self._bridge = bridge + self._index = index + self._data = [] + self._key_func = key_func + self._data_func = data_func + + def __len__(self): + return len(self._data) + + def __getitem__(self, i): + return self._data_func(self._data[i]) + + def _find_insert_pos(self, v, begin=0, end=None): + if end is None: + end = len(self._data) + + if begin == end: + return begin + else: + middle = (begin + end) // 2 + if self._key_func(v) < self._key_func(self._data[middle]): + return self._find_insert_pos(v, begin, middle) + else: + return self._find_insert_pos(v, middle + 1, end) + + @in_ui_thread + def __setitem__(self, i, v): + self._data[i] = v + self._bridge.signal_dataChanged([(i, 0)], [(i, 0)]) + + @in_ui_thread + def add(self, v): + """Add v to list and return chosen index""" + i = self._find_insert_pos(v) + self._bridge.beginInsertRows(None, i, i) + self._index.invalidate() + self._data.insert(i, v) + self._bridge.endInsertRows() + return i + + @in_ui_thread + def pop(self, i): + """Remove i-th element from list""" + self._bridge.beginRemoveRows(None, i, i) + self._index.invalidate() + self._data.pop(i) + self._bridge.endRemoveRows() + + @in_ui_thread + def remove(self, v): + """Remove element from list by value""" + self.pop(self._data.index(v)) + + @in_ui_thread + def remove_object(self, o): + """Remove element from list by identity""" + for i, _o in enumerate(self._data): + if o is _o: + self.pop(i) + return + raise ValueError("%r is not in list" % o) + + +def construct_list_model(bridge, python_side_constructor, other_side_constructor=ListModelOtherSide): + index = FlatModelIndexIndex() + list_model_python_side = python_side_constructor(bridge, index) + list_model_other_side = other_side_constructor(list_model_python_side, index) + return list_model_python_side, list_model_other_side + + +def ListModel(bridge): + return construct_list_model(bridge, ListModelPythonSide) + + +def SortedListModel(bridge): + return construct_list_model(bridge, SortedListModelPythonSide) + + +def get_model(): + def constructor(bridge): + python_side, other_side = SortedListModel(bridge) + start_twiddling(python_side, twiddle_sorted) + return other_side + + return constructor + + +def twiddle_unsorted(model): + time.sleep(1) + model.append('foo') + time.sleep(1) + model[0] = 'bar' + time.sleep(1) + model.append('baz') + time.sleep(1) + model.insert(0, 'first') + time.sleep(1) + model.pop(1) + + +def twiddle_sorted(model): + time.sleep(1) + model.add('foo') + time.sleep(1) + model[0] = 'bar' + time.sleep(1) + qux = 'qux' + model.add(qux) + time.sleep(1) + model.add('baz') + time.sleep(1) + model.add('first') + time.sleep(1) + model.remove_object(qux) + time.sleep(1) + model.remove('first') + time.sleep(1) + model.pop(1) + + +def start_twiddling(model, f): + threading.Thread(target=functools.partial(f, model)).start() diff --git a/pyotherside.pro b/pyotherside.pro index 7b0ef2b..99d6cc3 100644 --- a/pyotherside.pro +++ b/pyotherside.pro @@ -1,3 +1,4 @@ +CONFIG += debug TEMPLATE = subdirs SUBDIRS += src tests qtquicktests diff --git a/python.pri b/python.pri index bbc9143..655d662 100644 --- a/python.pri +++ b/python.pri @@ -1,5 +1,5 @@ isEmpty(PYTHON_CONFIG) { - PYTHON_CONFIG = python3-config + PYTHON_CONFIG = x86_64-linux-gnu-python3.4-dbg-config } message(PYTHON_CONFIG = $$PYTHON_CONFIG) diff --git a/src/pyitemmodel_proxy.cpp b/src/pyitemmodel_proxy.cpp new file mode 100644 index 0000000..49263e6 --- /dev/null +++ b/src/pyitemmodel_proxy.cpp @@ -0,0 +1,232 @@ + +/** + * PyOtherSide: Asynchronous Python 3 Bindings for Qt 5 + * Copyright (c) 2011, 2013, 2014, Thomas Perl + * Copyright (c) 2015, Robie Basak + * + * Permission to use, copy, modify, and/or distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH + * REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND + * FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, + * INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM + * LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR + * OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR + * PERFORMANCE OF THIS SOFTWARE. + **/ + +#include "pyitemmodel_proxy.h" +#include "qpython_itemmodel.h" +#include "qobject_ref.h" +#include +#include +#include + +typedef struct { + PyObject_HEAD + QObjectRef *m_qpythonitemmodel_ref; +} pyotherside_QPythonItemModelProxy; + +static bool is_gui_thread(void) { + QCoreApplication *p = QCoreApplication::instance(); + return p && (p->thread() == QThread::currentThread()); +} + +PyObject * +call_back_for_modification(PyObject *self, PyObject *cb) +{ + if (is_gui_thread()) { + // call back immediately since we're already in the GUI thread + PyObject *result = PyObject_CallObject(cb, NULL); + if (result) { + // though we could return this, doing so would cause different + // behaviour between the two is_gui_thread modes. + Py_DECREF(result); + Py_RETURN_NONE; + } else { + return NULL; // pass back the exception + } + } else { + // send a signal to handle the callback from the GUI thread. + pyotherside_QPythonItemModelProxy *proxy = (pyotherside_QPythonItemModelProxy *)self; + if (!proxy->m_qpythonitemmodel_ref) { + qFatal("proxy no longer exists"); // XXX handle with exception + return NULL; // never gets here + } + QPythonItemModel *model = (QPythonItemModel *)proxy->m_qpythonitemmodel_ref->value(); + model->sendModificationCallback(cb); + + // can't return anything useful right now + Py_RETURN_NONE; + } +} + +PyObject * +signal_dataChanged(PyObject *self, PyObject *args) +{ + pyotherside_QPythonItemModelProxy *proxy = (pyotherside_QPythonItemModelProxy *)self; + if (!proxy->m_qpythonitemmodel_ref) { + qFatal("proxy no longer exists"); // XXX handle with exception + return NULL; // never gets here + } + QPythonItemModel *model = (QPythonItemModel *)proxy->m_qpythonitemmodel_ref->value(); + model->sendDataChanged(args); + Py_RETURN_NONE; +} + +PyObject * +beginInsertRows(PyObject *self, PyObject *args) +{ + pyotherside_QPythonItemModelProxy *proxy = (pyotherside_QPythonItemModelProxy *)self; + if (!proxy->m_qpythonitemmodel_ref) { + qFatal("proxy no longer exists"); // XXX handle with exception + return NULL; // never gets here + } + QPythonItemModel *model = (QPythonItemModel *)proxy->m_qpythonitemmodel_ref->value(); + PyObject *parent_path; + int first, last; + if (!PyArg_ParseTuple(args, "Oii", &parent_path, &first, &last)) + return NULL; // XXX failure here doesn't lead to traceback for some reason + QModelIndex parent(model->pysequence_to_qmodelindex(parent_path)); + model->do_beginInsertRows(parent, first, last); + Py_RETURN_NONE; +} + +PyObject * +beginRemoveRows(PyObject *self, PyObject *args) +{ + pyotherside_QPythonItemModelProxy *proxy = (pyotherside_QPythonItemModelProxy *)self; + if (!proxy->m_qpythonitemmodel_ref) { + qFatal("proxy no longer exists"); // XXX handle with exception + return NULL; // never gets here + } + QPythonItemModel *model = (QPythonItemModel *)proxy->m_qpythonitemmodel_ref->value(); + PyObject *parent_path; + int first, last; + if (!PyArg_ParseTuple(args, "Oii", &parent_path, &first, &last)) + return NULL; // XXX failure here doesn't lead to traceback for some reason + QModelIndex parent(model->pysequence_to_qmodelindex(parent_path)); + model->do_beginRemoveRows(parent, first, last); + Py_RETURN_NONE; +} + +PyObject * +endInsertRows(PyObject *self, PyObject *null) +{ + pyotherside_QPythonItemModelProxy *proxy = (pyotherside_QPythonItemModelProxy *)self; + if (!proxy->m_qpythonitemmodel_ref) { + qFatal("proxy no longer exists"); // XXX handle with exception + return NULL; // never gets here + } + QPythonItemModel *model = (QPythonItemModel *)proxy->m_qpythonitemmodel_ref->value(); + model->do_endInsertRows(); + Py_RETURN_NONE; +} + +PyObject * +endRemoveRows(PyObject *self, PyObject *null) +{ + pyotherside_QPythonItemModelProxy *proxy = (pyotherside_QPythonItemModelProxy *)self; + if (!proxy->m_qpythonitemmodel_ref) { + qFatal("proxy no longer exists"); // XXX handle with exception + return NULL; // never gets here + } + QPythonItemModel *model = (QPythonItemModel *)proxy->m_qpythonitemmodel_ref->value(); + model->do_endRemoveRows(); + Py_RETURN_NONE; +} + +PyMethodDef pyotherside_QPythonItemModelProxyType_methods[] = { + {"call_back_for_modification", (PyCFunction)call_back_for_modification, METH_O, + "Request callback from UI thread to change data in the model safely" + }, + {"signal_dataChanged", (PyCFunction)signal_dataChanged, METH_VARARGS, + "Send dataChanged signal" + }, + {"beginInsertRows", (PyCFunction)beginInsertRows, METH_VARARGS, + "Call beginInsertRows on model" + }, + {"endInsertRows", (PyCFunction)endInsertRows, METH_NOARGS, + "Call endInsertRows on model" + }, + {"beginRemoveRows", (PyCFunction)beginRemoveRows, METH_VARARGS, + "Call beginRemoveRows on model" + }, + {"endRemoveRows", (PyCFunction)endRemoveRows, METH_NOARGS, + "Call endRemoveRows on model" + }, + {NULL} /* Sentinel */ +}; + +PyTypeObject pyotherside_QPythonItemModelProxyType = { + PyVarObject_HEAD_INIT(NULL, 0) + "pyotherside.ItemModelProxy", /* tp_name */ + sizeof(pyotherside_QPythonItemModelProxy), /* tp_basicsize */ + 0, /* tp_itemsize */ + 0, /* tp_dealloc */ + 0, /* tp_print */ + 0, /* tp_getattr */ + 0, /* tp_setattr */ + 0, /* tp_reserved */ + 0, /* tp_repr */ + 0, /* tp_as_number */ + 0, /* tp_as_sequence */ + 0, /* tp_as_mapping */ + 0, /* tp_hash */ + 0, /* tp_call */ + 0, /* tp_str */ + 0, /* tp_getattro */ + 0, /* tp_setattro */ + 0, /* tp_as_buffer */ + Py_TPFLAGS_DEFAULT, /* tp_flags */ + "Interface to QPythonItemModel", /* tp_doc */ + 0, /* tp_traverse */ + 0, /* tp_clear */ + 0, /* tp_richcompare */ + 0, /* tp_weaklistoffset */ + 0, /* tp_iter */ + 0, /* tp_iternext */ + pyotherside_QPythonItemModelProxyType_methods, /* tp_methods */ +}; + +int done_init = 0; + +static void +dealloc(pyotherside_QPythonItemModelProxy *self) +{ + delete self->m_qpythonitemmodel_ref; + Py_TYPE(self)->tp_free((PyObject *)self); +} + +static void create_type(void) { + if (!done_init) { + // QPythonItemModel proxy + pyotherside_QPythonItemModelProxyType.tp_dealloc = (destructor)dealloc; + if (PyType_Ready(&pyotherside_QPythonItemModelProxyType) < 0) { + qFatal("Could not initialize QPythonItemModelProxyType"); + // Not reached + return; + } + Py_INCREF(&pyotherside_QPythonItemModelProxyType); + done_init = 1; + } +} + +void +init_QPythonItemModelProxyType(PyObject *module) +{ + create_type(); + PyModule_AddObject(module, "ItemModelProxy", (PyObject *)(&pyotherside_QPythonItemModelProxyType)); +} + +PyObject * +create_QPythonItemModelProxy(QObject *obj) +{ + create_type(); + pyotherside_QPythonItemModelProxy *result = (pyotherside_QPythonItemModelProxy *)pyotherside_QPythonItemModelProxyType.tp_alloc(&pyotherside_QPythonItemModelProxyType, 0); + if (result) + result->m_qpythonitemmodel_ref = new QObjectRef(obj); + return (PyObject *)result; +} diff --git a/src/pyitemmodel_proxy.h b/src/pyitemmodel_proxy.h new file mode 100644 index 0000000..1af8b44 --- /dev/null +++ b/src/pyitemmodel_proxy.h @@ -0,0 +1,30 @@ + +/** + * PyOtherSide: Asynchronous Python 3 Bindings for Qt 5 + * Copyright (c) 2014, Thomas Perl + * Copyright (c) 2015, Robie Basak + * + * Permission to use, copy, modify, and/or distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH + * REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND + * FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, + * INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM + * LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR + * OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR + * PERFORMANCE OF THIS SOFTWARE. + **/ + +#ifndef PYOTHERSIDE_PYITEMMODEL_PROXY_H +#define PYOTHERSIDE_PYITEMMODEL_PROXY_H + +#include "Python.h" + +#include + +void init_QPythonItemModelProxyType(PyObject *module); +PyObject *create_QPythonItemModelProxy(QObject *obj); + +#endif /* PYOTHERSIDE_PYITEMMODEL_PROXY_H */ diff --git a/src/pyotherside_plugin.cpp b/src/pyotherside_plugin.cpp index 7c6a955..b113655 100644 --- a/src/pyotherside_plugin.cpp +++ b/src/pyotherside_plugin.cpp @@ -21,6 +21,7 @@ #include "pyglarea.h" #include "pyfbo.h" #include "qpython_imageprovider.h" +#include "qpython_itemmodel.h" #include "global_libpython_loader.h" #include "pythonlib_loader.h" @@ -71,4 +72,5 @@ PyOtherSideExtensionPlugin::registerTypes(const char *uri) qmlRegisterType(uri, 1, 5, PYOTHERSIDE_QPYTHON_NAME); qmlRegisterType(uri, 1, 5, PYOTHERSIDE_QPYGLAREA_NAME); qmlRegisterType(uri, 1, 5, PYOTHERSIDE_PYFBO_NAME); + qmlRegisterType(uri, 1, 5, PYOTHERSIDE_QPYTHONITEMMODEL_NAME); } diff --git a/src/pyotherside_plugin.h b/src/pyotherside_plugin.h index 59b7cde..5401157 100644 --- a/src/pyotherside_plugin.h +++ b/src/pyotherside_plugin.h @@ -27,6 +27,7 @@ #define PYOTHERSIDE_QPYTHON_NAME "Python" #define PYOTHERSIDE_QPYGLAREA_NAME "PyGLArea" #define PYOTHERSIDE_PYFBO_NAME "PyFBO" +#define PYOTHERSIDE_QPYTHONITEMMODEL_NAME "PythonItemModel" class Q_DECL_EXPORT PyOtherSideExtensionPlugin : public QQmlExtensionPlugin { Q_OBJECT diff --git a/src/qpython_itemmodel.cpp b/src/qpython_itemmodel.cpp new file mode 100644 index 0000000..a358ee8 --- /dev/null +++ b/src/qpython_itemmodel.cpp @@ -0,0 +1,350 @@ + +/** + * PyOtherSide: Asynchronous Python 3 Bindings for Qt 5 + * Copyright (c) 2011, 2013, 2014, Thomas Perl + * Copyright (c) 2015, Robie Basak + * + * Permission to use, copy, modify, and/or distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH + * REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND + * FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, + * INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM + * LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR + * OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR + * PERFORMANCE OF THIS SOFTWARE. + **/ + +#include "qpython_itemmodel.h" + +#include "qml_python_bridge.h" +#include "ensure_gil_state.h" +#include "pyitemmodel_proxy.h" + +QPythonItemModel::QPythonItemModel(QObject *parent) +{ + m_proxy = 0; + if (!connect(this, SIGNAL(needModificationCallback(PyObject *)), this, SLOT(handleModificationCallback(PyObject *)))) + qFatal("Could not connect needModificationCallback signal"); +} + +QPythonItemModel::~QPythonItemModel() +{ + Py_CLEAR(m_proxy); +} + +static PyObject * +qmodelindex_to_pyargs(const QModelIndex &index) +{ + if (index.isValid()) + return Py_BuildValue("(k)", index.internalId()); + else + return PyTuple_Pack(1, Py_None); +} + +static int +call_model_for_int(const PyObjectRef &model, const QModelIndex &parent, const char *method_name) +{ + ENSURE_GIL_STATE; + if (!model) + return 0; + PyObject *method = PyObject_GetAttrString(model.borrow(), method_name); + if (!method) { + PyErr_Print(); + PyErr_Clear(); + return 0; + } + PyObject *args = qmodelindex_to_pyargs(parent); + if (!args) { + Py_DECREF(method); + PyErr_Print(); + PyErr_Clear(); + return 0; + } + PyObject *result = PyObject_CallObject(method, args); + Py_DECREF(method); + Py_DECREF(args); + // XXX: should check result is long first, otherwise segfault + long result_long = PyLong_AsLong(result); + Py_DECREF(result); + return result_long; +} + +int +QPythonItemModel::columnCount(const QModelIndex &parent) const { + return call_model_for_int(m_model, parent, "columnCount"); +} + +int +QPythonItemModel::rowCount(const QModelIndex &parent) const { + return call_model_for_int(m_model, parent, "rowCount"); +} + +/* Note: this Py_DECREFs args for convenience */ +QModelIndex +QPythonItemModel::call_model_for_index(PyObject *args, const char *method_name) const +{ + ENSURE_GIL_STATE; + if (!m_model) + return QModelIndex(); + PyObject *method = PyObject_GetAttrString(m_model.borrow(), method_name); + if (!method) { + PyErr_Print(); + PyErr_Clear(); + return QModelIndex(); + } + PyObject *result = PyObject_CallObject(method, args); + Py_DECREF(method); + Py_DECREF(args); + + if (!result) { + PyErr_Print(); + PyErr_Clear(); + return QModelIndex(); + } else if (result == Py_None) { + Py_DECREF(result); + return QModelIndex(); + } else { + int row, column; + unsigned long index_id; + if (!PyArg_ParseTuple(result, "iik", &row, &column, &index_id)) { + Py_DECREF(result); + PyErr_Print(); + PyErr_Clear(); + return QModelIndex(); + } + Py_DECREF(result); + return createIndex(row, column, index_id); + } +} + +QModelIndex +QPythonItemModel::parent(const QModelIndex &index) const { + PyObject *args = qmodelindex_to_pyargs(index); + if (!args) { + PyErr_Print(); + PyErr_Clear(); + return QModelIndex(); + } + return call_model_for_index(args, "parent"); +} + +QModelIndex +QPythonItemModel::index(int row, int column, const QModelIndex &parent) const { + PyObject *args; + if (parent.isValid()) + args = Py_BuildValue("(iik)", row, column, parent.internalId()); + else + args = Py_BuildValue("(iiO)", row, column, Py_None); + + return call_model_for_index(args, "index"); +} + +QVariant +QPythonItemModel::data(const QModelIndex &index, int role) const +{ + ENSURE_GIL_STATE; + + PyObject *method; + + if (!m_model) + return QVariant(QString("1")); // XXX make empty QVariant() + + PyObject *args; + if (index.isValid()) + args = Py_BuildValue("(ki)", index.internalId(), role); + else + args = Py_BuildValue("(Oi)", Py_None, role); + if (!args) { + PyErr_Print(); + PyErr_Clear(); + return QVariant("4"); + } + + method = PyObject_GetAttrString(m_model.borrow(), "data"); + if (!method) { + Py_DECREF(args); + PyErr_Print(); + PyErr_Clear(); + return QVariant(QString("2")); + } + PyObjectRef result(PyObject_CallObject(method, args)); + Py_DECREF(args); + Py_DECREF(method); + if (!result) { + PyErr_PrintEx(0); + return QVariant(QString("3")); + } + return convertPyObjectToQVariant(result.borrow()); +} + +// XXX now that this uses a constructor, it should no longer be a property but +// a method instead +void +QPythonItemModel::setModel(QVariant model) +{ + ENSURE_GIL_STATE; + + if (!m_proxy) { + m_proxy = create_QPythonItemModelProxy(this); + if (!m_proxy) + qFatal("Failed to create QPythonItemModelProxy"); + } + + PyObjectRef model_constructor(model.value()); + if (model_constructor && PyCallable_Check(model_constructor.borrow())) { + PyObject *args = PyTuple_Pack(1, m_proxy); + if (!args) + return; /* spec doesn't say that this sets an exception */ + PyObject *result = PyObject_CallObject(model_constructor.borrow(), args); + Py_DECREF(args); + if (result == NULL) { + PyErr_Print(); + PyErr_Clear(); + } else { + beginResetModel(); + m_model = PyObjectRef(result); + endResetModel(); + } + } +} + +void +QPythonItemModel::handleModificationCallback(PyObject *cb) +{ + ENSURE_GIL_STATE; + PyObject *result = PyObject_CallObject(cb, NULL); + if (!result) { + PyErr_Print(); + PyErr_Clear(); + } + Py_DECREF(cb); + Py_XDECREF(result); +} + +QModelIndex +QPythonItemModel::pysequence_to_qmodelindex(PyObject *list) +{ + if (list == Py_None) + return QModelIndex(); + + PyObject *fast = PySequence_Fast(list, "Argument is not a sequence when converting to QModelIndex"); + if (!fast) { + PyErr_Print(); + PyErr_Clear(); + return QModelIndex(); + } + PyObject **items = PySequence_Fast_ITEMS(fast); + Py_ssize_t n = PySequence_Fast_GET_SIZE(fast); + + QModelIndex result; + int row, column; + for (Py_ssize_t i=0; i +QPythonItemModel::roleNames(void) const +{ + ENSURE_GIL_STATE; + + if (!m_model) + return QAbstractItemModel::roleNames(); + + PyObject *method = PyObject_GetAttrString(m_model.borrow(), "roleNames"); + if (!method) + return QAbstractItemModel::roleNames(); + + PyObject *py_result = PyObject_CallObject(method, NULL); + Py_DECREF(method); + if (!py_result) { + PyErr_Print(); + PyErr_Clear(); + return QAbstractItemModel::roleNames(); + } + + if (py_result == Py_None) { + Py_DECREF(py_result); + return QAbstractItemModel::roleNames(); + } + + if (!PyDict_Check(py_result)) { + Py_DECREF(py_result); + qFatal("not a dict"); // XXX handle properly + return QAbstractItemModel::roleNames(); + } + + PyObject *py_key, *py_value; + Py_ssize_t pos = 0; + + QHash q_result; + while (PyDict_Next(py_result, &pos, &py_key, &py_value)) { + long key_long = PyLong_AsLong(py_key); + if (PyErr_Occurred()) { + PyErr_Print(); + PyErr_Clear(); + qFatal("dict key not an int"); // XXX handle properly + continue; + } + if (key_long < INT_MIN || key_long > INT_MAX) { + qFatal("int overflow"); // XXX handle properly + continue; + } + int key_int = (int)key_long; + if (!PyUnicode_Check(py_value)) { + qFatal("dict value is not a str"); // XXX handle properly + continue; + } + /* Not sure what encoding to use here. What encoding does Qt use for + * matching against unicode role names defined in QML? It probably + * doesn't matter - role names are internal, don't need l10n and can + * be required to be in ASCII for now. */ + PyObject *py_bytes_value = PyUnicode_EncodeLocale(py_value, NULL); + if (!py_bytes_value) { + PyErr_Print(); + PyErr_Clear(); + qFatal("could not convert dict value to bytes"); // XXX handle properly + continue; + } + q_result.insert(key_int, QByteArray(PyBytes_AS_STRING(py_bytes_value), PyBytes_GET_SIZE(py_bytes_value))); + Py_DECREF(py_bytes_value); + } + + Py_DECREF(py_result); + return q_result; +} diff --git a/src/qpython_itemmodel.h b/src/qpython_itemmodel.h new file mode 100644 index 0000000..23ec2ec --- /dev/null +++ b/src/qpython_itemmodel.h @@ -0,0 +1,71 @@ + +/** + * PyOtherSide: Asynchronous Python 3 Bindings for Qt 5 + * Copyright (c) 2011, 2013, 2014, Thomas Perl + * Copyright (c) 2015, Robie Basak + * + * Permission to use, copy, modify, and/or distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH + * REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND + * FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, + * INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM + * LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR + * OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR + * PERFORMANCE OF THIS SOFTWARE. + **/ + +#ifndef PYOTHERSIDE_QPYTHON_ITEMMODEL_H +#define PYOTHERSIDE_QPYTHON_ITEMMODEL_H + +#include "Python.h" + +#include + +#include "pyobject_ref.h" + +class QPythonItemModel : public QAbstractItemModel { + Q_OBJECT + Q_PROPERTY(QVariant model READ model WRITE setModel) + +public: + QPythonItemModel(QObject *parent = 0); + virtual ~QPythonItemModel(); + + virtual int columnCount(const QModelIndex &parent = QModelIndex()) const; + virtual int rowCount(const QModelIndex & parent = QModelIndex()) const; + virtual QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const; + virtual QModelIndex index(int row, int column, const QModelIndex &parent = QModelIndex()) const; + virtual QModelIndex parent(const QModelIndex &index) const; + + QVariant model() const { return QVariant::fromValue(m_model); }; + void setModel(QVariant model); + void sendModificationCallback(PyObject *cb) { Py_INCREF(cb); emit needModificationCallback(cb); }; + void sendDataChanged(PyObject *args); + QModelIndex pysequence_to_qmodelindex(PyObject *list); + QHash roleNames(void) const; + + // Pretty hacky. I can't make pyitemmodel_proxy a friend because it isn't a + // class, and it can't be a class because Python needs it to have + // particular function prototypes that don't include "this". Need to find a + // better way. + void do_beginInsertRows(const QModelIndex & parent, int first, int last) { beginInsertRows(parent, first, last); }; + void do_endInsertRows(void) { endInsertRows(); }; + void do_beginRemoveRows(const QModelIndex & parent, int first, int last) { beginRemoveRows(parent, first, last); }; + void do_endRemoveRows(void) { endRemoveRows(); }; + +private slots: + void handleModificationCallback(PyObject *cb); + +signals: + void needModificationCallback(PyObject *cb); + +private: + PyObjectRef m_model; + PyObject *m_proxy; + QModelIndex call_model_for_index(PyObject *args, const char *method_name) const; +}; + +#endif /* PYOTHERSIDE_QPYTHON_ITEMMODEL_H */ diff --git a/src/qpython_priv.cpp b/src/qpython_priv.cpp index e569336..a5c7ced 100644 --- a/src/qpython_priv.cpp +++ b/src/qpython_priv.cpp @@ -22,6 +22,8 @@ #include "ensure_gil_state.h" +#include "pyitemmodel_proxy.h" + #include #include #include @@ -453,6 +455,15 @@ PyOtherSide_init() PyModule_AddIntConstant(pyotherside, "format_rgb888", QImage::Format_RGB888); PyModule_AddIntConstant(pyotherside, "format_rgb444", QImage::Format_RGB444); + // Role constants for data models (TBC) + PyModule_AddIntConstant(pyotherside, "QT_DISPLAY_ROLE", Qt::DisplayRole); + PyModule_AddIntConstant(pyotherside, "QT_DECORATION_ROLE", Qt::DecorationRole); + PyModule_AddIntConstant(pyotherside, "QT_EDIT_ROLE", Qt::EditRole); + PyModule_AddIntConstant(pyotherside, "QT_TOOL_TIP_ROLE", Qt::ToolTipRole); + PyModule_AddIntConstant(pyotherside, "QT_STATUS_TIP_ROLE", Qt::StatusTipRole); + PyModule_AddIntConstant(pyotherside, "QT_WHATS_THIS_ROLE", Qt::WhatsThisRole); + PyModule_AddIntConstant(pyotherside, "QT_USER_ROLE", Qt::UserRole); + // Custom constant - pixels are to be interpreted as encoded image file data PyModule_AddIntConstant(pyotherside, "format_data", PYOTHERSIDE_IMAGE_FORMAT_ENCODED); PyModule_AddIntConstant(pyotherside, "format_svg_data", PYOTHERSIDE_IMAGE_FORMAT_SVG); @@ -486,6 +497,8 @@ PyOtherSide_init() Py_INCREF(&pyotherside_QObjectMethodType); PyModule_AddObject(pyotherside, "QObjectMethod", (PyObject *)(&pyotherside_QObjectMethodType)); + init_QPythonItemModelProxyType(pyotherside); + return pyotherside; } diff --git a/src/src.pro b/src/src.pro index 3ce2cb2..6981a11 100644 --- a/src/src.pro +++ b/src/src.pro @@ -28,6 +28,10 @@ HEADERS += pyotherside_plugin.h SOURCES += qpython_imageprovider.cpp HEADERS += qpython_imageprovider.h +# QML AbstractItemModel +SOURCES += qpython_itemmodel.cpp pyitemmodel_proxy.cpp +HEADERS += qpython_itemmodel.h pyitemmodel_proxy.h + # PyGLArea SOURCES += pyglarea.cpp pyglrenderer.cpp HEADERS += pyglarea.h pyglrenderer.h diff --git a/tests/tests.pro b/tests/tests.pro index 8930c26..69be057 100644 --- a/tests/tests.pro +++ b/tests/tests.pro @@ -10,12 +10,16 @@ HEADERS += tests.h SOURCES += ../src/qpython.cpp SOURCES += ../src/qpython_worker.cpp SOURCES += ../src/qpython_priv.cpp +SOURCES += ../src/pyitemmodel_proxy.cpp +SOURCES += ../src/qpython_itemmodel.cpp SOURCES += ../src/pyobject_ref.cpp SOURCES += ../src/qobject_ref.cpp HEADERS += ../src/qpython.h HEADERS += ../src/qpython_worker.h HEADERS += ../src/qpython_priv.h +HEADERS += ../src/pyitemmodel_proxy.h +HEADERS += ../src/qpython_itemmodel.h HEADERS += ../src/converter.h HEADERS += ../src/qvariant_converter.h HEADERS += ../src/pyobject_converter.h From 44f1ab846cb0aafa942d14108bf250f5660950d8 Mon Sep 17 00:00:00 2001 From: Robie Basak Date: Mon, 2 Jan 2017 16:54:11 +0000 Subject: [PATCH 2/2] Add tests for model behaviour --- qtquicktests/PythonListView.qml | 37 ++++++++++++++++++++ qtquicktests/pymodel.py | 1 + qtquicktests/test_functions.py | 20 +++++++++++ qtquicktests/tst_model_add_one.qml | 18 ++++++++++ qtquicktests/tst_model_create.qml | 12 +++++++ qtquicktests/tst_model_exercise_sorted.qml | 37 ++++++++++++++++++++ qtquicktests/tst_model_exercise_unsorted.qml | 32 +++++++++++++++++ qtquicktests/tst_noop.qml | 10 ++++++ 8 files changed, 167 insertions(+) create mode 100644 qtquicktests/PythonListView.qml create mode 120000 qtquicktests/pymodel.py create mode 100644 qtquicktests/tst_model_add_one.qml create mode 100644 qtquicktests/tst_model_create.qml create mode 100644 qtquicktests/tst_model_exercise_sorted.qml create mode 100644 qtquicktests/tst_model_exercise_unsorted.qml create mode 100644 qtquicktests/tst_noop.qml diff --git a/qtquicktests/PythonListView.qml b/qtquicktests/PythonListView.qml new file mode 100644 index 0000000..b9079fe --- /dev/null +++ b/qtquicktests/PythonListView.qml @@ -0,0 +1,37 @@ +import QtQuick 2.3 + +import io.thp.pyotherside 1.5 + +Rectangle { + property bool sorted: false; + property var python_side_model; + property var model_wrapper; + property bool ready: false; + property alias view: view; + property alias py: py; + ListView { + id: view + model: model + delegate: Row { + Text { text: model.display } + } + anchors.fill: parent + } + PythonItemModel { + id: model + } + Python { + id: py + Component.onCompleted: { + addImportPath(Qt.resolvedUrl('.')); + importModule('test_functions', function() { + call('test_functions.ModelWrapper', [sorted], function(result) { + model.model = getattr(result, 'activate'); + python_side_model = getattr(result, 'python_side') + model_wrapper = result; + ready = true; + }); + }); + } + } +} diff --git a/qtquicktests/pymodel.py b/qtquicktests/pymodel.py new file mode 120000 index 0000000..76438ee --- /dev/null +++ b/qtquicktests/pymodel.py @@ -0,0 +1 @@ +../docs/pymodel.py \ No newline at end of file diff --git a/qtquicktests/test_functions.py b/qtquicktests/test_functions.py index 0d641d3..ee56310 100644 --- a/qtquicktests/test_functions.py +++ b/qtquicktests/test_functions.py @@ -1,4 +1,24 @@ +import pymodel + def function_that_takes_one_parameter(parameter): '''For tst_model_add_one:call_sync_with_parameters''' assert parameter == 1 return 1 + +class ModelWrapper: + def __init__(self, sorted=False): + self.sorted = sorted + + def activate(self, bridge): + index = pymodel.FlatModelIndexIndex() + if self.sorted: + self.python_side = pymodel.SortedListModelPythonSide(bridge, index) + else: + self.python_side = pymodel.ListModelPythonSide(bridge, index) + self.other_side = pymodel.ListModelOtherSide(self.python_side, index) + return self.other_side + + +def setitem(obj, idx, val): + '''See definition. Useful to avoid obtuse calls on the QML side.''' + obj[idx] = val diff --git a/qtquicktests/tst_model_add_one.qml b/qtquicktests/tst_model_add_one.qml new file mode 100644 index 0000000..85411c3 --- /dev/null +++ b/qtquicktests/tst_model_add_one.qml @@ -0,0 +1,18 @@ +import QtTest 1.0 + +PythonListView { + TestCase { + name: "model_add_one" + when: ready + + function get_model_item(row) { + return view.model.data(view.model.index(row, 0), 258); + } + + function test_model_add_one() { + py.call_sync(py.getattr(python_side_model, 'append'), [1]); + compare(view.count, 1); + compare(get_model_item(0), 1); + } + } +} diff --git a/qtquicktests/tst_model_create.qml b/qtquicktests/tst_model_create.qml new file mode 100644 index 0000000..121e736 --- /dev/null +++ b/qtquicktests/tst_model_create.qml @@ -0,0 +1,12 @@ +import QtTest 1.0 + +PythonListView { + TestCase { + name: "model_create" + when: ready + + function test_model_created() { + compare(view.count, 0); + } + } +} diff --git a/qtquicktests/tst_model_exercise_sorted.qml b/qtquicktests/tst_model_exercise_sorted.qml new file mode 100644 index 0000000..7eba2ee --- /dev/null +++ b/qtquicktests/tst_model_exercise_sorted.qml @@ -0,0 +1,37 @@ +import QtTest 1.0 + +PythonListView { + sorted: true; + TestCase { + name: "model_exercise_sorted" + when: ready + + function get_model_item(row) { + return view.model.data(view.model.index(row, 0), 258); + } + + function get_model_list() { + var result = new Array(view.count); + for (var i=0; i