diff options
31 files changed, 3337 insertions, 5 deletions
diff --git a/sources/pyside6/CMakeLists.txt b/sources/pyside6/CMakeLists.txt index f45c07114..9c238e980 100644 --- a/sources/pyside6/CMakeLists.txt +++ b/sources/pyside6/CMakeLists.txt @@ -25,6 +25,10 @@ if(Qt${QT_MAJOR_VERSION}Qml_FOUND) add_subdirectory(libpysideqml) endif() +if(Qt${QT_MAJOR_VERSION}RemoteObjects_FOUND) + add_subdirectory(libpysideremoteobjects) +endif() + if(Qt${QT_MAJOR_VERSION}UiTools_FOUND) add_subdirectory(plugins/uitools) find_package(Qt6 COMPONENTS Designer) diff --git a/sources/pyside6/PySide6/QtRemoteObjects/CMakeLists.txt b/sources/pyside6/PySide6/QtRemoteObjects/CMakeLists.txt index 07835b2f6..2522ab54f 100644 --- a/sources/pyside6/PySide6/QtRemoteObjects/CMakeLists.txt +++ b/sources/pyside6/PySide6/QtRemoteObjects/CMakeLists.txt @@ -29,20 +29,23 @@ ${QtRemoteObjects_GEN_DIR}/qtroserveriodevice_wrapper.cpp ${QtRemoteObjects_GEN_DIR}/qtremoteobjects_module_wrapper.cpp ) +find_package(Qt6 REQUIRED COMPONENTS Core) + set(QtRemoteObjects_include_dirs ${QtRemoteObjects_SOURCE_DIR} ${QtRemoteObjects_BINARY_DIR} ${Qt${QT_MAJOR_VERSION}RemoteObjects_INCLUDE_DIRS} + ${libpysideremoteobjects_SOURCE_DIR} ${SHIBOKEN_INCLUDE_DIR} ${libpyside_SOURCE_DIR} ${SHIBOKEN_PYTHON_INCLUDE_DIR} ${QtCore_GEN_DIR} ${QtNetwork_GEN_DIR}) -set(QtRemoteObjects_libraries pyside6 - ${Qt${QT_MAJOR_VERSION}RemoteObjects_LIBRARIES}) - set(QtRemoteObjects_deps QtCore QtNetwork) +set(QtRemoteObjects_libraries pyside6 pyside6remoteobjects + ${Qt${QT_MAJOR_VERSION}RemoteObjects_LIBRARIES}) + create_pyside_module(NAME QtRemoteObjects INCLUDE_DIRS QtRemoteObjects_include_dirs LIBRARIES QtRemoteObjects_libraries diff --git a/sources/pyside6/PySide6/QtRemoteObjects/typesystem_remoteobjects.xml b/sources/pyside6/PySide6/QtRemoteObjects/typesystem_remoteobjects.xml index 86e4d9093..a6e54ee18 100644 --- a/sources/pyside6/PySide6/QtRemoteObjects/typesystem_remoteobjects.xml +++ b/sources/pyside6/PySide6/QtRemoteObjects/typesystem_remoteobjects.xml @@ -8,6 +8,9 @@ <load-typesystem name="templates/core_common.xml" generate="no"/> <load-typesystem name="QtCore/typesystem_core.xml" generate="no"/> <load-typesystem name="QtNetwork/typesystem_network.xml" generate="no"/> + <inject-code class="native" position="beginning"> + #include "pysideremoteobjects.h" + </inject-code> <rejection class="QRemoteObjectStringLiterals"/> <rejection class="*" function-name="getTypeNameAndMetaobjectFromClassInfo"/> @@ -26,6 +29,10 @@ </object-type> <object-type name="QRemoteObjectNode"> <enum-type name="ErrorCode"/> + <add-function signature="acquire(PyTypeObject*, PyObject* @name@ = 0)" + return-type="PyTypeObject*"> + <inject-code class="target" file="../glue/qtremoteobjects.cpp" snippet="node-acquire"/> + </add-function> </object-type> <object-type name="QRemoteObjectPendingCall"> <enum-type name="Error"/> @@ -35,7 +42,12 @@ <object-type name="QRemoteObjectRegistryHost"/> <object-type name="QRemoteObjectReplica"> <enum-type name="State"/> - <!-- protected: <enum-type name="ConstructorType"/> --> + <enum-type name="ConstructorType" python-type="IntEnum"/> <!-- Needed even though protected --> + <modify-function signature="QRemoteObjectReplica(QRemoteObjectReplica::ConstructorType)"> + <modify-argument index="1"> + <replace-default-expression with="{}"/> + </modify-argument> + </modify-function> </object-type> <object-type name="QRemoteObjectSettingsStore"/> <value-type name="QRemoteObjectSourceLocationInfo"/> @@ -53,4 +65,7 @@ <!-- QtNetwork is pulled in via QtRemoteObjectsDepends. --> <suppress-warning text="^Scoped enum 'Q(Ocsp)|(Dtls).*' does not have a type entry.*$"/> + <inject-code class="target" position="end" + file="../glue/qtremoteobjects.cpp" snippet="qtro-init"/> + </typesystem> diff --git a/sources/pyside6/PySide6/glue/qtremoteobjects.cpp b/sources/pyside6/PySide6/glue/qtremoteobjects.cpp new file mode 100644 index 000000000..88d585892 --- /dev/null +++ b/sources/pyside6/PySide6/glue/qtremoteobjects.cpp @@ -0,0 +1,31 @@ +// Copyright (C) 2024 Ford Motor Company +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only + +// @snippet qtro-init +PySide::RemoteObjects::init(module); +// @snippet qtro-init + +// @snippet node-acquire +auto *typeObject = reinterpret_cast<PyTypeObject*>(%PYARG_1); +if (!PySide::inherits(typeObject, SbkPySide6_QtRemoteObjectsTypeStructs[SBK_QRemoteObjectReplica_IDX].fullName)) { + PyErr_SetString(PyExc_TypeError, "First argument must be a type deriving from QRemoteObjectReplica."); + return nullptr; +} + +static PyObject *pyConstructWithNode = Shiboken::Enum::newItem( + Shiboken::Module::get(SbkPySide6_QtRemoteObjectsTypeStructs[SBK_QRemoteObjectReplica_ConstructorType_IDX]), + 1 /* protected QRemoteObjectReplica::ConstructorType::ConstructWithNode */ +); + +Shiboken::AutoDecRef args; +if (pyArgs[1]) + args.reset(PyTuple_Pack(3, %PYSELF, pyConstructWithNode, pyArgs[1])); +else + args.reset(PyTuple_Pack(2, %PYSELF, pyConstructWithNode)); + +PyObject *instance = PyObject_CallObject(%PYARG_1, args.object()); +if (!instance) + return nullptr; // Propagate the exception + +%PYARG_0 = instance; +// @snippet node-acquire diff --git a/sources/pyside6/cmake/Macros/PySideModules.cmake b/sources/pyside6/cmake/Macros/PySideModules.cmake index a83f2b745..5cd12b683 100644 --- a/sources/pyside6/cmake/Macros/PySideModules.cmake +++ b/sources/pyside6/cmake/Macros/PySideModules.cmake @@ -297,6 +297,7 @@ macro(create_pyside_module) set(ld_prefix_list "") list(APPEND ld_prefix_list "${pysidebindings_BINARY_DIR}/libpyside") list(APPEND ld_prefix_list "${pysidebindings_BINARY_DIR}/libpysideqml") + list(APPEND ld_prefix_list "${pysidebindings_BINARY_DIR}/libpysideremoteobjects") list(APPEND ld_prefix_list "${SHIBOKEN_SHARED_LIBRARY_DIR}") if(WIN32) list(APPEND ld_prefix_list "${QT6_INSTALL_PREFIX}/${QT6_INSTALL_BINS}") diff --git a/sources/pyside6/cmake/PySideSetup.cmake b/sources/pyside6/cmake/PySideSetup.cmake index c92b0c986..45a63a1a0 100644 --- a/sources/pyside6/cmake/PySideSetup.cmake +++ b/sources/pyside6/cmake/PySideSetup.cmake @@ -199,6 +199,9 @@ endforeach() # Whether to add libpysideqml find_package(Qt6 COMPONENTS Qml) +# Whether to add libpysideremoteobjects +find_package(Qt6 COMPONENTS RemoteObjects) + string(REGEX MATCHALL "[0-9]+" qt_version_helper "${Qt${QT_MAJOR_VERSION}Core_VERSION}") list(GET qt_version_helper 0 QT_VERSION_MAJOR) diff --git a/sources/pyside6/doc/developer/index.rst b/sources/pyside6/doc/developer/index.rst index bddd39d91..0260ad272 100644 --- a/sources/pyside6/doc/developer/index.rst +++ b/sources/pyside6/doc/developer/index.rst @@ -36,3 +36,4 @@ many features and implementation details that the project has: signature_doc.rst mypy-correctness.rst feature-motivation.rst + remoteobjects.md diff --git a/sources/pyside6/doc/developer/remoteobjects.md b/sources/pyside6/doc/developer/remoteobjects.md new file mode 100644 index 000000000..7d4c29aa0 --- /dev/null +++ b/sources/pyside6/doc/developer/remoteobjects.md @@ -0,0 +1,162 @@ +# Qt Remote Objects Overview + +[Qt Remote Objects](https://doc.qt.io/qt-6/qtremoteobjects-index.html) (or QtRO) +is described as an IPC module. That puts the focus on the internal details. +It should be looked at more as a Connected Framework. + +QtRO lets you easily take an existing Qt application and interact with it from +other devices. QtRO allows you to create a +[_Replica_](https://doc.qt.io/qt-6/qtremoteobjects-replica.html) QObject, making +the Replica a surrogate for the real QOject in your program (called the +[_Source_](https://doc.qt.io/qt-6/qtremoteobjects-source.html)). You interact with +the Replica the same way you would the Source (with one important difference) and QtRO +ensures those interactions are forwarded to the source for handling. Changes to the +Source are cascaded to any Replicas. + +The mechanism Qt Remote Objects provides for enabling these objects to connect to each +other are a network of +[_Nodes_](https://doc.qt.io/qt-6/qtremoteobjects-node.html). Nodes handle the details of +connecting processes or devices. A Replica is created by calling +[acquire()](https://doc.qt.io/qt-6/qremoteobjectnode.html#acquire) on a Node, and Sources +are shared on the network using +[enableRemoting()](https://doc.qt.io/qt-6/qremoteobjecthostbase.html#enableRemoting). + +## Replicas are _latent copies_ + +Qt Remote Object interactions are inherently asynchronous. This _can_ lead to +confusing results initially + +```python +# Assume a replica initially has an int property `i` with a value of 2 +print(f"Value of i on replica = {replica.i}") # prints 2 +replica.iChanged.connect(lambda i: print(f"Value of i on replica changed to {i}")) +replica.i = 3 +print(f"Value of i on replica = {replica.i}") # prints 2, not 3 + +# When the eventloop runs, the change will be forwarded to the source instance, +# the change will be made, and the new i value will be sent back to the replica. +# The iChanged signal will be fired +# after some delay. +``` + +Note: To avoid this confusion, Qt Remote Objects can change setters to "push" +slots on the Replica class, making the asynchronous nature of the behavior +clear. + +```python +replica.pushI(3) # Request a change to `i` on the source object. +``` + +## How does this affect PySide? + +PySide wraps the Qt C++ classes used by QtRO, so much of the needed +functionality for QtRO is available in PySide. However, the interaction between +a Source and Replica are in effect a contract that is defined on a _per object_ +basis. I.e., different objects have different APIs, and every participant must +know about the contracts for the objects they intend to use. + +In C++, Qt Remote Objects leverages the +[Replica Compiler (repc)](https://doc.qt.io/qt-6/qtremoteobjects-repc.html) to +generate QObject header and C++ code that enforce the contracts for each type. +REPC uses a simplified text syntax to describe the desired API in .rep files. +REPC is integrated with qmake and cmake, simplifying the process of leveraging +QtRO in a C++ project. The challenges in PySide are +1) To parse the .rep file to extract the desired syntax +2) Allow generation of types that expose the desired API and match the needed + contract +3) Provide appropriate errors and handling in cases that can't be dynamically + handled in Python. +For example, C++ can register templated types such as a QMap<double, MyType> +and serialize such types once registered. While Python can create a similar +type, there isn't a path to dynamically serialize such a type so C++ could +interpret it correctly on the other side of a QtRO network. + +Under the covers, QtRO leverages Qt's QVariant infrastructure heavily. For +instance, a Replica internally holds a QVariantList where each element +represents one of the exposed QProperty values. The property's QVariant is +typed appropriately for the property, allows an autogenerated getter to (for +instance with a float property) return `return variant.value<float >();`. This +works well with PySide converters. + +## RepFile PySide type + +The first challenge is handled by adding a Python type RepFile can takes a .rep +file and parses it into an Abstract Syntax Tree (AST) describing the type. + +A simple .rep might look like: +```cpp +class Thermistat +{ + PROP(int temp) +} +``` + +The desired interface would be +```python +from pathlib import Path +from PySide6.QtRemoteObjects import RepFile + +input_file = Path(__file__).parent / "thermistat.rep" +rep_file = RepFile(input_file) +``` + +The RepFile holds dictionaries `source`, `replica` and `pod`. These use the +names of the types as the key, and the value is the PyTypeObject* of the +generated type meeting the desired contract: + +```python +Source = rep_file.source["Thermistat"] # A Type object for Source implementation of the type +Replica = rep_file.replica["Thermistat"] # A Type object for Replica implementation of the type +``` + +## Replica type + +A Replica for a given interface will be a distinct type. It should be usable +directly from Python once instantiated and initialized. + +```python +Replica = rep_file.replica["Thermistat"] # A Type object matching the Replica contract +replica = node.acquire(Replica) # We need to tell the node what type to instantiate +# These two lines can be combined +replica_instance = node.acquire(rep_file.replica["Thermistat"]) + +# If there is a Thermistat source on the network, our replica will get connected to it. +if replica.isInitialized(): + print(f"The current tempeerature is {replica.temp}") +else: + replica.initialized.connect(lambda: print(f"replica is now initialized. Temp = {replica.temp}")) +``` + +## Source type + +Unlike a Replica, whose interface is a passthrough of another object, the +Source needs to actually define the desired behavior. In C++, QtRO supports two +modes for Source objects. A MyTypeSource C++ class is autogenerated that +defines pure virtual getters and setters. This enables full customization of +the implementation. A MyTypeSimpleSource C++ class is also autogenerated that +creates basic data members for properties and getters/setters that work on +those data members. + +The intent is to follow the SimpleSource pattern in Python if possible. + +```python + Thermistat = rep_file.source["Thermistat"] + class MyThermistat(Thermistat): + def __init__(self, parent = None): + super().__init__(parent) + # Get the current temp from the system + self.temp = get_temp_from_system() +``` + +## Realizing Source/Replica types in python + +Assume there is a RepFile for thermistat.rep that defines a Thermistat class +interface. + +`ThermistatReplica = repFile.replica["Thermistat"]` should be a Shiboken.ObjectType +type, with a base of QRemoteObjectReplica's shiboken type. + +`ThermistatSource = repFile.source["Thermistat"]` should be a abstract class of +Shiboken.ObjectType type, with a base of QObject's shiboken type. + +Both should support new classes based on their type to customize behavior. diff --git a/sources/pyside6/libpysideremoteobjects/CMakeLists.txt b/sources/pyside6/libpysideremoteobjects/CMakeLists.txt new file mode 100644 index 000000000..f73eba6ee --- /dev/null +++ b/sources/pyside6/libpysideremoteobjects/CMakeLists.txt @@ -0,0 +1,88 @@ +# Copyright (C) 2025 Ford Motor Company +# SPDX-License-Identifier: BSD-3-Clause + +if (NOT CMAKE_MINIMUM_REQUIRED_VERSION) + cmake_minimum_required(VERSION 3.18) + cmake_policy(VERSION 3.18) +endif() + +project(libpysideremoteobjects LANGUAGES CXX) + +if (NOT libpyside_SOURCE_DIR) # Building standalone + message(STATUS "Building standalone. Setting C++ standard and build type.") + set(CMAKE_CXX_STANDARD 17) + set(CMAKE_CXX_STANDARD_REQUIRED ON) + if(NOT CMAKE_BUILD_TYPE) + set(CMAKE_BUILD_TYPE Release) + endif() + find_package(Python3 REQUIRED COMPONENTS Interpreter Development) + find_package(Shiboken6 REQUIRED) + find_package(libpyside REQUIRED) + get_target_property(pyside6_SOURCE_DIR PySide6::pyside6 INTERFACE_INCLUDE_DIRECTORIES) +endif() + +find_package(Qt6 REQUIRED COMPONENTS Core RepParser RemoteObjects) + +set(libpysideremoteobjects_HEADERS + pysidecapsulemethod_p.h + pysidedynamicclass_p.h + pysidedynamiccommon_p.h + pysidedynamicenum_p.h + pysidedynamicpod_p.h + pysiderephandler_p.h +) + +set(libpysideremoteobjects_SRC + pysiderephandler.cpp + pysidecapsulemethod.cpp + pysidedynamiccommon.cpp + pysidedynamicclass.cpp + pysidedynamicpod.cpp + pysidedynamicenum.cpp + ${libpysideremoteobjects_HEADERS} +) + +list(GET Qt6RepParser_INCLUDE_DIRS 0 REPPARSER_DIR) + +include(QtTargetHelpers) +include(QtTestHelpers) +include(QtLalrHelpers) +add_library(pyside6remoteobjects STATIC ${libpysideremoteobjects_SRC}) + +target_include_directories(pyside6remoteobjects PRIVATE + ${REPPARSER_DIR} + ${Qt${QT_VERSION_MAJOR}Core_PRIVATE_INCLUDE_DIRS} + ${Qt${QT_MAJOR_VERSION}RemoteObjects_INCLUDE_DIRS} + ${Qt${QT_MAJOR_VERSION}RemoteObjects_PRIVATE_INCLUDE_DIRS} + ${pyside6_SOURCE_DIR} # Added internally by the create_pyside_module function + ${SHIBOKEN_INCLUDE_DIR} + ${libpyside_SOURCE_DIR} + ${SHIBOKEN_PYTHON_INCLUDE_DIR} + ${CMAKE_CURRENT_BINARY_DIR} # Include the component-specific build directory +) + +target_link_libraries(pyside6remoteobjects PRIVATE + Shiboken6::libshiboken # Added internally by the create_pyside_module function + Qt6::Core + Qt6::RemoteObjectsPrivate +) + +qt_process_qlalr( + pyside6remoteobjects + "${REPPARSER_DIR}/parser.g" + "" +) + +set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -D QT_NO_CAST_FROM_ASCII -D QT_NO_CAST_TO_ASCII") + +# +# install stuff +# + +install(FILES ${libpysideremoteobjects_HEADERS} + DESTINATION include/${BINDING_NAME}${pyside6remoteobjects_SUFFIX}) + +install(TARGETS pyside6remoteobjects EXPORT PySide6RemoteObjectsTargets + LIBRARY DESTINATION "${LIB_INSTALL_DIR}" + ARCHIVE DESTINATION "${LIB_INSTALL_DIR}" + RUNTIME DESTINATION bin) diff --git a/sources/pyside6/libpysideremoteobjects/pysidecapsulemethod.cpp b/sources/pyside6/libpysideremoteobjects/pysidecapsulemethod.cpp new file mode 100644 index 000000000..d5a5454f0 --- /dev/null +++ b/sources/pyside6/libpysideremoteobjects/pysidecapsulemethod.cpp @@ -0,0 +1,230 @@ +// Copyright (C) 2025 Ford Motor Company +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only + +#include "pysidecapsulemethod_p.h" + +extern "C" +{ + +// This struct is used for both CapsuleMethod and CapsuleProperty +struct CapsuleDescriptor +{ + PyTypeObject base; + PyObject *capsule; + PyMethodDef methodDef; + + void configure(PyObject *capsule, PyMethodDef *method) + { + this->capsule = capsule; + Py_INCREF(capsule); + // We make a copy of the input name and doc strings so they can be temporary on + // the input. + if (method->ml_name) + methodDef.ml_name = strdup(method->ml_name); + if (method->ml_doc) + methodDef.ml_doc = strdup(method->ml_doc); + methodDef.ml_meth = method->ml_meth; + methodDef.ml_flags = method->ml_flags; + } +}; + +static PyObject *CapsuleDescriptor_tp_new(PyTypeObject *type, PyObject * /* args */, PyObject * /* kwds */); +static void CapsuleDescriptor_free(PyObject *self); +static PyObject *CapsuleMethod_descr_get(PyObject *self, PyObject *instance, PyObject * /* owner */); +static PyObject *CapsuleProperty_descr_get(PyObject *self, PyObject *instance, PyObject * /* owner */); +static int CapsuleProperty_descr_set(PyObject *self, PyObject *instance, PyObject * /* owner */); + +/** + * We are creating two related types, CapsuleMethod and CapsuleProperty, that are + * used to enable lambda-like behavior. The difference is in usage, where + * CapsuleMethod's __get__ function returns a Callable (i.e., method-like usage: + * obj.capsuleMethodName(args)) and only supports the __get__ method. + * CapsuleProperty on the other hand is used for properties, and supports both + * __get__ and __set__ methods (i.e., obj.capsulePropertyName = value or val = + * obj.capsulePropertyName). + */ +static PyTypeObject *createCapsuleMethodType() +{ + PyType_Slot CapsuleMethodType_slots[] = { + {Py_tp_new, reinterpret_cast<void *>(CapsuleDescriptor_tp_new)}, + {Py_tp_descr_get, reinterpret_cast<void *>(CapsuleMethod_descr_get)}, + {Py_tp_free, reinterpret_cast<void *>(CapsuleDescriptor_free)}, + {0, nullptr} + }; + + PyType_Spec CapsuleMethodType_spec = { + "2:PySide6.QtRemoteObjects.CapsuleMethod", + sizeof(CapsuleDescriptor), + 0, + Py_TPFLAGS_DEFAULT, + CapsuleMethodType_slots}; + + PyObject *type = PyType_FromSpec(&CapsuleMethodType_spec); + if (!type) { + PyErr_Print(); + return nullptr; + } + return reinterpret_cast<PyTypeObject*>(type); +} + +PyTypeObject *CapsuleMethod_TypeF(void) +{ + static auto *type = createCapsuleMethodType(); + return type; +} + +static PyTypeObject *createCapsulePropertyType(bool isWritable) +{ + PyType_Slot WritablePropertyType_slots[] = { + {Py_tp_new, reinterpret_cast<void *>(CapsuleDescriptor_tp_new)}, + {Py_tp_descr_get, reinterpret_cast<void *>(CapsuleProperty_descr_get)}, + {Py_tp_descr_set, reinterpret_cast<void *>(CapsuleProperty_descr_set)}, + {Py_tp_free, reinterpret_cast<void *>(CapsuleDescriptor_free)}, + {0, nullptr} + }; + + PyType_Slot ReadOnlyPropertyType_slots[] = { + {Py_tp_new, reinterpret_cast<void *>(CapsuleDescriptor_tp_new)}, + {Py_tp_descr_get, reinterpret_cast<void *>(CapsuleProperty_descr_get)}, + {Py_tp_free, reinterpret_cast<void *>(CapsuleDescriptor_free)}, + {0, nullptr} + }; + + PyType_Spec CapsulePropertyType_spec = { + "2:PySide6.QtRemoteObjects.CapsuleProperty", + sizeof(CapsuleDescriptor), + 0, + Py_TPFLAGS_DEFAULT, + isWritable ? WritablePropertyType_slots : ReadOnlyPropertyType_slots}; + + PyObject *type = PyType_FromSpec(&CapsulePropertyType_spec); + if (!type) { + PyErr_Print(); + return nullptr; + } + return reinterpret_cast<PyTypeObject*>(type); +} + +PyTypeObject *CapsuleProperty_TypeF(bool isWritable=false) +{ + if (isWritable) { + static auto *type = createCapsulePropertyType(true); + return type; + } + static auto *type = createCapsulePropertyType(false); + return type; +} + +static PyObject *CapsuleDescriptor_tp_new(PyTypeObject *type, PyObject * /* args */, PyObject * /* kwds */) +{ + auto *self = reinterpret_cast<CapsuleDescriptor *>(PyType_GenericAlloc(type, 0)); + if (self != nullptr) { + self->capsule = nullptr; + self->methodDef = {nullptr, nullptr, METH_NOARGS, nullptr}; // Initialize methodDef + } + return reinterpret_cast<PyObject *>(self); +} + +static void CapsuleDescriptor_free(PyObject *self) +{ + auto *d = reinterpret_cast<CapsuleDescriptor *>(self); + Py_XDECREF(d->capsule); + free(const_cast<char*>(d->methodDef.ml_name)); + free(const_cast<char*>(d->methodDef.ml_doc)); +} + +static PyObject *CapsuleMethod_descr_get(PyObject *self, PyObject *instance, PyObject * /* owner */) +{ + if (instance == nullptr) { + // Return the descriptor object if accessed from the class + Py_INCREF(self); + return self; + } + + auto *d = reinterpret_cast<CapsuleDescriptor *>(self); + CapsuleDescriptorData *data = new CapsuleDescriptorData{instance, d->capsule}; + PyObject *payload = PyCapsule_New(data, "Payload", [](PyObject *capsule) { + delete reinterpret_cast<CapsuleDescriptorData *>(PyCapsule_GetPointer(capsule, "Payload")); + }); + if (!payload) + return nullptr; + + Py_INCREF(payload); + return PyCFunction_New(&d->methodDef, payload); +} + +bool add_capsule_method_to_type(PyTypeObject *type, PyMethodDef *method, PyObject *capsule) +{ + if (PyType_Ready(type) < 0) { + PyErr_Print(); + return false; + } + auto *descriptor = reinterpret_cast<CapsuleDescriptor *>( + PyObject_CallObject(reinterpret_cast<PyObject *>(CapsuleMethod_TypeF()), nullptr)); + if (!descriptor) { + PyErr_Print(); + return false; + } + descriptor->configure(capsule, method); + + auto *descr = reinterpret_cast<PyObject *>(descriptor); + if (PyObject_SetAttrString(reinterpret_cast<PyObject *>(type), method->ml_name, descr) < 0) { + PyErr_Print(); + return false; + } + return true; +} + +static PyObject *CapsuleProperty_descr_get(PyObject *self, PyObject *instance, PyObject * /* owner */) +{ + if (instance == nullptr) { + // Return the descriptor object if accessed from the class + Py_INCREF(self); + return self; + } + + auto *d = reinterpret_cast<CapsuleDescriptor *>(self); + CapsuleDescriptorData *data = new CapsuleDescriptorData{instance, d->capsule}; + PyObject *payload = PyCapsule_New(data, "Payload", [](PyObject *capsule) { + delete reinterpret_cast<CapsuleDescriptorData *>(PyCapsule_GetPointer(capsule, "Payload")); + }); + if (!payload) + return nullptr; + + return PyObject_CallFunctionObjArgs(PyCFunction_New(&d->methodDef, payload), nullptr); +} + +static int CapsuleProperty_descr_set(PyObject *self, PyObject *instance, PyObject *value) +{ + auto *d = reinterpret_cast<CapsuleDescriptor *>(self); + CapsuleDescriptorData *data = new CapsuleDescriptorData{instance, d->capsule}; + PyObject *payload = PyCapsule_New(data, "Payload", [](PyObject *capsule) { + delete reinterpret_cast<CapsuleDescriptorData *>(PyCapsule_GetPointer(capsule, "Payload")); + }); + if (!payload) + return -1; + + Py_INCREF(payload); + PyObject *result = PyObject_CallFunctionObjArgs(PyCFunction_New(&d->methodDef, payload), + value, nullptr); + if (!result) + return -1; + + Py_DECREF(result); + return 0; +} + +// Returns a new CapsuleProperty descriptor object for use with PySideProperty +PyObject *make_capsule_property(PyMethodDef *method, PyObject *capsule, bool isWritable) +{ + auto *type = CapsuleProperty_TypeF(isWritable); + auto *descriptor = PyObject_CallObject(reinterpret_cast<PyObject *>(type), nullptr); + if (!descriptor) + return nullptr; + + reinterpret_cast<CapsuleDescriptor*>(descriptor)->configure(capsule, method); + + return descriptor; +} + +} // extern "C" diff --git a/sources/pyside6/libpysideremoteobjects/pysidecapsulemethod_p.h b/sources/pyside6/libpysideremoteobjects/pysidecapsulemethod_p.h new file mode 100644 index 000000000..7b6abc54b --- /dev/null +++ b/sources/pyside6/libpysideremoteobjects/pysidecapsulemethod_p.h @@ -0,0 +1,87 @@ +// Copyright (C) 2025 Ford Motor Company +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only + +#ifndef PYSIDE_CAPSULEMETHOD_P_H +#define PYSIDE_CAPSULEMETHOD_P_H + +#include <sbkpython.h> + +extern "C" +{ + +/** + * This code is needed to solve, in C++ and adhering to the stable API, + * creating what are in effect lambda functions as instance methods on custom + * types. The goal is to be able to add methods to a dynamic type. If the .rep + * file defines a slot `mySlot`, it need to be added to the dynamic type. For + * Source types, this should be an abstract method that raises a + * NotImplementedError unless defined in the Python subclass. For Replica + * types, this should include an implementation that forwards the request + * through the underlying QRemoteObjectReplica instance. + * + * The stable API doesn't currently provide a way define a method that can + * receive both the `self`, `args`, and runtime (but constant per method, i.e., + * lambda like) data using Py_tp_methods. Possibly post 3.13 when METH_METHOD is + * part of the stable API. But for now, it is not. + * + * The solution is to create a custom descriptor + * (https://docs.python.org/3/howto/descriptor.html) that can hold the runtime + * data and then when called, will return a PyCFunction_New generated PyObject + * that is passed both class instance `self` and the runtime data (a PyCapsule) + * together as a tuple as a new `self` for the method. The static method + * definition needs to expect and handle this, but when combined in C++, we can + * define a single handler that receives both the original `self` of the instance + * and the runtime capsule with data for handling. + */ + +/** + * The CapsuleDescriptorData struct is what will be passed as the pseudo `self` + * from a CapsuleMethod or CapsuleProperty to the associated handler method. The + * handler method (which should look like a standard PyMethodDef method) should + * parse it into the payload (the "lambda variables") and the actual instance + * (the "self"). + */ +struct CapsuleDescriptorData +{ + PyObject *self; + PyObject *payload; +}; + +/** + * The new type defining a descriptor that stores a PyCapsule. This is used to + * store the runtime data, with the __get__ method returning a new Callable. + */ +PyTypeObject *CapsuleMethod_TypeF(void); + +/** + * The new type defining a descriptor that stores a PyCapsule. This is used to + * store the runtime data, with the __get__ (and __set__ if isWritable) providing + * property behavior. + */ +PyTypeObject *CapsuleProperty_TypeF(bool isWritable); + +/** + * Add a capsule method (a descriptor) to a type. This will create a new capsule + * method descriptor and add it as an attribute to the type, using the given name. + * + * A single handle can then respond to what appear to be distinct methods on the + * type, but using the runtime data (from the capsule) when handling each call. + * + * @param type The type to attach the created descriptor to. + * @param method The method definition to associate with the descriptor. + * The name of the method will be used as the attribute name. + * @param capsule The capsule to store in the descriptor. + * @return True if the descriptor was added successfully, false otherwise. + */ +bool add_capsule_method_to_type(PyTypeObject *type, PyMethodDef *method, + PyObject *capsule); + +/** + * Make a new CapsuleProperty type. + */ +PyObject *make_capsule_property(PyMethodDef *method, PyObject *capsule, + bool isWritable = false); + +} // extern "C" + +#endif // PYSIDE_CAPSULEMETHOD_P_H diff --git a/sources/pyside6/libpysideremoteobjects/pysidedynamicclass.cpp b/sources/pyside6/libpysideremoteobjects/pysidedynamicclass.cpp new file mode 100644 index 000000000..941e38c6e --- /dev/null +++ b/sources/pyside6/libpysideremoteobjects/pysidedynamicclass.cpp @@ -0,0 +1,506 @@ +// Copyright (C) 2025 Ford Motor Company +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only + +// Workaround to access protected functions. PySide builds with this, but +// since this is now a separate library, we need to add it too. + +#include "pysidedynamiccommon_p.h" +#include "pysidedynamicclass_p.h" +#include "pysidecapsulemethod_p.h" +#include "pysiderephandler_p.h" + +#include <basewrapper.h> +#include <sbkconverter.h> +#include <sbkstring.h> + +#include <pyside_p.h> +#include <pysideproperty.h> +#include <pysideqobject.h> +#include <pysidesignal.h> +#include <pysideutils.h> + +#include <QtCore/qmetaobject.h> +#include <QtCore/qvariantlist.h> + +#include <QtRemoteObjects/qremoteobjectpendingcall.h> +#include <QtRemoteObjects/qremoteobjectreplica.h> + +using namespace Shiboken; + +class FriendlyReplica : public QRemoteObjectReplica +{ +public: + using QRemoteObjectReplica::send; + using QRemoteObjectReplica::setProperties; + using QRemoteObjectReplica::propAsVariant; + using QRemoteObjectReplica::sendWithReply; +}; + +extern "C" +{ + +PyObject *propertiesAttr() +{ + static PyObject *const s = Shiboken::String::createStaticString("__PROPERTIES__"); + return s; +} + +struct SourceDefs +{ + static PyTypeObject *getSbkType() + { + static PyTypeObject *sbkType = + Shiboken::Conversions::getPythonTypeObject("QObject"); + return sbkType; + } + + static PyObject *getBases() + { + static PyObject *bases = PyTuple_Pack(1, getSbkType()); + return bases; + } + + static const char *getTypePrefix() + { + return "2:PySide6.QtRemoteObjects.DynamicSource."; + } + + static int tp_init(PyObject *self, PyObject *args, PyObject *kwds) + { + static initproc initFunc = reinterpret_cast<initproc>(PepType_GetSlot(getSbkType(), Py_tp_init)); + int res = initFunc(self, args, kwds); + if (res < 0) { + PyErr_Print(); + return res; + } + + // Get the properties from the type + PyTypeObject *type = Py_TYPE(self); + auto *pyProperties = PyObject_GetAttr(reinterpret_cast<PyObject *>(type), propertiesAttr()); + if (!pyProperties) { + PyErr_SetString(PyExc_RuntimeError, "Failed to get properties from type"); + return -1; + } + // Add a copy of the properties to the object + auto *propPtr = reinterpret_cast<QVariantList *>(PyCapsule_GetPointer(pyProperties, nullptr)); + auto *propertiesCopy = new QVariantList(*propPtr); + PyObject *capsule = PyCapsule_New(propertiesCopy, nullptr, [](PyObject *capsule) { + delete reinterpret_cast<QVariantList *>(PyCapsule_GetPointer(capsule, nullptr)); + }); + PyObject_SetAttr(self, propertiesAttr(), capsule); + Py_DECREF(capsule); + return res; + } + + static PyObject *capsule_method_handler(PyObject *payload, PyObject *args) + { + auto *methodData = reinterpret_cast<CapsuleDescriptorData *>(PyCapsule_GetPointer(payload, + "Payload")); + if (!methodData) { + PyErr_SetString(PyExc_RuntimeError, "Invalid call to dynamic method. Missing payload."); + return nullptr; + } + PyObject *self = methodData->self; + if (PyCapsule_IsValid(methodData->payload, "PropertyCapsule")) { + // Handle property getter/setter against our hidden properties attribute + auto *capsule = PyCapsule_GetPointer(methodData->payload, "PropertyCapsule"); + if (capsule) { + auto *ob_dict = SbkObject_GetDict_NoRef(self); + auto *propPtr = PyCapsule_GetPointer(PyDict_GetItem(ob_dict, propertiesAttr()), + nullptr); + auto *currentProperties = reinterpret_cast<QVariantList *>(propPtr); + auto *callData = reinterpret_cast<PropertyCapsule *>(capsule); + if (callData->indexInObject < 0 + || callData->indexInObject >= currentProperties->size()) { + PyErr_Format(PyExc_RuntimeError, "Unknown property method: %s", + callData->name.constData()); + return nullptr; + } + const QVariant ¤tVariant = currentProperties->at(callData->indexInObject); + + // Handle getter + if (PyTuple_Size(args) == 0) + return toPython(currentVariant); + + // Handle setter + if (PyTuple_Size(args) != 1) { + PyErr_SetString(PyExc_TypeError, "Property setter takes exactly one argument"); + return nullptr; + } + Conversions::SpecificConverter converter(currentVariant.metaType().name()); + QVariant variant{currentVariant.metaType()}; + auto metaType = currentVariant.metaType(); + if (metaType.flags().testFlag(QMetaType::IsEnumeration)) { + converter.toCpp(PyTuple_GetItem(args, 0), variant.data()); + variant.convert(metaType); + } else { + converter.toCpp(PyTuple_GetItem(args, 0), variant.data()); + } + if (PyErr_Occurred()) // POD conversion can produce an error + return nullptr; + if (variant == currentVariant) + Py_RETURN_NONE; + + currentProperties->replace(callData->indexInObject, variant); + // Get the QMetaObject and emit the property changed signal if there is one + const auto *metaObject = PySide::retrieveMetaObject(self); + auto metaProperty = metaObject->property(callData->propertyIndex); + if (metaProperty.hasNotifySignal()) { + // We know our custom types don't have multiple cpp objects + void *cptr = reinterpret_cast<SbkObject *>(self)->d->cptr[0]; + auto *qObject = reinterpret_cast<QObject *>(cptr); + void *_args[] = {nullptr, variant.data()}; + QMetaObject::activate(qObject, metaProperty.notifySignalIndex(), _args); + } + Py_RETURN_NONE; + } + } + if (PyCapsule_IsValid(methodData->payload, "MethodCapsule")) { + auto *capsule = PyCapsule_GetPointer(methodData->payload, "MethodCapsule"); + auto *callData = reinterpret_cast<MethodCapsule *>(capsule); + if (callData->name.startsWith("push") && callData->name.size() > 4) { + const auto *metaObject = PySide::retrieveMetaObject(self); + // The convention for QtRO is if a property is named "something" and uses + // push, the name of the push method will be "pushSomething". But it is + // possible the name would be "Something", so we need to check upper + // and lower case. + auto name = callData->name.sliced(4); + auto index = metaObject->indexOfProperty(name.constData()); + if (index < 0) { + name[0] = tolower(name[0]); // Try lower case + index = metaObject->indexOfProperty(name.constData()); + } + // It is possible a .rep names a Slot "push" or "pushSomething" that + // isn't generated for a property. Let that fall through to regular + // method handling. + if (index >= 0) { + // Call the custom descriptor's set method + auto result = PyObject_SetAttrString(self, name.constData(), + PyTuple_GetItem(args, 0)); + if (result < 0) { + PyErr_Print(); + return nullptr; + } + Py_RETURN_NONE; + } + } + // TODO: This doesn't do much, as it is "eaten" by a PyError_Print in + // SignalManager::handleMetaCallError() + // Is there a better way to address slots that need to be implemented? + PyErr_Format(PyExc_NotImplementedError, "** The method %s is not implemented", + callData->name.constData()); + return nullptr; + } + + PyErr_SetString(PyExc_RuntimeError, "Unknown capsule type"); + return nullptr; + } +}; + +struct ReplicaDefs +{ + static PyTypeObject *getSbkType() + { + static PyTypeObject *sbkType = + Shiboken::Conversions::getPythonTypeObject("QRemoteObjectReplica"); + return sbkType; + } + + static PyObject *getBases() + { + static PyObject *bases = PyTuple_Pack(1, getSbkType()); + return bases; + } + + static const char *getTypePrefix() + { + return "2:PySide6.QtRemoteObjects.DynamicReplica."; + } + + static int tp_init(PyObject *self, PyObject *args, PyObject *kwds) + { + static initproc initFunc = reinterpret_cast<initproc>(PepType_GetSlot(getSbkType(), + Py_tp_init)); + QRemoteObjectReplica *replica = nullptr; + if (PyTuple_Size(args) == 0) { + if (initFunc(self, args, kwds) < 0) + return -1; + Shiboken::Conversions::pythonToCppPointer(getSbkType(), self, &replica); + } else { // Process replica with arguments passed from the added node.acquire method + PyObject *node = nullptr; + PyObject *constructorType = nullptr; + PyObject *name = nullptr; + static PyTypeObject *nodeType = Shiboken::Conversions::getPythonTypeObject("QRemoteObjectNode"); + if (!PyArg_UnpackTuple(args, "Replica.__init__", 2, 3, &node, &constructorType, &name) || + !PySide::inherits(Py_TYPE(node), nodeType->tp_name)) { + PyErr_SetString(PyExc_TypeError, + "Replicas can be initialized with no arguments or by node.acquire only"); + return -1; + } + static auto *constructorArgs = PyTuple_Pack(1, constructorType); + if (initFunc(self, constructorArgs, kwds) < 0) + return -1; + if (name) + PyObject_CallMethod(self, "initializeNode", "OO", node, name); + else + PyObject_CallMethod(self, "initializeNode", "O", node); + Shiboken::Conversions::pythonToCppPointer(getSbkType(), self, &replica); + } + if (!replica) { + PyErr_SetString(PyExc_RuntimeError, "Failed to initialize replica"); + return -1; + } + // Get the properties from the type + PyTypeObject *type = Py_TYPE(self); + auto *pyProperties = PyObject_GetAttr(reinterpret_cast<PyObject *>(type), propertiesAttr()); + if (!pyProperties) { + PyErr_SetString(PyExc_RuntimeError, "Failed to get properties from type"); + return -1; + } + // Make a copy of the properties and set them on the replica + auto *propPtr = reinterpret_cast<QVariantList *>(PyCapsule_GetPointer(pyProperties, nullptr)); + auto propertiesCopy = QVariantList(*propPtr); + static_cast<FriendlyReplica *>(replica)->setProperties(std::move(propertiesCopy)); + return 0; + } + + static PyObject *capsule_method_handler(PyObject *payload, PyObject *args) + { + auto *methodData = reinterpret_cast<CapsuleDescriptorData *>(PyCapsule_GetPointer(payload, + "Payload")); + if (!methodData) { + PyErr_SetString(PyExc_RuntimeError, "Invalid call to dynamic method. Missing payload."); + return nullptr; + } + PyObject *self = methodData->self; + QRemoteObjectReplica *replica = nullptr; + Shiboken::Conversions::pythonToCppPointer(getSbkType(), self, &replica); + if (PyCapsule_IsValid(methodData->payload, "PropertyCapsule")) { + auto *capsule = PyCapsule_GetPointer(methodData->payload, "PropertyCapsule"); + if (capsule) { + auto *callData = reinterpret_cast<PropertyCapsule *>(capsule); + QVariant currentVariant = static_cast<FriendlyReplica *>(replica)->propAsVariant(callData->indexInObject); + + // Handle getter + if (PyTuple_Size(args) == 0) // Getter + return toPython(currentVariant); + + // Handle setter - currentVariant is a copy, so we can modify it + if (PyTuple_Size(args) != 1) { + PyErr_SetString(PyExc_TypeError, + "Property setter takes exactly one argument"); + return nullptr; + } + Conversions::SpecificConverter converter(currentVariant.metaType().name()); + auto metaType = currentVariant.metaType(); + if (metaType.flags().testFlag(QMetaType::IsEnumeration)) { + converter.toCpp(PyTuple_GetItem(args, 0), currentVariant.data()); + currentVariant.convert(metaType); + } else { + converter.toCpp(PyTuple_GetItem(args, 0), currentVariant.data()); + } + if (PyErr_Occurred()) // POD conversion can produce an error + return nullptr; + QVariantList _args{currentVariant}; + static_cast<FriendlyReplica *>(replica)->send(QMetaObject::WriteProperty, callData->propertyIndex, _args); + Py_RETURN_NONE; + } + } + if (PyCapsule_IsValid(methodData->payload, "MethodCapsule")) { + auto *capsule = PyCapsule_GetPointer(methodData->payload, "MethodCapsule"); + if (capsule) { + auto *callData = reinterpret_cast<MethodCapsule *>(capsule); + if (PyTuple_Size(args) != callData->argumentTypes.size()) { + PyErr_SetString(PyExc_TypeError, + "Method called with incorrect number of arguments"); + return nullptr; + } + QVariantList _args; + static Conversions::SpecificConverter argsConverter("QVariantList"); + argsConverter.toCpp(args, &_args); + if (PyErr_Occurred()) // POD conversion can produce an error + return nullptr; + if (!callData->returnType.isValid() || + (callData->returnType.isValid() && callData->returnType.id() == QMetaType::Void)) { + static_cast<FriendlyReplica *>(replica)->send(QMetaObject::InvokeMetaMethod, callData->methodIndex, _args); + Py_RETURN_NONE; + } + QRemoteObjectPendingCall *cppResult = new QRemoteObjectPendingCall; + *cppResult = static_cast<FriendlyReplica *>(replica)->sendWithReply(QMetaObject::InvokeMetaMethod, + callData->methodIndex, _args); + static PyTypeObject *baseType = + Shiboken::Conversions::getPythonTypeObject("QRemoteObjectPendingCall"); + Q_ASSERT(baseType); + auto *pyResult = Shiboken::Object::newObject(baseType, cppResult, true, true); + return pyResult; + } + } + + PyErr_SetString(PyExc_RuntimeError, "Unknown capsule type"); + return nullptr; + } +}; + +static int DynamicType_traverse(PyObject *self, visitproc visit, void *arg) +{ + auto traverseProc = reinterpret_cast<traverseproc>(PepType_GetSlot(SbkObject_TypeF(), + Py_tp_traverse)); + return traverseProc(self, visit, arg); +} + +static int DynamicType_clear(PyObject *self) +{ + auto clearProc = reinterpret_cast<inquiry>(PepType_GetSlot(SbkObject_TypeF(), Py_tp_clear)); + return clearProc(self); +} + +static PyMethodDef DynamicClass_methods[] = { + {"get_enum", reinterpret_cast<PyCFunction>(DynamicType_get_enum), METH_O | METH_CLASS, + "Get enum type by name"}, + {nullptr, nullptr, 0, nullptr} +}; + +static PyType_Slot DynamicClass_slots[] = { + {Py_tp_base, nullptr}, // inserted by introduceWrapperType + {Py_tp_init, nullptr}, // inserted by createDynamicType + {Py_tp_traverse, reinterpret_cast<void *>(DynamicType_traverse)}, + {Py_tp_clear, reinterpret_cast<void *>(DynamicType_clear)}, + {Py_tp_methods, reinterpret_cast<void *>(DynamicClass_methods)}, + {0, nullptr} +}; + +} // extern "C" + +template <typename T, typename BaseType> +PyTypeObject *createDynamicClassImpl(QMetaObject *meta) +{ + DynamicClass_slots[1].pfunc = reinterpret_cast<void*>(T::tp_init); + + auto fullTypeName = QByteArray{T::getTypePrefix()} + meta->className(); + PyType_Spec spec = { + fullTypeName.constData(), + 0, + 0, + Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE | Py_TPFLAGS_HAVE_GC, + DynamicClass_slots + }; + + auto type = Shiboken::ObjectType::introduceWrapperType( + reinterpret_cast<PyObject *>(PySideRepFile_TypeF()), + meta->className(), + meta->className(), + &spec, + &Shiboken::callCppDestructor<BaseType>, + T::getBases(), + Shiboken::ObjectType::WrapperFlags::InternalWrapper); + + auto *self = reinterpret_cast<PyObject *>(type); + if (create_managed_py_enums(self, meta) < 0) + return nullptr; + + PySide::Signal::registerSignals(type, meta); + Shiboken::ObjectType::setSubTypeInitHook(type, &PySide::initQObjectSubType); + PySide::initDynamicMetaObject(type, meta, 0); // Size 0? + + PyMethodDef method = { + nullptr, + reinterpret_cast<PyCFunction>(T::capsule_method_handler), + METH_VARARGS, + nullptr + }; + for (int i = meta->propertyOffset(); i < meta->propertyCount(); ++i) { + // Create a PropertyCapsule for each property to store the info needed for + // the handler. Assign the __get__ and (if needed) __set__ attributes to a + // PySideProperty which becomes the attribute set on the new type. + auto metaProperty = meta->property(i); + PyObject *kwds = PyDict_New(); + auto metaType = metaProperty.metaType(); + auto *pyPropertyType = PyUnicode_FromString(metaType.name()); + PyDict_SetItemString(kwds, "type", pyPropertyType); + Py_DECREF(pyPropertyType); + + method.ml_name = metaProperty.name(); + auto *pc = new PropertyCapsule{metaProperty.name(), i, i - meta->propertyOffset()}; + auto capsule = PyCapsule_New(pc, "PropertyCapsule", [](PyObject *capsule) { + delete static_cast<PropertyCapsule *>(PyCapsule_GetPointer(capsule, "PropertyCapsule")); + }); + auto capsulePropObject = make_capsule_property(&method, capsule, + metaProperty.isWritable()); + PyObject *fget = PyObject_GetAttrString(capsulePropObject, "__get__"); + PyDict_SetItemString(kwds, "fget", fget); + if (metaProperty.isWritable()) { + PyObject *fset = PyObject_GetAttrString(capsulePropObject, "__set__"); + PyDict_SetItemString(kwds, "fset", fset); + if (metaProperty.hasNotifySignal()) { + auto nameString = metaProperty.notifySignal().name(); + auto *notify = PyObject_GetAttrString(reinterpret_cast<PyObject *>(type), + nameString.constData()); + PyDict_SetItemString(kwds, "notify", notify); + } + } + PyObject *pyProperty = PyObject_Call(reinterpret_cast<PyObject *>(PySideProperty_TypeF()), + PyTuple_New(0), kwds); + if (PyObject_SetAttrString(reinterpret_cast<PyObject *>(type), + metaProperty.name(), pyProperty) < 0) { + return nullptr; + } + Py_DECREF(pyProperty); + } + for (int i = meta->methodOffset(); i < meta->methodCount(); ++i) { + // Create a CapsuleMethod for each Slot method to store the info needed + // for the handler. + auto metaMethod = meta->method(i); + // Note: We are creating our custom metatype ourselves, which makes our added + // (non-signal), methods return QMetaMethod::MethodType::Method, not + // MethodType::Slot. This is fine, we just need to create a CapsuleMethod + // for those methods. + if (metaMethod.methodType() == QMetaMethod::MethodType::Signal) + continue; + auto name = metaMethod.name(); + method.ml_name = name.constData(); + QList<QMetaType> argumentTypes; + for (int j = 0; j < metaMethod.parameterCount(); ++j) + argumentTypes << metaMethod.parameterMetaType(j); + MethodCapsule *capsuleData = new MethodCapsule{metaMethod.name(), + metaMethod.methodIndex(), + std::move(argumentTypes), + metaMethod.returnMetaType()}; + add_capsule_method_to_type(type, &method, + PyCapsule_New(capsuleData, "MethodCapsule", + [](PyObject *capsule) { + delete reinterpret_cast<MethodCapsule *>(PyCapsule_GetPointer(capsule, "MethodCapsule")); + })); + } + + return type; +} + +PyTypeObject *createDynamicClass(QMetaObject *meta, PyObject *properties_capsule) +{ + bool isSource; + if (strncmp(meta->superClass()->className(), "QObject", 7) == 0) { + isSource = true; + } else if (strncmp(meta->superClass()->className(), "QRemoteObjectReplica", 20) == 0) { + isSource = false; + } else { + PyErr_SetString(PyExc_RuntimeError, + "Dynamic type must be a subclass of QObject or QRemoteObjectReplica"); + return nullptr; + } + + PyTypeObject *newType = nullptr; + + if (isSource) + newType = createDynamicClassImpl<SourceDefs, QObject>(meta); + else + newType = createDynamicClassImpl<ReplicaDefs, QRemoteObjectReplica>(meta); + + // Add the properties to the new type as an attribute + if (PyObject_SetAttr(reinterpret_cast<PyObject *>(newType), propertiesAttr(), + properties_capsule) < 0) { + Py_DECREF(newType); + return nullptr; + } + + return newType; +} diff --git a/sources/pyside6/libpysideremoteobjects/pysidedynamicclass_p.h b/sources/pyside6/libpysideremoteobjects/pysidedynamicclass_p.h new file mode 100644 index 000000000..4716f4f32 --- /dev/null +++ b/sources/pyside6/libpysideremoteobjects/pysidedynamicclass_p.h @@ -0,0 +1,15 @@ +// Copyright (C) 2025 Ford Motor Company +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only + +#ifndef PYSIDE_DYNAMIC_CLASS_P_H +#define PYSIDE_DYNAMIC_CLASS_P_H + +#include <sbkpython.h> + +#include <QtCore/qtclasshelpermacros.h> + +QT_FORWARD_DECLARE_STRUCT(QMetaObject) + +PyTypeObject *createDynamicClass(QMetaObject *meta, PyObject *properties_capsule); + +#endif // PYSIDE_DYNAMIC_CLASS_P_H diff --git a/sources/pyside6/libpysideremoteobjects/pysidedynamiccommon.cpp b/sources/pyside6/libpysideremoteobjects/pysidedynamiccommon.cpp new file mode 100644 index 000000000..b1f01fed6 --- /dev/null +++ b/sources/pyside6/libpysideremoteobjects/pysidedynamiccommon.cpp @@ -0,0 +1,124 @@ +// Copyright (C) 2025 Ford Motor Company +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only + +#include "pysidedynamiccommon_p.h" +#include "pysidedynamicenum_p.h" + +#include <sbkstring.h> + +#include <QtCore/qmetaobject.h> + +using namespace Shiboken; + +PyObject *toPython(const QVariant &variant) +{ + auto metaType = variant.metaType(); + Conversions::SpecificConverter converter(metaType.name()); + auto *value = converter.toPython(variant.data()); + if (metaType.flags().testFlag(QMetaType::IsGadget)) { + // A single converter is used for all POD types - it converts to a Python + // tuple. We need an additional step to convert to our Python type for the POD. + // Thankfully, the converter stores the specific type we created, so we can call + // the constructor with the tuple. + auto *podType = Conversions::getPythonTypeObject(converter); + if (!podType) { + Py_DECREF(value); + PyErr_SetString(PyExc_RuntimeError, "Failed to get Python type for POD"); + return nullptr; + } + PyObject *podValue = PyObject_CallObject(reinterpret_cast<PyObject *>(podType), value); + Py_DECREF(value); + if (!podValue) { + PyErr_SetString(PyExc_RuntimeError, "Failed to create POD instance"); + return nullptr; + } + return podValue; + } + if (metaType.flags().testFlag(QMetaType::IsEnumeration)) { + // Enums are converted to Python ints + auto *enumType = Conversions::getPythonTypeObject(converter); + if (!enumType) { + Py_DECREF(value); + PyErr_SetString(PyExc_RuntimeError, "Failed to get Python type for enum"); + return nullptr; + } + PyObject *enumValue = PyObject_CallFunctionObjArgs(reinterpret_cast<PyObject *>(enumType), + value, nullptr); + Py_DECREF(value); + if (!enumValue) { + PyErr_Print(); + PyErr_SetString(PyExc_RuntimeError, "Failed to create enum instance"); + return nullptr; + } + return enumValue; + } + return value; +} + + +/** + * @brief Creates and manages memory for Python enum types for each QEnum in the + * provided QMetaObject. + * + * This function iterates over the enumerators in the provided QMetaObject, + * creates corresponding Python enum types, and stores them in a dictionary. + * The dictionary is then set as an attribute ()"_enum_data") on the provided + * Python object, to be accessed by the _get_enum that has been added to each + * of our dynamic types. + * + * These are "managed" in the sense that the enums clean up their converters + * using our PyCapsule method, and by adding the dictionary as a Python attribute, + * the dictionary will be cleaned up when the containing type is garbage + * collected. + * + * @param self A pointer to the Python object where the enum data will be stored. + * @param meta A pointer to the QMetaObject containing the enumerators. + * @return Returns 0 on success, or -1 on failure. + */ +int create_managed_py_enums(PyObject *self, QMetaObject *meta) +{ + PyObject *enum_data = PyDict_New(); + for (int i = meta->enumeratorOffset(); i < meta->enumeratorCount(); ++i) { + auto metaEnum = meta->enumerator(i); + auto *enumType = createEnumType(&metaEnum); + if (!enumType) { + PyErr_Print(); + PyErr_Format(PyExc_RuntimeError, "Failed to create enum type for POD '%s'", + meta->className()); + return -1; + } + PyDict_SetItemString(enum_data, metaEnum.enumName(), + reinterpret_cast<PyObject *>(enumType)); + Py_DECREF(enumType); + } + if (PyObject_SetAttrString(self, "_enum_data", enum_data) < 0) { + PyErr_Print(); + qWarning() << "Failed to set _enum_data attribute on type" + << reinterpret_cast<PyTypeObject *>(self)->tp_name; + return -1; + } + Py_DECREF(enum_data); + + return 0; +} + +PyObject *DynamicType_get_enum(PyObject *self, PyObject *name) +{ + // Our enum types are always stored in a dictionary attribute named "_enum_data" + PyObject *enum_dict = PyObject_GetAttrString(self, "_enum_data"); + if (!enum_dict) { + PyErr_SetString(PyExc_RuntimeError, "Failed to get _enum_data attribute"); + return nullptr; + } + + PyObject *enum_type = PyDict_GetItem(enum_dict, name); + Py_DECREF(enum_dict); + + if (!enum_type) { + PyErr_Format(PyExc_KeyError, "Enum '%s' not found", String::toCString(name)); + return nullptr; + } + + Py_INCREF(enum_type); + return enum_type; +} diff --git a/sources/pyside6/libpysideremoteobjects/pysidedynamiccommon_p.h b/sources/pyside6/libpysideremoteobjects/pysidedynamiccommon_p.h new file mode 100644 index 000000000..1e9f8d55a --- /dev/null +++ b/sources/pyside6/libpysideremoteobjects/pysidedynamiccommon_p.h @@ -0,0 +1,89 @@ +// Copyright (C) 2025 Ford Motor Company +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only + +#ifndef PYSIDE_DYNAMIC_COMMON_P_H +#define PYSIDE_DYNAMIC_COMMON_P_H + +#include <sbkconverter.h> + +#include <QtCore/qlist.h> +#include <QtCore/qvariant.h> +#include <QtCore/qmetatype.h> + +PyObject *toPython(const QVariant &variant); +int create_managed_py_enums(PyObject *self, QMetaObject *meta); +PyObject *DynamicType_get_enum(PyObject *self, PyObject *name); + +// Data for dynamically created property handlers +struct PropertyCapsule +{ + QByteArray name; + int propertyIndex; // meta->indexOfProperty() - including offset + int indexInObject; // Index minus offset for indexing into QVariantList +}; + +// Data for dynamically created method handlers +struct MethodCapsule +{ + QByteArray name; + int methodIndex; + QList<QMetaType> argumentTypes; + QMetaType returnType; // meta->indexOfMethod() - including offset +}; + +// These functions are used to create a PyCapsule holding a pointer to a C++ +// object, which is set as an attribute on a Python type. When the Python +// type is garbage collected, the type's attributes are as well, resulting in +// the capsule's cleanup running to delete the pointer. This won't be as +// efficient as a custom tp_free on the type, but it's easier to manage. +// And it only runs when as all references to the type (and all instances) are +// released, so it won't be used frequently. + +static int capsule_count = 0; + +static PyObject *get_capsule_count() +{ + return PyLong_FromLong(capsule_count); +} + +template <typename T> +void Capsule_destructor(PyObject *capsule) +{ + capsule_count--; + T pointer = static_cast<T>(PyCapsule_GetPointer(capsule, nullptr)); + delete pointer; + pointer = nullptr; +} + +template <> +inline void Capsule_destructor<SbkConverter *>(PyObject *capsule) +{ + capsule_count--; + SbkConverter *pointer = static_cast<SbkConverter *>(PyCapsule_GetPointer(capsule, nullptr)); + Shiboken::Conversions::deleteConverter(pointer); + pointer = nullptr; +} + +template <typename T> +int set_cleanup_capsule_attr_for_pointer(PyTypeObject *type, const char *name, T pointer) +{ + static_assert(std::is_pointer<T>::value, "T must be a pointer type"); + + if (!pointer) { + PyErr_SetString(PyExc_RuntimeError, "Pointer is null"); + return -1; + } + auto capsule = PyCapsule_New(pointer, nullptr, Capsule_destructor<T>); + if (!capsule) + return -1; // Propagate the error + + if (PyObject_SetAttrString(reinterpret_cast<PyObject *>(type), name, capsule) < 0) + return -1; // Propagate the error + + Py_DECREF(capsule); + capsule_count++; + + return 0; +} + +#endif // PYSIDE_DYNAMIC_COMMON_P_H diff --git a/sources/pyside6/libpysideremoteobjects/pysidedynamicenum.cpp b/sources/pyside6/libpysideremoteobjects/pysidedynamicenum.cpp new file mode 100644 index 000000000..1f92224f5 --- /dev/null +++ b/sources/pyside6/libpysideremoteobjects/pysidedynamicenum.cpp @@ -0,0 +1,158 @@ +// Copyright (C) 2025 Ford Motor Company +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only + +#include "pysidedynamicenum_p.h" +#include "pysidedynamiccommon_p.h" + +#include <autodecref.h> +#include <sbkconverter.h> +#include <sbkenum.h> + +#include <QtCore/qmetaobject.h> + +using namespace Shiboken; + +// Remote Objects transfer enums as integers of the underlying type. +#define CREATE_ENUM_CONVERSION_FUNCTIONS(SUFFIX, INT_TYPE, PY_TYPE) \ +static void pythonToCpp_PyEnum_QEnum_##SUFFIX(PyObject *pyIn, void *cppOut) \ +{ \ + Enum::EnumValueType value = Enum::getValue(pyIn); \ + INT_TYPE val(value); \ + *reinterpret_cast<INT_TYPE *>(cppOut) = val; \ +} \ +static PythonToCppFunc is_PyEnum_PythonToCpp_QEnum_##SUFFIX##_Convertible(PyObject *pyIn) \ +{ \ + if (Enum::check(pyIn)) \ + return pythonToCpp_PyEnum_QEnum_##SUFFIX; \ + return {}; \ +} \ +static PyObject *cppToPython_QEnum_##SUFFIX##_PyEnum(const void *cppIn) \ +{ \ + auto convertedCppIn = *reinterpret_cast<const INT_TYPE *>(cppIn); \ + return PY_TYPE(convertedCppIn); \ +} + +CREATE_ENUM_CONVERSION_FUNCTIONS(I8, int8_t, PyLong_FromLong) +CREATE_ENUM_CONVERSION_FUNCTIONS(I16, int16_t, PyLong_FromLong) +CREATE_ENUM_CONVERSION_FUNCTIONS(I32, int32_t, PyLong_FromLong) +CREATE_ENUM_CONVERSION_FUNCTIONS(U8, uint8_t, PyLong_FromUnsignedLong) +CREATE_ENUM_CONVERSION_FUNCTIONS(U16, uint16_t, PyLong_FromUnsignedLong) +CREATE_ENUM_CONVERSION_FUNCTIONS(U32, uint32_t, PyLong_FromUnsignedLong) +CREATE_ENUM_CONVERSION_FUNCTIONS(I64, int64_t, PyLong_FromLongLong) +CREATE_ENUM_CONVERSION_FUNCTIONS(U64, uint64_t, PyLong_FromUnsignedLongLong) + +PyTypeObject *createEnumType(QMetaEnum *metaEnum) +{ + static const auto namePrefix = QByteArrayLiteral("2:PySide6.QtRemoteObjects.DynamicEnum."); + auto fullName = namePrefix + metaEnum->scope() + "." + metaEnum->enumName(); + + AutoDecRef args(PyList_New(0)); + auto *pyEnumItems = args.object(); + auto metaType = metaEnum->metaType(); + auto underlyingType = metaType.underlyingType(); + bool isUnsigned = underlyingType.flags().testFlag(QMetaType::IsUnsignedEnumeration); + for (int idx = 0; idx < metaEnum->keyCount(); ++idx) { + auto *key = PyUnicode_FromString(metaEnum->key(idx)); + auto *key_value = PyTuple_New(2); + PyTuple_SetItem(key_value, 0, key); + // Value should only return a nullopt if there is no metaObject or the index is not valid + auto valueOpt = metaEnum->value64(idx); + if (!valueOpt) { + PyErr_SetString(PyExc_RuntimeError, "Failed to get value64 from enum"); + return nullptr; + } + if (isUnsigned) { + auto *value = PyLong_FromUnsignedLongLong(*valueOpt); + PyTuple_SetItem(key_value, 1, value); + } else { + auto *value = PyLong_FromLongLong(*valueOpt); + PyTuple_SetItem(key_value, 1, value); + } + PyList_Append(pyEnumItems, key_value); + } + + PyTypeObject *newType{}; + if (metaEnum->isFlag()) + newType = Enum::createPythonEnum(fullName.constData(), pyEnumItems, "Flag"); + else + newType = Enum::createPythonEnum(fullName.constData(), pyEnumItems); + + SbkConverter *converter = nullptr; + switch (underlyingType.sizeOf()) { + case 1: + if (isUnsigned) { + converter = Conversions::createConverter(newType, + cppToPython_QEnum_U8_PyEnum); + Conversions::addPythonToCppValueConversion(converter, + pythonToCpp_PyEnum_QEnum_U8, + is_PyEnum_PythonToCpp_QEnum_U8_Convertible); + } else { + converter = Conversions::createConverter(newType, + cppToPython_QEnum_I8_PyEnum); + Conversions::addPythonToCppValueConversion(converter, + pythonToCpp_PyEnum_QEnum_I8, + is_PyEnum_PythonToCpp_QEnum_I8_Convertible); + } + break; + case 2: + if (isUnsigned) { + converter = Conversions::createConverter(newType, + cppToPython_QEnum_U16_PyEnum); + Conversions::addPythonToCppValueConversion(converter, + pythonToCpp_PyEnum_QEnum_U16, + is_PyEnum_PythonToCpp_QEnum_U16_Convertible); + } else { + converter = Conversions::createConverter(newType, + cppToPython_QEnum_I16_PyEnum); + Conversions::addPythonToCppValueConversion(converter, + pythonToCpp_PyEnum_QEnum_I16, + is_PyEnum_PythonToCpp_QEnum_I16_Convertible); + } + break; + case 4: + if (isUnsigned) { + converter = Conversions::createConverter(newType, + cppToPython_QEnum_U32_PyEnum); + Conversions::addPythonToCppValueConversion(converter, + pythonToCpp_PyEnum_QEnum_U32, + is_PyEnum_PythonToCpp_QEnum_U32_Convertible); + } else { + converter = Conversions::createConverter(newType, + cppToPython_QEnum_I32_PyEnum); + Conversions::addPythonToCppValueConversion(converter, + pythonToCpp_PyEnum_QEnum_I32, + is_PyEnum_PythonToCpp_QEnum_I32_Convertible); + } + break; + case 8: + if (isUnsigned) { + converter = Conversions::createConverter(newType, + cppToPython_QEnum_U64_PyEnum); + Conversions::addPythonToCppValueConversion(converter, + pythonToCpp_PyEnum_QEnum_U64, + is_PyEnum_PythonToCpp_QEnum_U64_Convertible); + } else { + converter = Conversions::createConverter(newType, + cppToPython_QEnum_I64_PyEnum); + Conversions::addPythonToCppValueConversion(converter, + pythonToCpp_PyEnum_QEnum_I64, + is_PyEnum_PythonToCpp_QEnum_I64_Convertible); + } + break; + default: + PyErr_SetString(PyExc_RuntimeError, "Unsupported enum underlying type"); + return nullptr; + } + auto scopedName = QByteArray(metaEnum->scope()) + "::" + metaEnum->enumName(); + Conversions::registerConverterName(converter, scopedName.constData()); + Conversions::registerConverterName(converter, metaEnum->enumName()); + // createConverter increases the ref count of type, but that will create a + // circular reference when we add the capsule with the converter's pointer + // to the type's attributes. So we need to decrease the ref count on the + // type after calling createConverter. + Py_DECREF(newType); + if (set_cleanup_capsule_attr_for_pointer(newType, "_converter_capsule", converter) < 0) + return nullptr; + + return newType; +} diff --git a/sources/pyside6/libpysideremoteobjects/pysidedynamicenum_p.h b/sources/pyside6/libpysideremoteobjects/pysidedynamicenum_p.h new file mode 100644 index 000000000..14181fac8 --- /dev/null +++ b/sources/pyside6/libpysideremoteobjects/pysidedynamicenum_p.h @@ -0,0 +1,15 @@ +// Copyright (C) 2025 Ford Motor Company +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only + +#ifndef PYSIDE_DYNAMIC_ENUM_P_H +#define PYSIDE_DYNAMIC_ENUM_P_H + +#include <sbkpython.h> + +#include <QtCore/qtclasshelpermacros.h> + +QT_FORWARD_DECLARE_CLASS(QMetaEnum) + +PyTypeObject *createEnumType(QMetaEnum *metaEnum); + +#endif // PYSIDE_DYNAMIC_ENUM_P_H diff --git a/sources/pyside6/libpysideremoteobjects/pysidedynamicpod.cpp b/sources/pyside6/libpysideremoteobjects/pysidedynamicpod.cpp new file mode 100644 index 000000000..abfeaa037 --- /dev/null +++ b/sources/pyside6/libpysideremoteobjects/pysidedynamicpod.cpp @@ -0,0 +1,260 @@ +// Copyright (C) 2025 Ford Motor Company +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only + +#include "pysidedynamicpod_p.h" +#include "pysidecapsulemethod_p.h" +#include "pysidedynamiccommon_p.h" + +#include <autodecref.h> +#include <helper.h> +#include <pep384ext.h> +#include <sbkconverter.h> +#include <sbkstaticstrings.h> +#include <sbkstring.h> + +#include <pysidestaticstrings.h> + +#include <QtCore/qmetaobject.h> + +using namespace Shiboken; + +extern "C" +{ + +struct PodDefs +{ + static PyObject *tp_new(PyTypeObject *type, PyObject *args, PyObject *kwds) + { + SBK_UNUSED(kwds); + AutoDecRef param_types(PyObject_GetAttrString(reinterpret_cast<PyObject *>(type), + "__param_types__")); + if (!param_types) { + PyErr_Format(PyExc_RuntimeError, "Failed to get POD attributes for type %s", + type->tp_name); + return nullptr; + } + + // param_types is a tuple of PyTypeObject pointers + Py_ssize_t size = PyTuple_Size(param_types); + if (size != PyTuple_Size(args)) { + PyErr_Format(PyExc_TypeError, + "Incorrect number of arguments for type %s. Expected %zd.", + type->tp_name, size); + return nullptr; + } + + PyObject *self = PepExt_Type_GetAllocSlot(type)(type, size); + + if (!self) + return nullptr; + + for (Py_ssize_t i = 0; i < size; ++i) { + PyObject *expected_type = PyTuple_GetItem(param_types, i); + PyObject *item = PyTuple_GetItem(args, i); + // Check if the item is an instance of the expected type + if (PyObject_IsInstance(item, expected_type)) { + Py_INCREF(item); + PyTuple_SetItem(self, i, item); + } else { + // Try to convert the item to the expected type + PyObject *converted_item = PyObject_CallFunctionObjArgs(expected_type, item, nullptr); + if (!converted_item) { + Py_DECREF(self); + PyErr_Format(PyExc_TypeError, "Argument %zd must be convertible to type %s", i, + reinterpret_cast<PyTypeObject *>(expected_type)->tp_name); + return nullptr; + } + PyTuple_SetItem(self, i, converted_item); + } + } + + return self; + } + + static PyObject *tp_repr(PyObject *self) + { + auto *type = Py_TYPE(self); + std::string repr(type->tp_name); + repr += "("; + for (Py_ssize_t i = 0; i < PyTuple_Size(self); ++i) { + if (i > 0) + repr += ", "; + + PyObject *item_repr = PyObject_Repr(PyTuple_GetItem(self, i)); + repr += String::toCString(item_repr); + } + repr += ")"; + return PyUnicode_FromString(repr.c_str()); + } + + static PyObject *CapsuleMethod_handler(PyObject *payload, PyObject * /* args */) + { + auto *methodData = reinterpret_cast<CapsuleDescriptorData *>( + PyCapsule_GetPointer(payload, "Payload")); + if (!methodData) { + PyErr_SetString(PyExc_RuntimeError, "Invalid call to dynamic method. Missing payload."); + return nullptr; + } + PyObject *self = methodData->self; + if (PyCapsule_IsValid(methodData->payload, "PropertyCapsule")) { + // Handle property getter/setter against our hidden properties attribute + auto *capsule = PyCapsule_GetPointer(methodData->payload, "PropertyCapsule"); + if (capsule) { + auto *callData = reinterpret_cast<PropertyCapsule *>(capsule); + if (callData->indexInObject < 0 || callData->indexInObject >= PyTuple_Size(self)) { + PyErr_Format(PyExc_RuntimeError, "Unknown property method: %s", + callData->name.constData()); + return nullptr; + } + auto *val = PyTuple_GetItem(self, callData->indexInObject); + Py_INCREF(val); + return val; + } + } + + PyErr_SetString(PyExc_RuntimeError, "Unknown capsule type"); + return nullptr; + } +}; + +static PyMethodDef DynamicPod_tp_methods[] = { + {"get_enum", reinterpret_cast<PyCFunction>(DynamicType_get_enum), METH_O | METH_CLASS, + "Get enum type by name"}, + {nullptr, nullptr, 0, nullptr} +}; + +static PyType_Slot DynamicPod_slots[] = { + {Py_tp_base, reinterpret_cast<void *>(&PyTuple_Type)}, + {Py_tp_new, reinterpret_cast<void *>(PodDefs::tp_new)}, + {Py_tp_repr, reinterpret_cast<void *>(PodDefs::tp_repr)}, + {Py_tp_methods, reinterpret_cast<void *>(DynamicPod_tp_methods)}, + {0, nullptr} +}; + +// C++ to Python conversion for POD types. +static PyObject *cppToPython_POD_Tuple(const void *cppIn) +{ + const auto &cppInRef = *reinterpret_cast<const QVariantList *>(cppIn); + PyObject *pyOut = PyTuple_New(Py_ssize_t(cppInRef.size())); + Py_ssize_t idx = 0; + for (auto it = std::cbegin(cppInRef), end = std::cend(cppInRef); it != end; ++it, ++idx) { + static const Conversions::SpecificConverter argConverter("QVariant"); + const auto &cppItem = *it; + PyTuple_SetItem(pyOut, idx, Shiboken::Conversions::copyToPython(argConverter, &cppItem)); + } + return pyOut; +} +static void pythonToCpp_Tuple_POD(PyObject *pyIn, void *cppOut) +{ + auto &cppOutRef = *reinterpret_cast<QVariantList *>(cppOut); + + Py_ssize_t tupleSize = PyTuple_Size(pyIn); + if (tupleSize != cppOutRef.size()) { + PyErr_Format(PyExc_ValueError, + "Size mismatch: tuple has %zd elements, but POD expects %d elements", + tupleSize, cppOutRef.size()); + return; + } + + for (Py_ssize_t i = 0; i < tupleSize; ++i) { + static const Conversions::SpecificConverter argConverter("QVariant"); + PyObject *item = PyTuple_GetItem(pyIn, i); + QVariant &variant = cppOutRef[i]; + Conversions::SpecificConverter converter(variant.metaType().name()); + Shiboken::Conversions::pythonToCppCopy(converter, item, variant.data()); + } +} +static PythonToCppFunc is_Tuple_PythonToCpp_POD_Convertible(PyObject *pyIn) +{ + if (PyTuple_Check(pyIn)) + return pythonToCpp_Tuple_POD; + + return {}; +} + +} // extern "C" + +PyTypeObject *createPodType(QMetaObject *meta) +{ + auto qualname = QByteArrayLiteral("DynamicPod.") + meta->className(); + PyType_Spec spec = { + qualname.constData(), + 0, + 0, + Py_TPFLAGS_DEFAULT | Py_TPFLAGS_TYPE_SUBCLASS, + DynamicPod_slots + }; + + PyObject *obType = PyType_FromSpec(&spec); + if (!obType) + return nullptr; + + if (create_managed_py_enums(obType, meta) < 0) + return nullptr; + + Py_ssize_t size = meta->propertyCount() - meta->propertyOffset(); + AutoDecRef pyParamTypes(PyTuple_New(size)); + for (int i = 0; i < size; ++i) { + auto metaProperty = meta->property(i + meta->propertyOffset()); + auto metaType = metaProperty.metaType(); + if (!metaType.isValid()) { + PyErr_Format(PyExc_RuntimeError, "Failed to get meta type for property %s", + metaProperty.name()); + return nullptr; + } + auto *pyType = Conversions::getPythonTypeObject(metaType.name()); + Py_INCREF(pyType); + PyTuple_SetItem(pyParamTypes, i, reinterpret_cast<PyObject *>(pyType)); + } + + auto *type = reinterpret_cast<PyTypeObject *>(obType); + PyMethodDef method = { + nullptr, + reinterpret_cast<PyCFunction>(PodDefs::CapsuleMethod_handler), + METH_VARARGS, + nullptr + }; + for (int i = meta->propertyOffset(); i < meta->propertyCount(); ++i) { + // Create a PropertyCapsule for each property to store the info needed + // for the handler. + auto metaProperty = meta->property(i); + + method.ml_name = metaProperty.name(); + auto *capsule = PyCapsule_New(new PropertyCapsule{metaProperty.name(), + i, + i - meta->propertyOffset()}, + "PropertyCapsule", + [](PyObject *capsule) { + delete static_cast<PropertyCapsule *>( + PyCapsule_GetPointer(capsule, "PropertyCapsule")); + }); + auto *capsulePropObject = make_capsule_property(&method, capsule); + if (PyObject_SetAttrString(reinterpret_cast<PyObject *>(type), metaProperty.name(), + capsulePropObject) < 0) { + return nullptr; + } + + Py_DECREF(capsulePropObject); + } + + // createConverter increases the ref count of type, but that will create + // a circular reference. When we add the capsule with the converter's pointer + // to the type's attributes. So we need to decrease the ref count on the type + // after calling createConverter. + auto *converter = Shiboken::Conversions::createConverter(type, cppToPython_POD_Tuple); + Py_DECREF(type); + if (set_cleanup_capsule_attr_for_pointer(type, "_converter_capsule", converter) < 0) + return nullptr; + Shiboken::Conversions::registerConverterName(converter, meta->className()); + Shiboken::Conversions::registerConverterName(converter, type->tp_name); + Shiboken::Conversions::addPythonToCppValueConversion(converter, pythonToCpp_Tuple_POD, + is_Tuple_PythonToCpp_POD_Convertible); + + static PyObject *const module = String::createStaticString("PySide6.QtRemoteObjects"); + AutoDecRef pyQualname(String::fromCString(qualname.constData())); + PyObject_SetAttr(obType, PyMagicName::qualname(), pyQualname); + PyObject_SetAttr(obType, PyMagicName::module(), module); + PyObject_SetAttrString(obType, "__param_types__", pyParamTypes); + + return type; +} diff --git a/sources/pyside6/libpysideremoteobjects/pysidedynamicpod_p.h b/sources/pyside6/libpysideremoteobjects/pysidedynamicpod_p.h new file mode 100644 index 000000000..6dc9db9dd --- /dev/null +++ b/sources/pyside6/libpysideremoteobjects/pysidedynamicpod_p.h @@ -0,0 +1,15 @@ +// Copyright (C) 2025 Ford Motor Company +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only + +#ifndef PYSIDE_DYNAMIC_POD_P_H +#define PYSIDE_DYNAMIC_POD_P_H + +#include <sbkpython.h> + +#include <QtCore/qtclasshelpermacros.h> + +QT_FORWARD_DECLARE_STRUCT(QMetaObject) + +PyTypeObject *createPodType(QMetaObject *meta); + +#endif // PYSIDE_DYNAMIC_POD_P_H diff --git a/sources/pyside6/libpysideremoteobjects/pysideremoteobjects.h b/sources/pyside6/libpysideremoteobjects/pysideremoteobjects.h new file mode 100644 index 000000000..e0828c960 --- /dev/null +++ b/sources/pyside6/libpysideremoteobjects/pysideremoteobjects.h @@ -0,0 +1,16 @@ +// Copyright (C) 2025 Ford Motor Company +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only + +#ifndef PYSIDEREMOTEOBJECTS_H +#define PYSIDEREMOTEOBJECTS_H + +#include <sbkpython.h> + +namespace PySide::RemoteObjects +{ + +void init(PyObject *module); + +} // namespace PySide::RemoteObjects + +#endif // PYSIDEREMOTEOBJECTS_H diff --git a/sources/pyside6/libpysideremoteobjects/pysiderephandler.cpp b/sources/pyside6/libpysideremoteobjects/pysiderephandler.cpp new file mode 100644 index 000000000..25bdbef9b --- /dev/null +++ b/sources/pyside6/libpysideremoteobjects/pysiderephandler.cpp @@ -0,0 +1,459 @@ +// Copyright (C) 2025 Ford Motor Company +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only + +#include "pysiderephandler_p.h" +#include "pysidedynamicclass_p.h" +#include "pysidedynamicpod_p.h" +#include "pysidedynamiccommon_p.h" + +#include <pep384ext.h> +#include <sbkstring.h> +#include <sbktypefactory.h> +#include <signature.h> + +#include <pysideutils.h> + +#include <QtCore/qbuffer.h> +#include <QtCore/qiodevice.h> +#include <QtCore/qmetaobject.h> + +#include <QtRemoteObjects/qremoteobjectreplica.h> +#include <QtRemoteObjects/qremoteobjectpendingcall.h> + +#include <private/qremoteobjectrepparser_p.h> + +using namespace Qt::StringLiterals; +using namespace Shiboken; + +/** + * @file pysiderephandler.cpp + * @brief This file contains the implementation of the PySideRepFile type and its + * associated methods for handling Qt Remote Objects in PySide6. + * + * The PySideRepFile type provides functionality to parse and handle Qt Remote Objects + * (QtRO) files, and dynamically generate Python types for QtRO sources, replicas, and + * PODs (Plain Old Data structures). + * + * The RepFile_tp_methods array defines the methods available on the PySideRepFile object: + * - source: Generates a dynamic Python type for a QtRO source class. + * - replica: Generates a dynamic Python type for a QtRO replica class. + * - pod: Generates a dynamic Python type for a QtRO POD class. + * + * When generating a source or replica type, the generateDynamicType function is + * used, creating a new Python type based on the generated QMetaObject, and adds + * method descriptors for the required methods. A QVariantList for the types + * properties is also created, populated with default values if set in the input + * .rep file. +*/ + +static QVariantList generateProperties(QMetaObject *meta, const ASTClass &astClass); + +extern "C" +{ + +// Code for the PySideRepFile type +static PyObject *RepFile_tp_string(PyObject *self); +static PyObject *RepFile_tp_new(PyTypeObject *subtype, PyObject *args, PyObject *kwds); +static int RepFile_tp_init(PyObject *self, PyObject *args, PyObject *kwds); +static void RepFile_tp_free(void *self); +static void RepFile_tp_dealloc(PySideRepFile *self); + +static PyObject *RepFile_get_pods(PySideRepFile *self, void * /*unused*/); +static PyObject *RepFile_get_replicas(PySideRepFile *self, void * /*unused*/); +static PyObject *RepFile_get_sources(PySideRepFile *self, void * /*unused*/); + +bool instantiateFromDefaultValue(QVariant &variant, const QString &defaultValue); + +static PyObject *cppToPython_POD_Tuple(const void *cppIn); +static void pythonToCpp_Tuple_POD(PyObject *pyIn, void *cppOut); +static PythonToCppFunc is_Tuple_PythonToCpp_POD_Convertible(PyObject *pyIn); + +static PyGetSetDef RepFile_tp_getters[] = { + {"pod", reinterpret_cast<getter>(RepFile_get_pods), nullptr, "POD dictionary", nullptr}, + {"replica", reinterpret_cast<getter>(RepFile_get_replicas), nullptr, "Replica dictionary", nullptr}, + {"source", reinterpret_cast<getter>(RepFile_get_sources), nullptr, "Source dictionary", nullptr}, + {nullptr, nullptr, nullptr, nullptr, nullptr} // Sentinel +}; + +static PyTypeObject *createRepFileType() +{ + PyType_Slot PySideRepFileType_slots[] = { + {Py_tp_str, reinterpret_cast<void *>(RepFile_tp_string)}, + {Py_tp_init, reinterpret_cast<void *>(RepFile_tp_init)}, + {Py_tp_new, reinterpret_cast<void *>(RepFile_tp_new)}, + {Py_tp_free, reinterpret_cast<void *>(RepFile_tp_free)}, + {Py_tp_dealloc, reinterpret_cast<void *>(RepFile_tp_dealloc)}, + {Py_tp_getset, reinterpret_cast<void *>(RepFile_tp_getters)}, + {0, nullptr} + }; + + PyType_Spec PySideRepFileType_spec = { + "2:PySide6.QtRemoteObjects.RepFile", + sizeof(PySideRepFile), + 0, + Py_TPFLAGS_DEFAULT, + PySideRepFileType_slots}; + return SbkType_FromSpec(&PySideRepFileType_spec); +} + +PyTypeObject *PySideRepFile_TypeF(void) +{ + static auto *type = createRepFileType(); + return type; +} + +static PyObject *RepFile_tp_string(PyObject *self) +{ + auto *cppSelf = reinterpret_cast<PySideRepFile *>(self); + QString result = QStringLiteral("RepFile(Classes: [%1], PODs: [%2])") + .arg(cppSelf->d->classes.join(", "_L1), cppSelf->d->pods.join(", "_L1)); + return PyUnicode_FromString(result.toUtf8().constData()); +} + +static PyObject *RepFile_tp_new(PyTypeObject *subtype, PyObject * /* args */, PyObject * /* kwds */) +{ + auto *me = PepExt_TypeCallAlloc<PySideRepFile>(subtype, 0); + auto *priv = new PySideRepFilePrivate; + priv->podDict = PyDict_New(); + if (!priv->podDict) { + delete priv; + return nullptr; + } + priv->replicaDict = PyDict_New(); + if (!priv->replicaDict) { + Py_DECREF(priv->podDict); + delete priv; + return nullptr; + } + priv->sourceDict = PyDict_New(); + if (!priv->sourceDict) { + Py_DECREF(priv->podDict); + Py_DECREF(priv->replicaDict); + delete priv; + return nullptr; + } + me->d = priv; + return reinterpret_cast<PyObject *>(me); +} + +static PyObject *RepFile_get_pods(PySideRepFile *self, void * /* closure */) +{ + Py_INCREF(self->d->podDict); + return self->d->podDict; +} + +static PyObject *RepFile_get_replicas(PySideRepFile *self, void * /* closure */) +{ + Py_INCREF(self->d->replicaDict); + return self->d->replicaDict; +} + +static PyObject *RepFile_get_sources(PySideRepFile *self, void * /* closure */) +{ + Py_INCREF(self->d->sourceDict); + return self->d->sourceDict; +} + +static void RepFile_tp_dealloc(PySideRepFile *self) +{ + Py_XDECREF(self->d->podDict); + Py_XDECREF(self->d->replicaDict); + Py_XDECREF(self->d->sourceDict); + PepExt_TypeCallFree(reinterpret_cast<PyObject *>(self)); +} + +static int parseArgsToAST(PyObject *args, PySideRepFile *repFile) +{ + // Verify args is a single string argument + if (PyTuple_Size(args) != 1 || !PyUnicode_Check(PyTuple_GetItem(args, 0))) { + PyErr_SetString(PyExc_TypeError, "RepFile constructor requires a single string argument"); + return -1; + } + + // Wrap contents into a QBuffer + const auto contents = PySide::pyStringToQString(PyTuple_GetItem(args, 0)); + auto byteArray = contents.toUtf8(); + QBuffer buffer(&byteArray); + buffer.open(QIODevice::ReadOnly); + RepParser repparser(buffer); + if (!repparser.parse()) { + PyErr_Format(PyExc_RuntimeError, "Error parsing input, line %d: error: %s", + repparser.lineNumber(), qPrintable(repparser.errorString())); + auto lines = contents.split("\n"_L1); + auto lMin = std::max(1, repparser.lineNumber() - 2); + auto lMax = std::min(repparser.lineNumber() + 2, int(lines.size())); + // Print a few lines around the error + qWarning() << "Contents:"; + for (int i = lMin; i <= lMax; ++i) { + if (i == repparser.lineNumber()) + qWarning().nospace() << " line " << i << ": > " << lines.at(i - 1); + else + qWarning().nospace() << " line " << i << ": " << lines.at(i - 1); + } + return -1; + } + + repFile->d->ast = repparser.ast(); + + return 0; +} + +static const char *repName(QMetaObject *meta) +{ + const int ind = meta->indexOfClassInfo(QCLASSINFO_REMOTEOBJECT_TYPE); + return ind >= 0 ? meta->classInfo(ind).value() : "<Invalid RemoteObject>"; +} + +static int RepFile_tp_init(PyObject *self, PyObject *args, PyObject * /* kwds */) +{ + auto *cppSelf = reinterpret_cast<PySideRepFile *>(self); + if (parseArgsToAST(args, cppSelf) < 0) + return -1; + + for (const auto &pod : std::as_const(cppSelf->d->ast.pods)) { + cppSelf->d->pods << pod.name; + auto *qobject = new QObject; + auto *meta = createAndRegisterMetaTypeFromPOD(pod, qobject); + if (!meta) { + delete qobject; + PyErr_Format(PyExc_RuntimeError, "Failed to create meta object for POD '%s'", + pod.name.toUtf8().constData()); + return -1; + } + + PyTypeObject *newType = createPodType(meta); + if (!newType) { + delete qobject; + PyErr_Print(); + PyErr_Format(PyExc_RuntimeError, "Failed to create POD type for POD '%s'", + pod.name.toUtf8().constData()); + return -1; + } + if (set_cleanup_capsule_attr_for_pointer(newType, "_qobject_capsule", qobject) < 0) { + delete qobject; + return -1; + } + + PyDict_SetItemString(cppSelf->d->podDict, meta->className(), + reinterpret_cast<PyObject *>(newType)); + Py_DECREF(newType); + } + + if (PyErr_Occurred()) + PyErr_Print(); + + for (const auto &cls : std::as_const(cppSelf->d->ast.classes)) { + cppSelf->d->classes << cls.name; + + // Create Source type + { + auto *qobject = new QObject; + auto *meta = createAndRegisterSourceFromASTClass(cls, qobject); + if (!meta) { + delete qobject; + PyErr_Format(PyExc_RuntimeError, "Failed to create Source meta object for class '%s'", + cls.name.toUtf8().constData()); + return -1; + } + + auto properties = generateProperties(meta, cls); + // Check if an error occurred during generateProperties + if (PyErr_Occurred()) { + delete qobject; + return -1; + } + auto *propertiesPtr = new QVariantList(properties); + auto *pyCapsule = PyCapsule_New(propertiesPtr, nullptr, [](PyObject *capsule) { + delete reinterpret_cast<QVariantList *>(PyCapsule_GetPointer(capsule, nullptr)); + }); + + PyTypeObject *newType = createDynamicClass(meta, pyCapsule); + if (!newType) { + delete qobject; + PyErr_Format(PyExc_RuntimeError, + "Failed to create Source Python type for class '%s'", + meta->className()); + return -1; + } + if (set_cleanup_capsule_attr_for_pointer(newType, "_qobject_capsule", qobject) < 0) { + delete qobject; + return -1; + } + + PyDict_SetItemString(cppSelf->d->sourceDict, repName(meta), + reinterpret_cast<PyObject *>(newType)); + Py_DECREF(newType); + } + + // Create Replica type + { + auto *qobject = new QObject; + auto *meta = createAndRegisterReplicaFromASTClass(cls, qobject); + if (!meta) { + delete qobject; + PyErr_Format(PyExc_RuntimeError, + "Failed to create Replica meta object for class '%s'", + qPrintable(cls.name)); + return -1; + } + + auto properties = generateProperties(meta, cls); + // Check if an error occurred during generateProperties + if (PyErr_Occurred()) { + delete qobject; + return -1; + } + auto *propertiesPtr = new QVariantList(properties); + auto *pyCapsule = PyCapsule_New(propertiesPtr, nullptr, [](PyObject *capsule) { + delete reinterpret_cast<QVariantList *>(PyCapsule_GetPointer(capsule, nullptr)); + }); + + PyTypeObject *newType = createDynamicClass(meta, pyCapsule); + if (!newType) { + delete qobject; + PyErr_Format(PyExc_RuntimeError, + "Failed to create Replica Python type for class '%s'", + meta->className()); + return -1; + } + if (set_cleanup_capsule_attr_for_pointer(newType, "_qobject_capsule", qobject) < 0) { + delete qobject; + return -1; + } + + PyDict_SetItemString(cppSelf->d->replicaDict, repName(meta), + reinterpret_cast<PyObject *>(newType)); + Py_DECREF(newType); + } + } + + return 0; +} + +static void RepFile_tp_free(void *self) +{ + PySideRepFile *obj = reinterpret_cast<PySideRepFile*>(self); + delete obj->d; +} + +/** + * @brief Sets the QVariant value based on the provided default value text. + * + * This function attempts to set the provided QVariant's value based on the + * provided text. It evaluates the text as a Python expression, the the python + * type associated with the provided QMetaType. It first retrieves the Python + * type object corresponding to the given QMetaType, then constructs a Python + * expression to instantiate the type with the default value. The expression is + * evaluated using PyRun_String, and the result is then set on the QVariant. + * Note: The variant is passed by reference and modified in place. + * + * @return True if the instantiation is successful, false otherwise. + */ +bool instantiateFromDefaultValue(QVariant &variant, const QString &defaultValue) +{ + auto metaType = variant.metaType(); + auto *pyType = Shiboken::Conversions::getPythonTypeObject(metaType.name()); + if (!pyType) { + PyErr_Format(PyExc_TypeError, "Failed to find Python type for meta type: %s", + metaType.name()); + return false; + } + + // Evaluate the code + static PyObject *pyLocals = PyDict_New(); + + // Create the Python expression to evaluate + std::string code = std::string(pyType->tp_name) + '(' + + defaultValue.toUtf8().constData() + ')'; + PyObject *pyResult = PyRun_String(code.c_str(), Py_eval_input, pyLocals, pyLocals); + + if (!pyResult) { + PyObject *ptype = nullptr; + PyObject *pvalue = nullptr; + PyObject *ptraceback = nullptr; + PyErr_Fetch(&ptype, &pvalue, &ptraceback); + PyErr_NormalizeException(&ptype, &pvalue, &ptraceback); + PyErr_Format(PyExc_TypeError, + "Failed to generate default value. Error: %s. Problematic code: %s", + Shiboken::String::toCString(PyObject_Str(pvalue)), code.c_str()); + Py_XDECREF(ptype); + Py_XDECREF(pvalue); + Py_XDECREF(ptraceback); + Py_DECREF(pyLocals); + return false; + } + + Conversions::SpecificConverter converter(metaType.name()); + if (!converter) { + PyErr_Format(PyExc_TypeError, "Failed to find converter from Python type: %s to Qt type: %s", + pyResult->ob_type->tp_name, metaType.name()); + Py_DECREF(pyResult); + return false; + } + converter.toCpp(pyResult, variant.data()); + Py_DECREF(pyResult); + + return true; +} + +} // extern "C" + +static QVariantList generateProperties(QMetaObject *meta, const ASTClass &astClass) +{ + QVariantList properties; + auto propertyCount = astClass.properties.size(); + properties.reserve(propertyCount); + for (auto i = 0; i < propertyCount; ++i) { + auto j = i + meta->propertyOffset(); // Corresponding property index in the meta object + auto metaProperty = meta->property(j); + auto metaType = metaProperty.metaType(); + if (!metaType.isValid()) { + PyErr_Format(PyExc_RuntimeError, "Invalid meta type for property %d: %s", i, + astClass.properties[i].type.toUtf8().constData()); + return {}; + } + auto variant = QVariant(metaType); + if (auto defaultValue = astClass.properties[i].defaultValue; !defaultValue.isEmpty()) { + auto success = instantiateFromDefaultValue(variant, defaultValue); + if (!success) { + // Print a warning giving the property name, then propagate the error + qWarning() << "Failed to instantiate default value for property: " + << metaProperty.name(); + return {}; + } + } + properties << variant; + } + return properties; +} + +namespace PySide::RemoteObjects +{ + +static const char *RepFile_SignatureStrings[] = { + "PySide6.RemoteObjects.RepFile(self,content:str)", + nullptr}; // Sentinel + +void init(PyObject *module) +{ + if (InitSignatureStrings(PySideRepFile_TypeF(), RepFile_SignatureStrings) < 0) + return; + + qRegisterMetaType<QRemoteObjectPendingCall>(); + qRegisterMetaType<QRemoteObjectPendingCallWatcher>(); + + Py_INCREF(PySideRepFile_TypeF()); + PyModule_AddObject(module, "RepFile", reinterpret_cast<PyObject *>(PySideRepFile_TypeF())); + + // Add a test helper to verify type reference counting + static PyMethodDef get_capsule_count_def = { + "getCapsuleCount", // name of the function in Python + reinterpret_cast<PyCFunction>(get_capsule_count), // C function pointer + METH_NOARGS, // flags indicating parameters + "Returns the current count of PyCapsule objects" // docstring + }; + + PyModule_AddObject(module, "getCapsuleCount", PyCFunction_New(&get_capsule_count_def, nullptr)); +} + +} // namespace PySide::RemoteObjects diff --git a/sources/pyside6/libpysideremoteobjects/pysiderephandler_p.h b/sources/pyside6/libpysideremoteobjects/pysiderephandler_p.h new file mode 100644 index 000000000..5956f0b49 --- /dev/null +++ b/sources/pyside6/libpysideremoteobjects/pysiderephandler_p.h @@ -0,0 +1,35 @@ +// Copyright (C) 2025 Ford Motor Company +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only + +#ifndef PYSIDE_REPHANDLER_P_H +#define PYSIDE_REPHANDLER_P_H + +#include <sbkpython.h> + +#include <QtRemoteObjects/repparser.h> + +#include <QtCore/qstringlist.h> + +struct PySideRepFilePrivate +{ + AST ast; + PyObject *podDict{}; + PyObject *replicaDict{}; + PyObject *sourceDict{}; + QStringList classes; + QStringList pods; +}; + +extern "C" +{ + extern PyTypeObject *PySideRepFile_TypeF(void); + + // Internal object + struct PySideRepFile + { + PyObject_HEAD + PySideRepFilePrivate *d; + }; +}; // extern "C" + +#endif // PYSIDE_REPHANDLER_P_H diff --git a/sources/pyside6/tests/QtRemoteObjects/CMakeLists.txt b/sources/pyside6/tests/QtRemoteObjects/CMakeLists.txt index 2f7cb08b9..ace1a00fa 100644 --- a/sources/pyside6/tests/QtRemoteObjects/CMakeLists.txt +++ b/sources/pyside6/tests/QtRemoteObjects/CMakeLists.txt @@ -1 +1,11 @@ -# Please add some tests, here +# Copyright (C) 2025 Ford Motor Company +# SPDX-License-Identifier: BSD-3-Clause + +# FIXME: TypeError: Failed to generate default value. Error: name 'int' is not defined. Problematic code: int(2) +if(NOT APPLE) +PYSIDE_TEST(repfile_test.py) +PYSIDE_TEST(dynamic_types_test.py) +PYSIDE_TEST(integration_test.py) + +add_subdirectory(cpp_interop) +endif() diff --git a/sources/pyside6/tests/QtRemoteObjects/cpp_interop/CMakeLists.txt b/sources/pyside6/tests/QtRemoteObjects/cpp_interop/CMakeLists.txt new file mode 100644 index 000000000..407a8f874 --- /dev/null +++ b/sources/pyside6/tests/QtRemoteObjects/cpp_interop/CMakeLists.txt @@ -0,0 +1,25 @@ +# Copyright (C) 2024 The Qt Company Ltd. +# SPDX-License-Identifier: BSD-3-Clause + +set(CMAKE_INCLUDE_CURRENT_DIR ON) + +find_package(Qt6 REQUIRED COMPONENTS Core RemoteObjects) + +add_executable(cpp_interop ${MOC_SOURCES} cpp_interop.cpp) +set_target_properties(cpp_interop PROPERTIES AUTOMOC ON) + +target_link_libraries(cpp_interop PUBLIC + Qt6::Core + Qt6::RemoteObjects +) + +# Add a custom target to build the C++ program +add_custom_target(build_cpp_interop + COMMAND ${CMAKE_COMMAND} --build ${CMAKE_BINARY_DIR} --target cpp_interop + WORKING_DIRECTORY ${CMAKE_BINARY_DIR} +) + +# Exclude windows (see cpp_interop.cpp) +if(NOT WIN32) + PYSIDE_TEST(cpp_interop_test.py) +endif()
\ No newline at end of file diff --git a/sources/pyside6/tests/QtRemoteObjects/cpp_interop/cpp_interop.cpp b/sources/pyside6/tests/QtRemoteObjects/cpp_interop/cpp_interop.cpp new file mode 100644 index 000000000..6aeef91dd --- /dev/null +++ b/sources/pyside6/tests/QtRemoteObjects/cpp_interop/cpp_interop.cpp @@ -0,0 +1,127 @@ +// Copyright (C) 2025 Ford Motor Company +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only + +#include <QtCore/qcoreapplication.h> +#include <QtCore/qsocketnotifier.h> +#include <QtCore/qtimer.h> + +#include <QtRemoteObjects/qremoteobjectreplica.h> +#include <QtRemoteObjects/qremoteobjectnode.h> + +#ifdef Q_OS_WIN +# include <QtCore/qt_windows.h> +# include <QtCore/qwineventnotifier.h> +#endif // Q_OS_WIN + +#include <iostream> + +using namespace Qt::StringLiterals; + +class CommandReader : public QObject +{ + Q_OBJECT +public: + explicit CommandReader(QObject *parent = nullptr) : QObject(parent) + { +#ifndef Q_OS_WIN + auto *notifier = new QSocketNotifier(fileno(stdin), QSocketNotifier::Read, this); + connect(notifier, &QSocketNotifier::activated, this, &CommandReader::handleInput); +#else + // FIXME: Does not work, signals triggers too often, the app is stuck in getline() + auto notifier = new QWinEventNotifier(GetStdHandle(STD_INPUT_HANDLE), this); + connect(notifier, &QWinEventNotifier::activated, this, &CommandReader::handleInput); +#endif + } + +signals: + void started(); + +private slots: + void handleInput() + { + std::string line; + if (!std::getline(std::cin, line)) + return; + + if (line == "quit") { + std::cerr << "harness: Received quit. Stopping harness event loop.\n"; + QCoreApplication::quit(); + } else if (line == "start") { + std::cerr << "harness: Received start. Initializing harness nodes.\n"; + emit started(); + } else { + std::cerr << "harness: Unknown command \"" << line << "\"\n"; + } + } +}; + +class Runner : public QObject +{ + Q_OBJECT +public: + Runner(const QUrl &url, const QString &repName, QObject *parent = nullptr) + : QObject(parent) + , m_url(url) + , m_repName(repName) + { + m_host.setObjectName("cpp_host"); + if (!m_host.setHostUrl(QUrl("tcp://127.0.0.1:0"_L1))) { + qWarning() << "harness: setHostUrl failed: " << m_host.lastError() << m_host.hostUrl(); + std::cerr << "harness: Fatal harness error.\n"; + QCoreApplication::exit(-2); + } + + m_node.setObjectName("cpp_node"); + std::cerr << "harness: Host url:" << m_host.hostUrl().toEncoded().constData() << '\n'; + } + +public slots: + void onStart() + { + m_node.connectToNode(m_url); + m_replica.reset(m_node.acquireDynamic(m_repName)); + if (!m_replica->waitForSource(1000)) { + std::cerr << "harness: Failed to acquire replica.\n"; + QCoreApplication::exit(-1); + } + + m_host.enableRemoting(m_replica.get()); + } + +private: + QUrl m_url; + QString m_repName; + QRemoteObjectHost m_host; + QRemoteObjectNode m_node; + std::unique_ptr<QRemoteObjectDynamicReplica> m_replica; +}; + +int main(int argc, char *argv[]) +{ + QCoreApplication a(argc, argv); + if (argc < 3) { + std::cerr << "Usage: " << argv[0] << " <url> <name of type>\n"; + return -1; + } + QUrl url = QUrl::fromUserInput(QString::fromUtf8(argv[1])); + QString repName = QString::fromUtf8(argv[2]); + + if (!url.isValid()) { + std::cerr << "Invalid URL: " << argv[1] << '\n'; + return -1; + } + + CommandReader reader; + Runner runner(url, repName); + + + QRemoteObjectNode node; + node.setObjectName("cpp_node"); + std::unique_ptr<QRemoteObjectDynamicReplica> replica; + + QObject::connect(&reader, &CommandReader::started, &runner, &Runner::onStart); + + return QCoreApplication::exec(); +} + +#include "cpp_interop.moc" diff --git a/sources/pyside6/tests/QtRemoteObjects/cpp_interop/cpp_interop_test.py b/sources/pyside6/tests/QtRemoteObjects/cpp_interop/cpp_interop_test.py new file mode 100644 index 000000000..d9ab60c23 --- /dev/null +++ b/sources/pyside6/tests/QtRemoteObjects/cpp_interop/cpp_interop_test.py @@ -0,0 +1,189 @@ +#!/usr/bin/python +# Copyright (C) 2025 Ford Motor Company +# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0 +from __future__ import annotations + +'''Verify Python <--> C++ interop''' + +import os +import sys +import textwrap + +import unittest + +from pathlib import Path +sys.path.append(os.fspath(Path(__file__).resolve().parents[2])) # For init_paths +from init_paths import init_test_paths +init_test_paths(False) + +from PySide6.QtCore import QUrl, QProcess, QObject, Signal +from PySide6.QtRemoteObjects import (QRemoteObjectHost, QRemoteObjectNode, QRemoteObjectReplica, + RepFile) +from PySide6.QtTest import QSignalSpy, QTest + +sys.path.append(os.fspath(Path(__file__).resolve().parents[1])) # For wrap_tests_for_cleanup +from test_shared import wrap_tests_for_cleanup +from helper.usesqapplication import UsesQApplication + + +""" +The previous tests all verify Remote Objects integration, but only +using Python for both Source and Replica. We need to make sure there +aren't any surprises in the interplay between Python and C++. + +This implements an initial test harness with a C++ app that is +started by the Python unittest. We leverage the fact that Remote +Objects can +1) Allow remoting any QObject as a Source with enableRemoting +2) Acquire Dynamic Replicas, where the definition needed for the + Replica is sent from the source. + +With these, we can create a working C++ app that doesn't need to be +compiled with any information about the types being used. We have +a host node in Python that shares a class derived from a RepFile +Source type. The address of this node is passed to the C++ app via +QProcess, and there a C++ node connects to that address to acquire +(dynamically) a replica of the desired object. + +The C++ code also creates a host node and sends the address/port +back to Python via the QProcess interface. Once the Python code +receives the C++ side address and port, it connects a node to that +URL and acquires the RepFile based type from Python. + +Python C++ +Host -----> Node (Dynamic acquire) + | + | Once initialized, the dynamic replica is + | shared (enable_remoting) from the C++ Host + | +Node <----- Host +""" + + +def msg_cannot_start(process, executable): + return ('Cannot start "' + executable + '" in "' + + os.fspath(Path.cwd()) + '": ' + process.errorString()) + + +def stop_process(process): + result = process.waitForFinished(2000) + if not result: + process.kill() + result = process.waitForFinished(2000) + return result + + +class Controller(QObject): + ready = Signal() + + def __init__(self, utest: unittest.TestCase): + super().__init__() + # Store utest so we can make assertions + self.utest = utest + + # Set up nodes + self.host = QRemoteObjectHost() + self.host.setObjectName("py_host") + self.host.setHostUrl(QUrl("tcp://127.0.0.1:0")) + self.cpp_url = None + self.node = QRemoteObjectNode() + self.node.setObjectName("py_node") + self._executable = "cpp_interop.exe" if os.name == "nt" else "./cpp_interop" + + def start(self): + # Start the C++ application + self.process = QProcess() + self.process.readyReadStandardOutput.connect(self.process_harness_output) + self.process.readyReadStandardError.connect(self.process_harness_output) + urls = self.host.hostUrl().toDisplayString() + print(f'Starting C++ application "{self._executable}" "{urls}"', file=sys.stderr) + self.process.start(self._executable, [self.host.hostUrl().toDisplayString(), "Simple"]) + self.utest.assertTrue(self.process.waitForStarted(2000), + msg_cannot_start(self.process, self._executable)) + + # Wait for the C++ application to output the host url + spy = QSignalSpy(self.ready) + self.utest.assertTrue(spy.wait(1000)) + self.utest.assertTrue(self.cpp_url.isValid()) + + self.utest.assertTrue(self.node.connectToNode(self.cpp_url)) + return True + + def stop(self): + if self.process.state() == QProcess.ProcessState.Running: + print(f'Stopping C++ application "{self._executable}" {self.process.processId()}', + file=sys.stderr) + self.process.write("quit\n".encode()) + self.process.closeWriteChannel() + self.utest.assertTrue(stop_process(self.process)) + self.utest.assertEqual(self.process.exitStatus(), QProcess.ExitStatus.NormalExit) + + def add_source(self, Source, Replica): + """ + Source and Replica are types. + + Replica is from the rep file + Source is a class derived from the rep file's Source type + """ + self.process.write("start\n".encode()) + source = Source() + self.host.enableRemoting(source) + replica = self.node.acquire(Replica) + self.utest.assertTrue(replica.waitForSource(5000)) + self.utest.assertEqual(replica.state(), QRemoteObjectReplica.State.Valid) + return source, replica + + def process_harness_output(self): + '''Process stderr from the C++ application''' + output = self.process.readAllStandardError().trimmed() + lines = output.data().decode().split("\n") + HOST_LINE = "harness: Host url:" + for line in lines: + print(line, file=sys.stderr) + if line.startswith(HOST_LINE): + urls = line[len(HOST_LINE):].strip() + print(f'url="{urls}"', file=sys.stderr) + self.cpp_url = QUrl(urls) + self.ready.emit() + + +class HarnessTest(UsesQApplication): + def setUp(self): + super().setUp() + self.rep = RepFile(self.__class__.contents) + self.controller = Controller(self) + self.assertTrue(self.controller.start()) + + def tearDown(self): + self.controller.stop() + self.app.processEvents() + super().tearDown() + QTest.qWait(100) # Wait for 100 msec + + +@wrap_tests_for_cleanup(extra=['rep']) +class TestBasics(HarnessTest): + contents = textwrap.dedent("""\ + class Simple + { + PROP(int i = 2); + PROP(float f = -1. READWRITE); + } + """) + + def compare_properties(self, instance, values): + '''Compare properties of instance with values''' + self.assertEqual(instance.i, values[0]) + self.assertAlmostEqual(instance.f, values[1], places=5) + + def testInitialization(self): + '''Test constructing RepFile from a path string''' + class Source(self.rep.source["Simple"]): + pass + source, replica = self.controller.add_source(Source, self.rep.replica["Simple"]) + self.compare_properties(source, [2, -1]) + self.compare_properties(replica, [2, -1]) + + +if __name__ == '__main__': + unittest.main() diff --git a/sources/pyside6/tests/QtRemoteObjects/dynamic_types_test.py b/sources/pyside6/tests/QtRemoteObjects/dynamic_types_test.py new file mode 100644 index 000000000..5fb828cd2 --- /dev/null +++ b/sources/pyside6/tests/QtRemoteObjects/dynamic_types_test.py @@ -0,0 +1,97 @@ +#!/usr/bin/python +# Copyright (C) 2025 Ford Motor Company +# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0 +from __future__ import annotations + +'''Test cases for dynamic source/replica types''' + +import os +import sys +import unittest +from pathlib import Path +sys.path.append(os.fspath(Path(__file__).resolve().parents[1])) +from init_paths import init_test_paths +init_test_paths(False) + +from PySide6.QtRemoteObjects import RepFile + +from test_shared import wrap_tests_for_cleanup + + +contents = """ +class Simple +{ + PROP(int i = 2); + PROP(float f = -1. READWRITE); + SIGNAL(random(int i)); + SLOT(void reset()); +}; +""" + + +@wrap_tests_for_cleanup(extra=['rep_file']) +class QDynamicReplicas(unittest.TestCase): + '''Test case for dynamic Replicas''' + + def setUp(self): + '''Set up test environment''' + self.rep_file = RepFile(contents) + + def testDynamicReplica(self): + '''Verify that a valid Replica is created''' + Replica = self.rep_file.replica["Simple"] + self.assertIsNotNone(Replica) + replica = Replica() + self.assertIsNotNone(replica) + self.assertIsNotNone(replica.metaObject()) + meta = replica.metaObject() + self.assertEqual(meta.className(), "Simple") + self.assertEqual(meta.superClass().className(), "QRemoteObjectReplica") + i = meta.indexOfProperty("i") + self.assertNotEqual(i, -1) + self.assertEqual(replica.propAsVariant(0), int(2)) + self.assertEqual(replica.propAsVariant(1), float(-1.0)) + self.assertEqual(replica.i, int(2)) + self.assertEqual(replica.f, float(-1.0)) + + +@wrap_tests_for_cleanup(extra=['rep_file']) +class QDynamicSources(unittest.TestCase): + '''Test case for dynamic Sources''' + + def setUp(self): + '''Set up test environment''' + self.rep_file = RepFile(contents) + self.test_val = 0 + + def on_changed(self, val): + self.test_val = val + + def testDynamicSource(self): + '''Verify that a valid Source is created''' + Source = self.rep_file.source["Simple"] + self.assertIsNotNone(Source) + source = Source() + self.assertIsNotNone(source) + self.assertIsNotNone(source.metaObject()) + meta = source.metaObject() + self.assertEqual(meta.className(), "SimpleSource") + self.assertEqual(meta.superClass().className(), "QObject") + i = meta.indexOfProperty("i") + self.assertNotEqual(i, -1) + self.assertIsNotNone(source.__dict__.get('__PROPERTIES__')) + self.assertEqual(source.i, int(2)) + self.assertEqual(source.f, float(-1.0)) + source.iChanged.connect(self.on_changed) + source.fChanged.connect(self.on_changed) + source.i = 7 + self.assertEqual(source.i, int(7)) + self.assertEqual(self.test_val, int(7)) + source.i = 3 + self.assertEqual(self.test_val, int(3)) + source.f = 3.14 + self.assertAlmostEqual(self.test_val, float(3.14), places=5) + + +if __name__ == '__main__': + unittest.main() diff --git a/sources/pyside6/tests/QtRemoteObjects/integration_test.py b/sources/pyside6/tests/QtRemoteObjects/integration_test.py new file mode 100644 index 000000000..69b4930da --- /dev/null +++ b/sources/pyside6/tests/QtRemoteObjects/integration_test.py @@ -0,0 +1,369 @@ +#!/usr/bin/python +# Copyright (C) 2025 Ford Motor Company +# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0 +from __future__ import annotations + +'''Test cases for basic Source/Replica communication''' + +import os +import sys +import textwrap +import enum +import gc + +import unittest + +from pathlib import Path +sys.path.append(os.fspath(Path(__file__).resolve().parents[1])) +from init_paths import init_test_paths +init_test_paths(False) + +from PySide6.QtCore import QUrl, qWarning +from PySide6.QtRemoteObjects import (QRemoteObjectHost, QRemoteObjectNode, QRemoteObjectReplica, + QRemoteObjectPendingCall, RepFile, getCapsuleCount) +from PySide6.QtTest import QSignalSpy, QTest + +from test_shared import wrap_tests_for_cleanup +from helper.usesqapplication import UsesQApplication + +contents = """ +class Simple +{ + PROP(int i = 2); + PROP(float f = -1. READWRITE); + SIGNAL(random(int i)); + SLOT(void reset()); + SLOT(int add(int i)); +}; +""" + + +class QBasicTest(UsesQApplication): + '''Test case for basic source/replica communication''' + def setUp(self): + # Separate output to make debugging easier + qWarning(f"\nSet up {self.__class__.__qualname__}") + super().setUp() + '''Set up test environment''' + if hasattr(self.__class__, "contents"): + qWarning(f"Using class contents >{self.__class__.contents}<") + self.rep = RepFile(self.__class__.contents) + else: + self.rep = RepFile(contents) + self.host = QRemoteObjectHost(QUrl("tcp://127.0.0.1:0")) + self.host.setObjectName("host") + self.node = QRemoteObjectNode() + self.node.setObjectName("node") + self.node.connectToNode(self.host.hostUrl()) # pick up the url with the assigned port + + def compare_properties(self, instance, values): + '''Compare properties of instance with values''' + self.assertEqual(instance.i, values[0]) + self.assertAlmostEqual(instance.f, values[1], places=5) + + def default_setup(self): + '''Set up default test environment''' + replica = self.node.acquire(self.rep.replica["Simple"]) + # Make sure the replica is initialized with default values + self.compare_properties(replica, [2, -1]) + self.assertEqual(replica.isInitialized(), False) + source = self.rep.source["Simple"]() + # Make sure the source is initialized with default values + self.compare_properties(source, [2, -1]) + return replica, source + + def tearDown(self): + self.assertEqual(getCapsuleCount(), 0) + self.app.processEvents() + super().tearDown() + # Separate output to make debugging easier + qWarning(f"Tore down {self.__class__.__qualname__}\n") + + +@wrap_tests_for_cleanup(extra=['rep', 'host', 'node']) +class ReplicaInitialization(QBasicTest): + def test_ReplicaInitialization(self): + replica, source = self.default_setup() + source.i = -1 + source.f = 3.14 + self.compare_properties(source, [-1, 3.14]) + init_spy = QSignalSpy(replica.initialized) + self.host.enableRemoting(source) + self.assertEqual(replica.waitForSource(1000), True) + self.assertEqual(replica.state(), QRemoteObjectReplica.State.Valid) + # Make sure the replica values are updated to the source values + self.compare_properties(replica, [-1, 3.14]) + self.assertEqual(init_spy.count(), 1) + self.assertEqual(replica.isInitialized(), True) + + +@wrap_tests_for_cleanup(extra=['rep', 'host', 'node']) +class SourcePropertyChange(QBasicTest): + def test_SourcePropertyChange(self): + replica, source = self.default_setup() + self.host.enableRemoting(source) + self.assertEqual(replica.waitForSource(1000), True) + # Make sure the replica values are unchanged since the source had the same values + self.compare_properties(replica, [2, -1]) + source_spy = QSignalSpy(source.iChanged) + replica_spy = QSignalSpy(replica.iChanged) + source.i = 42 + self.assertEqual(source_spy.count(), 1) + # Make sure the source value is updated + self.compare_properties(source, [42, source.f]) + self.assertTrue(replica_spy.wait(1000)) + self.assertEqual(replica_spy.count(), 1) + # Make sure the replica value is updated + self.compare_properties(replica, [42, replica.f]) + + +@wrap_tests_for_cleanup(extra=['rep', 'host', 'node']) +class ReplicaPropertyChange(QBasicTest): + def test_ReplicaPropertyChange(self): + replica, source = self.default_setup() + self.host.enableRemoting(source) + self.assertEqual(replica.waitForSource(1000), True) + # Make sure push methods are working + source_spy = QSignalSpy(source.iChanged) + replica_spy = QSignalSpy(replica.iChanged) + replica.pushI(11) + # # Let eventloop run to update the source and verify the values + self.assertTrue(source_spy.wait(1000)) + self.assertEqual(source_spy.count(), 1) + self.compare_properties(source, [11, source.f]) + # Let eventloop run to update the replica and verify the values + self.assertTrue(replica_spy.wait(1000)) + self.assertEqual(replica_spy.count(), 1) + self.compare_properties(replica, [11, replica.f]) + + # Test setter on replica + source_spy = QSignalSpy(source.fChanged) + replica_spy = QSignalSpy(replica.fChanged) + replica.f = 4.2 + # Make sure the replica values are ** NOT CHANGED ** since the eventloop hasn't run + self.compare_properties(replica, [11, -1]) + # Let eventloop run to update the source and verify the values + self.assertTrue(source_spy.wait(1000)) + self.assertEqual(source_spy.count(), 1) + self.compare_properties(source, [source.i, 4.2]) + # Let eventloop run to update the replica and verify the values + self.assertTrue(replica_spy.wait(1000)) + self.assertEqual(replica_spy.count(), 1) + self.compare_properties(replica, [replica.i, 4.2]) + + +@wrap_tests_for_cleanup(extra=['rep', 'host', 'node']) +class DerivedReplicaPropertyChange(QBasicTest): + def test_DerivedReplicaPropertyChange(self): + # Don't use default_setup(), instead create a derived replica + Replica = self.rep.replica["Simple"] + + class DerivedReplica(Replica): + pass + + replica = self.node.acquire(DerivedReplica) + # Make sure the replica is initialized with default values + self.compare_properties(replica, [2, -1]) + self.assertEqual(replica.isInitialized(), False) + source = self.rep.source["Simple"]() + self.host.enableRemoting(source) + self.assertEqual(replica.waitForSource(1000), True) + + +@wrap_tests_for_cleanup(extra=['rep', 'host', 'node']) +class ReplicaSlotNotImplementedChange(QBasicTest): + def test_ReplicaSlotNotImplementedChange(self): + replica, source = self.default_setup() + self.host.enableRemoting(source) + self.assertEqual(replica.waitForSource(1000), True) + # Ideally this would fail as the slot is not implemented on the source + res = replica.reset() + self.assertEqual(type(res), type(None)) + QTest.qWait(100) # Wait for 100 ms for async i/o. There isn't a signal to wait on + res = replica.add(5) + self.assertEqual(type(res), QRemoteObjectPendingCall) + + +@wrap_tests_for_cleanup(extra=['rep', 'host', 'node']) +class ReplicaSlotImplementedChange(QBasicTest): + def test_ReplicaSlotImplementedChange(self): + replica = self.node.acquire(self.rep.replica["Simple"]) + replica.setObjectName("replica") + + class Source(self.rep.source["Simple"]): + def __init__(self): + super().__init__() + self.i = 6 + self.f = 3.14 + + def reset(self): + self.i = 0 + self.f = 0 + + def add(self, i): + return self.i + i + + source = Source() + source.setObjectName("source") + self.host.enableRemoting(source) + self.assertEqual(replica.waitForSource(1000), True) + self.compare_properties(source, [6, 3.14]) + self.compare_properties(replica, [6, 3.14]) + replica_spy = QSignalSpy(replica.iChanged) + res = replica.reset() + self.assertEqual(type(res), type(None)) + self.assertEqual(replica_spy.wait(1000), True) + self.compare_properties(source, [0, 0]) + self.compare_properties(replica, [0, 0]) + res = replica.add(5) + self.assertEqual(type(res), QRemoteObjectPendingCall) + self.assertEqual(res.waitForFinished(1000), True) + self.assertEqual(res.returnValue(), 5) + + +@wrap_tests_for_cleanup(extra=['rep', 'host', 'node']) +class RefCountTest(QBasicTest): + contents = textwrap.dedent("""\ + POD MyPOD{ + ENUM class Position : unsigned short {position1=1, position2=2, position3=4} + Position pos, + QString name + } + class Simple + { + ENUM Position {Left, Right, Top, Bottom} + PROP(MyPOD myPod); + PROP(Position pos); + } + """) + + def test_RefCount(self): + # Once the rep file is loaded, we should be tracking 4 converter capsules + # - 1 for the POD itself + # - 1 for the enum in the POD + # - 1 for the enum in the Source + # - 1 for the enum in the Replica + # We should be tracking 3 qobject capsules (POD, Replica, Source) + # Note: Source and Replica are distinct types, so Source::EPosition and + # Replica::EPosition are distinct as well. + # Note 2: The name of the enum ("Position") can be reused for different + # types in different classes as shown above. + self.assertEqual(getCapsuleCount(), 7) + MyPod = self.rep.pod["MyPOD"] + self.assertTrue(isinstance(MyPod, type)) + self.assertTrue(issubclass(MyPod, tuple)) + MyEnum = MyPod.get_enum("Position") + self.assertTrue(isinstance(MyEnum, type)) + self.assertTrue(issubclass(MyEnum, enum.Enum)) + e = MyEnum(4) # noqa: F841 + Source = self.rep.source["Simple"] + source = Source() # noqa: F841 + source = None # noqa: F841 + Source = None + Replica = self.rep.replica["Simple"] + replica = self.node.acquire(Replica) # noqa: F841 + replica = None # noqa: F841 + Replica = None + MyEnum = None + MyPod = None + self.rep = None + e = None # noqa: F841 + gc.collect() + # The enum and POD capsules will only be deleted (garbage collected) if + # the types storing them (RepFile, Replica and Source) are garbage + # collected first. + self.assertEqual(getCapsuleCount(), 0) + + +@wrap_tests_for_cleanup(extra=['rep', 'host', 'node']) +class EnumTest(QBasicTest): + contents = textwrap.dedent("""\ + POD MyPOD{ + ENUM class Position : unsigned short {position1=1, position2=2, position3=4} + Position pos, + QString name + } + class Simple + { + ENUM Position {Left, Right, Top, Bottom} + PROP(MyPOD myPod); + PROP(Position pos); + } + """) + + def test_Enum(self): + MyPod = self.rep.pod["MyPOD"] + self.assertTrue(isinstance(MyPod, type)) + self.assertTrue(issubclass(MyPod, tuple)) + PodEnum = MyPod.get_enum("Position") + self.assertTrue(isinstance(PodEnum, type)) + self.assertTrue(issubclass(PodEnum, enum.Enum)) + t = (PodEnum(4), "test") + myPod = MyPod(*t) + with self.assertRaises(ValueError): + myPod = MyPod(PodEnum(0), "thing") # 0 isn't a valid enum value + myPod = MyPod(PodEnum(2), "thing") + self.assertEqual(myPod.pos, PodEnum.position2) + replica = self.node.acquire(self.rep.replica["Simple"]) + replica.setObjectName("replica") + source = self.rep.source["Simple"]() + source.setObjectName("source") + source.myPod = (PodEnum.position2, "Hello") + SourceEnum = source.get_enum("Position") + self.assertTrue(isinstance(SourceEnum, type)) + self.assertTrue(issubclass(SourceEnum, enum.Enum)) + source.pos = SourceEnum.Top + self.assertEqual(source.myPod, (PodEnum.position2, "Hello")) + self.assertNotEqual(source.pos, 2) + self.host.enableRemoting(source) + self.assertEqual(replica.waitForSource(1000), True) + self.assertEqual(replica.myPod, (PodEnum.position2, "Hello")) + ReplicaEnum = replica.get_enum("Position") + # Test invalid comparisons + self.assertNotEqual(replica.pos, 2) + self.assertNotEqual(replica.pos, SourceEnum.Top) + self.assertNotEqual(replica.myPod, (SourceEnum(2), "Hello")) + self.assertNotEqual(replica.myPod, (ReplicaEnum(2), "Hello")) + self.assertNotEqual(replica.myPod, (2, "Hello")) + # Test valid comparisons to Replica enum + self.assertEqual(replica.pos, ReplicaEnum.Top) + self.assertEqual(replica.myPod, (PodEnum(2), "Hello")) + + +@wrap_tests_for_cleanup(extra=['rep', 'host', 'node']) +class PodTest(QBasicTest): + contents = textwrap.dedent("""\ + POD MyPod(int i, QString s) + + class Simple + { + PROP(MyPod pod); + } + """) + + def test_Pod(self): + MyPod = self.rep.pod["MyPod"] + self.assertTrue(isinstance(MyPod, type)) + self.assertTrue(issubclass(MyPod, tuple)) + source = self.rep.source["Simple"]() + t = (42, "Hello") + pod = MyPod(*t) + source.pod = t + self.assertEqual(source.pod, t) + self.assertEqual(source.pod, pod) + source.pod = pod + self.assertEqual(source.pod, t) + self.assertEqual(source.pod, pod) + with self.assertRaises(ValueError): + source.pod = (11, "World", "!") + with self.assertRaises(TypeError): + source.pod = MyPod("Hello", "World") + self.assertEqual(source.pod, pod) + self.assertTrue(isinstance(pod, MyPod)) + self.assertEqual(pod.i, 42) + self.assertEqual(pod.s, "Hello") + self.assertTrue(isinstance(source.pod, MyPod)) + + +if __name__ == '__main__': + unittest.main() diff --git a/sources/pyside6/tests/QtRemoteObjects/repfile_test.py b/sources/pyside6/tests/QtRemoteObjects/repfile_test.py new file mode 100644 index 000000000..b73c84f3a --- /dev/null +++ b/sources/pyside6/tests/QtRemoteObjects/repfile_test.py @@ -0,0 +1,65 @@ +#!/usr/bin/python +# Copyright (C) 2025 Ford Motor Company +# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0 +from __future__ import annotations + +'''Test cases for RepFile''' + +import os +import sys +import unittest +from pathlib import Path +sys.path.append(os.fspath(Path(__file__).resolve().parents[1])) +from init_paths import init_test_paths +init_test_paths(False) +from PySide6.QtRemoteObjects import RepFile + +from test_shared import wrap_tests_for_cleanup + +contents = """ +class Simple +{ + PROP(int i = 2); + PROP(float f = -1. READWRITE); + SIGNAL(random(int i)); + SLOT(void reset()); +}; +""" + + +@wrap_tests_for_cleanup() +class QRepFileConstructor(unittest.TestCase): + '''Test case for RepFile constructors''' + expected = "RepFile(Classes: [Simple], PODs: [])" + + def setUp(self): + '''Set up test environment''' + self.cwd = Path(__file__).parent + self.path = self.cwd / "simple.rep" + + def testRepFileFromPath(self): + '''Test constructing RepFile from a path''' + with open(self.path, 'r') as f: + rep_file = RepFile(f.read()) + self.assertEqual(str(rep_file), self.expected) + + def testRepFileFromString(self): + '''Test constructing RepFile from a string''' + rep_file = RepFile(contents) + self.assertEqual(str(rep_file), self.expected) + + def testRepFileInvalidString(self): + '''Test constructing RepFile from a string''' + with self.assertRaises(RuntimeError) as result: + RepFile("\n\n}\n\n") + self.assertEqual(str(result.exception), + "Error parsing input, line 3: error: Unknown token encountered") + + def testRepFileNoArguments(self): + '''Test constructing RepFile with no arguments''' + with self.assertRaises(TypeError): + RepFile() + + +if __name__ == '__main__': + unittest.main() diff --git a/sources/pyside6/tests/QtRemoteObjects/simple.rep b/sources/pyside6/tests/QtRemoteObjects/simple.rep new file mode 100644 index 000000000..7e801a8c6 --- /dev/null +++ b/sources/pyside6/tests/QtRemoteObjects/simple.rep @@ -0,0 +1,7 @@ +class Simple +{ + PROP(int i = 2); + PROP(float f = -1. READWRITE); + SIGNAL(random(int i)); + SLOT(void reset()); +}; diff --git a/sources/pyside6/tests/QtRemoteObjects/test_shared.py b/sources/pyside6/tests/QtRemoteObjects/test_shared.py new file mode 100644 index 000000000..5b176ce9d --- /dev/null +++ b/sources/pyside6/tests/QtRemoteObjects/test_shared.py @@ -0,0 +1,126 @@ +# Copyright (C) 2025 Ford Motor Company +# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0 +from __future__ import annotations + +import gc +import sys +from functools import wraps + + +def _cleanup_local_variables(self, extra, debug): + """ + Function to clean up local variables after a unit test. + + This method will set any local variables defined in the test run to None. It also + sets variables of self to None, if they are provided in the extra list. + + The self argument is passed by the decorator, so we can access the instance variables. + """ + local_vars = self._locals + if debug: + print(f" Cleaning up locals: {local_vars.keys()} and member of self: {extra}", + file=sys.stderr) + exclude_vars = {'__builtins__', 'self', 'args', 'kwargs'} + for var in list(local_vars.keys()): + if var not in exclude_vars: + local_vars[var] = None + if debug: + print(f" Set {var} to None", file=sys.stderr) + # Remove variables added to 'self' during our test + for var in list(vars(self).keys()): + if var in extra: + setattr(self, var, None) + if debug: + print(f" Set self.{var} to None", file=sys.stderr) + gc.collect() + + +# This leverages the tip from # https://stackoverflow.com/a/9187022/169296 +# for capturing local variables using sys.setprofile and a tracer function +def wrap_tests_for_cleanup(extra: str | list[str] = None, debug: bool = False): + """ + Method that returns a decorator for setting variables used in a test to + None, thus allowing the garbage collection to clean up properly and ensure + destruction behavior is correct. Using a method to return the decorator + allows us to pass extra arguments to the decorator, in this case for extra + data members on `self` to set to None or whether to output additional debug + logging. + + It simply returns the class decorator to be used. + """ + def decorator(cls): + """ + This is a class decorator that finds and wraps all test methods in a + class. + + The provided extra is used to define a set() of variables that are set + to None on `self` after the test method has run. This is useful for + making sure the local and self variables can be garbage collected. + """ + _extra = set() + if extra: + if isinstance(extra, str): + _extra.add(extra) + else: + _extra.update(extra) + for name, attr in cls.__dict__.items(): + if name.startswith("test") and callable(attr): + """ + Only wrap methods that start with 'test' and are callable. + """ + def make_wrapper(method): + """ + This is the actual wrapper that will be used to wrap the + test methods. It will set a tracer function to capture the + local variables and then calls our cleanup function to set + the variables to None. + """ + @wraps(method) + def wrapper(self, *args, **kwargs): + if debug: + print(f"wrap_tests_for_cleanup - calling {method.__name__}", + file=sys.stderr) + + def tracer(frame, event, arg): + if event == 'return': + self._locals = frame.f_locals.copy() + + # tracer is activated on next call, return or exception + sys.setprofile(tracer) + try: + # trace the function call + return method(self, *args, **kwargs) + finally: + # disable tracer and replace with old one + sys.setprofile(None) + # call our cleanup function + _cleanup_local_variables(self, _extra, debug) + if debug: + print(f"wrap_tests_for_cleanup - done calling {method.__name__}", + file=sys.stderr) + return wrapper + setattr(cls, name, make_wrapper(attr)) + return cls + return decorator + + +if __name__ == "__main__": + # Set up example test class + @wrap_tests_for_cleanup(extra="name", debug=True) + class test: + def __init__(self): + self.name = "test" + + def testStuff(self): + value = 42 + raise ValueError("Test") + temp = 11 # noqa: F841 + return value + + t = test() + try: + t.testStuff() + except ValueError: + pass + # Should print that `value` and `self.name` are set to None, even with the + # exception being raised. |
