diff --git a/CMakeLists.txt b/CMakeLists.txt
index 8d7eda0..1770be5 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -26,11 +26,10 @@ add_subdirectory(src/proxy)
# force plugin_python to build mobase
add_dependencies(plugin_python mobase)
+set_property(DIRECTORY ${PROJECT_SOURCE_DIR} PROPERTY VS_STARTUP_PROJECT plugin_python)
set(PLUGIN_PYTHON_TESTS ${PLUGIN_PYTHON_TESTS} CACHE BOOL "build tests for plugin_python")
-
if (PLUGIN_PYTHON_TESTS)
+ enable_testing()
add_subdirectory(tests)
endif()
-
-set_property(DIRECTORY ${PROJECT_SOURCE_DIR} PROPERTY VS_STARTUP_PROJECT plugin_python)
diff --git a/src/proxy/plugin_python_en.ts b/src/proxy/plugin_python_en.ts
index 9d484bb..b8eb0a7 100644
--- a/src/proxy/plugin_python_en.ts
+++ b/src/proxy/plugin_python_en.ts
@@ -16,58 +16,58 @@ Do you want to try initializing python again (at the risk of another crash)?
-
+
Python Proxy
-
+
Proxy Plugin to allow plugins written in python to be loaded
-
+
ModOrganizer path contains a semicolon
-
+
Python DLL not found
-
+
Invalid Python DLL
-
+
Initializing Python failed
-
-
+
+
invalid problem key %1
-
+
The path to Mod Organizer (%1) contains a semicolon. <br>While this is legal on NTFS drives, many softwares do not handle it correctly.<br>Unfortunately MO depends on libraries that seem to fall into that group.<br>As a result the python plugin cannot be loaded, and the only solution we canoffer is to remove the semicolon or move MO to a path without a semicolon.
-
+
The Python plugin DLL was not found, maybe your antivirus deleted it. Re-installing MO2 might fix the problem.
-
+
The Python plugin DLL is invalid, maybe your antivirus is blocking it. Re-installing MO2 and adding exclusions for it to your AV might fix the problem.
-
+
The initialization of the Python plugin DLL failed, unfortunately without any details.
diff --git a/src/proxy/proxypython.cpp b/src/proxy/proxypython.cpp
index c5b9f36..87f2455 100644
--- a/src/proxy/proxypython.cpp
+++ b/src/proxy/proxypython.cpp
@@ -142,7 +142,11 @@ bool ProxyPython::init(IOrganizer* moInfo)
m_Runner = std::unique_ptr{createPythonRunner()};
if (m_Runner) {
- m_Runner->initialize(pluginFolder / "libs");
+ const auto libpath = pluginFolder / "libs";
+ const QStringList paths{
+ QFileInfo(libpath / "pythoncore.zip").absoluteFilePath(),
+ QFileInfo(libpath).absoluteFilePath(), IOrganizer::getPluginDataPath()};
+ m_Runner->initialize(paths);
}
if (m_MOInfo) {
diff --git a/src/pybind11-qt/pybind11_qt_basic.cpp b/src/pybind11-qt/pybind11_qt_basic.cpp
index fbd3ac2..b8f19f1 100644
--- a/src/pybind11-qt/pybind11_qt_basic.cpp
+++ b/src/pybind11-qt/pybind11_qt_basic.cpp
@@ -6,6 +6,9 @@
#include "pybind11_qt/details/pybind11_qt_utils.h"
+// need to import containers to get QVariantList and QVariantMap
+#include "pybind11_qt/pybind11_qt_containers.h"
+
namespace pybind11::detail {
template
@@ -83,13 +86,22 @@ namespace pybind11::detail {
bool type_caster::load(handle src, bool implicit)
{
- if (PyList_Check(src.ptr())) {
+ // test for string first otherwise PyList_Check also works
+ if (PyBytes_Check(src.ptr()) || PyUnicode_Check(src.ptr())) {
+ value = src.cast();
+ return true;
+ }
+ else if (PySequence_Check(src.ptr())) {
// we could check if all the elements can be converted to QString
// and store a QStringList in the QVariant but I am not sure that is
// really useful.
value = src.cast();
return true;
}
+ else if (PyMapping_Check(src.ptr())) {
+ value = src.cast();
+ return true;
+ }
else if (src == Py_None) {
value = QVariant();
return true;
@@ -98,10 +110,6 @@ namespace pybind11::detail {
value = src.cast();
return true;
}
- else if (PyBytes_Check(src.ptr()) || PyUnicode_Check(src.ptr())) {
- value = src.cast();
- return true;
- }
// PyBool will also return true for PyLong_Check but not the other way
// around, so the order here is relevant.
else if (PyBool_Check(src.ptr())) {
diff --git a/src/runner/CMakeLists.txt b/src/runner/CMakeLists.txt
index 52429a8..0080232 100644
--- a/src/runner/CMakeLists.txt
+++ b/src/runner/CMakeLists.txt
@@ -6,10 +6,12 @@ mo2_configure_library(pythonrunner
WARNINGS OFF
AUTOMOC ON
TRANSLATIONS OFF
- PRIVATE_DEPENDS uibase Qt::Core
+ PUBLIC_DEPENDS uibase Qt::Core
)
target_link_libraries(pythonrunner PRIVATE pybind11::embed pybind11::qt)
-target_include_directories(pythonrunner PRIVATE ${PYTHON_ROOT}/Include)
+target_include_directories(pythonrunner
+ PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}
+ PRIVATE ${PYTHON_ROOT}/Include)
# this is kind of broken but it only works with this...
target_compile_definitions(pythonrunner
diff --git a/src/runner/pythonrunner.cpp b/src/runner/pythonrunner.cpp
index 74e3cde..911e770 100644
--- a/src/runner/pythonrunner.cpp
+++ b/src/runner/pythonrunner.cpp
@@ -34,7 +34,7 @@ public:
QList load(const QString& identifier) override;
void unload(const QString& identifier) override;
- bool initialize(std::filesystem::path const& libpath) override;
+ bool initialize(QStringList const& paths) override;
bool isInitialized() const override;
private:
@@ -58,7 +58,7 @@ IPythonRunner* CreatePythonRunner()
return new PythonRunner();
}
-bool PythonRunner::initialize(std::filesystem::path const& libpath)
+bool PythonRunner::initialize(QStringList const& paths)
{
// we only initialize Python once for the whole lifetime of the program, even if MO2
// is restarted and the proxy or PythonRunner objects are deleted and recreated,
@@ -81,10 +81,9 @@ bool PythonRunner::initialize(std::filesystem::path const& libpath)
// initialize the core Path of Python, this must be done before initialization
//
- const QStringList paths{
- QFileInfo(libpath / "pythoncore.zip").absoluteFilePath(),
- QFileInfo(libpath).absoluteFilePath(), IOrganizer::getPluginDataPath()};
- Py_SetPath(paths.join(';').toStdWString().c_str());
+ if (!paths.isEmpty()) {
+ Py_SetPath(paths.join(';').toStdWString().c_str());
+ }
Py_OptimizeFlag = 2;
Py_NoSiteFlag = 1;
diff --git a/src/runner/pythonrunner.h b/src/runner/pythonrunner.h
index 5ebef40..5e96fb8 100644
--- a/src/runner/pythonrunner.h
+++ b/src/runner/pythonrunner.h
@@ -6,6 +6,7 @@
#include
#include
+#include
#include
#include
@@ -17,9 +18,11 @@ public:
// initialize Python
//
- // libpath should be the folder containing the Python library (pythonxxx.zip, etc.)
+ // paths contains the list of built-in paths for the Python library (pythonxxx.zip,
+ // etc.), an empty list uses the default Python paths (e.g., the PYTHONPATH
+ // environment variable)
//
- virtual bool initialize(std::filesystem::path const& libpath) = 0;
+ virtual bool initialize(QStringList const& paths = {}) = 0;
// check if the runner has been initialized, i.e., initialize() has been called and
// succeeded
diff --git a/tests/python/CMakeLists.txt b/tests/python/CMakeLists.txt
index 15b24d1..5475f3a 100644
--- a/tests/python/CMakeLists.txt
+++ b/tests/python/CMakeLists.txt
@@ -24,7 +24,6 @@ mo2_python_pip_install(pytest
PACKAGES pytest PyQt6==6.3.0)
add_dependencies(pytest mobase)
-
file(GLOB test_files CONFIGURE_DEPENDS "test_*.cpp")
foreach (test_file ${test_files})
get_filename_component(target ${test_file} NAME_WLE)
diff --git a/tests/python/test_qt.cpp b/tests/python/test_qt.cpp
index 564e147..4d656c2 100644
--- a/tests/python/test_qt.cpp
+++ b/tests/python/test_qt.cpp
@@ -2,6 +2,8 @@
#include
+#include
+
using namespace pybind11::literals;
PYBIND11_MODULE(qt, m)
@@ -33,6 +35,7 @@ PYBIND11_MODULE(qt, m)
});
// QMap
+
m.def("qmap_to_length", [](QMap const& map) {
QMap res;
for (auto it = map.begin(); it != map.end(); ++it) {
@@ -56,4 +59,61 @@ PYBIND11_MODULE(qt, m)
return datetime.toString(format);
},
"datetime"_a, "format"_a = Qt::DateFormat::ISODate);
+
+ // QVariant
+
+ m.def("qvariant_from_none", [](QVariant const& variant) {
+ return std::make_tuple(variant.userType() == QVariant::Invalid,
+ variant.isValid());
+ });
+ m.def("qvariant_from_int", [](QVariant const& variant) {
+ return std::make_tuple(variant.userType() == QVariant::Int, variant.toInt());
+ });
+ m.def("qvariant_from_bool", [](QVariant const& variant) {
+ return std::make_tuple(variant.userType() == QVariant::Bool, variant.toBool());
+ });
+ m.def("qvariant_from_str", [](QVariant const& variant) {
+ return std::make_tuple(variant.userType() == QVariant::String,
+ variant.toString());
+ });
+ m.def("qvariant_from_list", [](QVariant const& variant) {
+ return std::make_tuple(variant.userType() == QVariant::List, variant.toList());
+ });
+ m.def("qvariant_from_map", [](QVariant const& variant) {
+ return std::make_tuple(variant.userType() == QVariant::Map, variant.toMap());
+ });
+
+ m.def("qvariant_none", []() {
+ return QVariant();
+ });
+ m.def("qvariant_int", []() {
+ return QVariant(42);
+ });
+ m.def("qvariant_bool", []() {
+ return QVariant(true);
+ });
+ m.def("qvariant_str", []() {
+ return QVariant("hello world");
+ });
+ m.def("qvariant_list", [] {
+ QVariantMap subMap;
+ subMap["bar"] = 42;
+ subMap["moo"] = QVariantList{44, true};
+ QVariantList list;
+ list.push_back(33);
+ list.push_back(QVariantList{4, "foo"});
+ list.push_back(false);
+ list.push_back("hello");
+ list.push_back(QVariant());
+ list.push_back(subMap);
+ list.push_back(45);
+ return QVariant(list);
+ });
+ m.def("qvariant_map", []() {
+ QVariantMap map;
+ map["bar"] = 42;
+ map["moo"] = true;
+ map["baz"] = "world hello";
+ return map;
+ });
}
diff --git a/tests/python/test_qt.py b/tests/python/test_qt.py
index 71b0771..3cb94d1 100644
--- a/tests/python/test_qt.py
+++ b/tests/python/test_qt.py
@@ -1,6 +1,5 @@
import pytest
from PyQt6.QtCore import QDateTime, Qt
-from PyQt6.QtWidgets import QWidget
m = pytest.importorskip("mobase_tests.qt")
@@ -48,3 +47,46 @@ def test_qdatetime():
assert m.datetime_to_string(date, Qt.DateFormat.TextDate) == date.toString(
Qt.DateFormat.TextDate
)
+
+
+def test_qvariant():
+
+ # Python -> C++
+
+ assert m.qvariant_from_none(None) == (True, False)
+
+ assert m.qvariant_from_int(-52) == (True, -52)
+ assert m.qvariant_from_int(0) == (True, 0)
+ assert m.qvariant_from_int(33) == (True, 33)
+
+ assert m.qvariant_from_bool(True) == (True, True)
+ assert m.qvariant_from_bool(False) == (True, False)
+
+ assert m.qvariant_from_str("a string") == (True, "a string")
+
+ assert m.qvariant_from_list([]) == (True, [])
+ assert m.qvariant_from_list([1, "hello", False]) == (True, [1, "hello", False])
+
+ assert m.qvariant_from_map({"a": 33, "b": False, "c": ["a", "b"]}) == (
+ True,
+ {"a": 33, "b": False, "c": ["a", "b"]},
+ )
+
+ # C++ -> Python (see .cpp file for the value)
+
+ assert m.qvariant_none() is None
+ assert m.qvariant_int() == 42
+ assert m.qvariant_bool() is True
+ assert m.qvariant_str() == "hello world"
+
+ assert m.qvariant_map() == {"baz": "world hello", "bar": 42, "moo": True}
+
+ assert m.qvariant_list() == [
+ 33,
+ [4, "foo"],
+ False,
+ "hello",
+ None,
+ {"bar": 42, "moo": [44, True]},
+ 45,
+ ]
diff --git a/tests/runner/CMakeLists.txt b/tests/runner/CMakeLists.txt
index feb510a..6747c83 100644
--- a/tests/runner/CMakeLists.txt
+++ b/tests/runner/CMakeLists.txt
@@ -1,8 +1,42 @@
-cmake_minimum_required(VERSION 3.16)
+cmake_minimum_required(VERSION 3.22)
-add_executable(pythonrunner-tests)
-mo2_configure_tests(pythonrunner-tests
- WARNINGS OFF)
-mo2_add_dependencies(pythonrunner-tests PUBLIC uibase)
+# setting-up the tests for the runner is a bit complex because we need a tons of
+# things
+
+# first we configure the tests as with other tests
+add_executable(pythonrunner-tests EXCLUDE_FROM_ALL)
+mo2_configure_tests(pythonrunner-tests WARNINGS OFF)
+
+# add mocks
target_include_directories(pythonrunner-tests
PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../mocks)
+
+# link to pythonrunner - we set PYTHONRUNNER_LIBRARY to not export the symbol but
+# loads it directly, we are going to add the DLL path below
+target_compile_definitions(pythonrunner-tests PUBLIC PYTHONRUNNER_LIBRARY)
+target_link_libraries(pythonrunner-tests PUBLIC pythonrunner)
+
+set(PYLIB_DIR ${CMAKE_CURRENT_BINARY_DIR}/pylibs)
+mo2_python_pip_install(pythonrunner-tests
+ DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/pylibs
+ PACKAGES pytest PyQt6==6.3.0)
+
+add_dependencies(pythonrunner-tests mobase)
+
+# we set multiple properties, including:
+# - updated PATH for DLLs (Qt, etc.), Python and pythonrunner
+# - PYTHONPATH
+
+set(pythoncore "${PYTHON_ROOT}/PCbuild/amd64/pythoncore")
+file(GLOB pythoncorezip "${pythoncore}/python*.zip")
+set(PYTHONPATH "${PYLIB_DIR}\\;$\\;${pythoncore}\\;${pythoncorezip}")
+
+set(extra_paths "${MO2_INSTALL_PATH}/bin/dlls")
+string(APPEND extra_paths "\\;${PYTHON_ROOT}/PCbuild/amd64")
+string(APPEND extra_paths "\\;$")
+set_tests_properties(${pythonrunner-tests_gtests}
+ PROPERTIES
+ WORKING_DIRECTORY "${MO2_INSTALL_PATH}/bin"
+ ENVIRONMENT "PLUGIN_DIR=${CMAKE_CURRENT_SOURCE_DIR}/plugins"
+ ENVIRONMENT_MODIFICATION
+ "PATH=path_list_prepend:${extra_paths};PYTHONPATH=set:${PYTHONPATH}")
diff --git a/tests/runner/plugins/dummy-iplugin.py b/tests/runner/plugins/dummy-iplugin.py
new file mode 100644
index 0000000..0d85001
--- /dev/null
+++ b/tests/runner/plugins/dummy-iplugin.py
@@ -0,0 +1,31 @@
+# -*- encoding: utf-8 -*-
+
+import mobase
+
+
+class DummyPlugin(mobase.IPlugin):
+ def init(self, organizer: mobase.IOrganizer) -> bool:
+ return True
+
+ def author(self) -> str:
+ return "The Author"
+
+ def name(self) -> str:
+ return "The Name"
+
+ def description(self) -> str:
+ return "The Description"
+
+ def version(self) -> mobase.VersionInfo:
+ return mobase.VersionInfo("1.3.0")
+
+ def settings(self) -> list[mobase.PluginSetting]:
+ return [
+ mobase.PluginSetting(
+ "a setting", "the setting description", default_value=12
+ )
+ ]
+
+
+def createPlugin() -> mobase.IPlugin:
+ return DummyPlugin()
diff --git a/tests/runner/test_iplugin.cpp b/tests/runner/test_iplugin.cpp
new file mode 100644
index 0000000..94e29a5
--- /dev/null
+++ b/tests/runner/test_iplugin.cpp
@@ -0,0 +1,42 @@
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+
+#include "MockOrganizer.h"
+#include "iplugin.h"
+#include "pythonrunner.h"
+
+#include
+
+using namespace MOBase;
+
+TEST(IPlugin, Basic)
+{
+ const auto plugins_folder = QString(std::getenv("PLUGIN_DIR"));
+
+ std::unique_ptr runner(CreatePythonRunner());
+ runner->initialize();
+
+ // load objects
+ const auto objects = runner->load(plugins_folder + "/dummy-iplugin.py");
+ EXPECT_EQ(objects.size(), 1);
+
+ // load the IPlugin
+ const IPlugin* plugin = qobject_cast(objects[0]);
+ EXPECT_NE(plugin, nullptr);
+
+ EXPECT_EQ(plugin->author(), "The Author");
+ EXPECT_EQ(plugin->name(), "The Name");
+ EXPECT_EQ(plugin->version(), VersionInfo(1, 3, 0));
+ EXPECT_EQ(plugin->description(), "The Description");
+
+ // settings
+ const auto settings = plugin->settings();
+ EXPECT_EQ(settings.size(), 1);
+ EXPECT_EQ(settings[0].key, "a setting");
+ EXPECT_EQ(settings[0].description, "the setting description");
+ EXPECT_EQ(settings[0].defaultValue.userType(), QVariant::Type::Int);
+ EXPECT_EQ(settings[0].defaultValue.toInt(), 12);
+
+ // no translation, no custom implementation -> name()
+ EXPECT_EQ(plugin->localizedName(), "The Name");
+}
diff --git a/tests/runner/test_lifetime.cpp b/tests/runner/test_lifetime.cpp
new file mode 100644
index 0000000..1eb92db
--- /dev/null
+++ b/tests/runner/test_lifetime.cpp
@@ -0,0 +1,47 @@
+#include "gtest/gtest.h"
+
+#include "MockOrganizer.h"
+#include "pythonrunner.h"
+
+#include
+
+TEST(Lifetime, Plugins)
+{
+ const auto plugins_folder = QString(std::getenv("PLUGIN_DIR"));
+
+ std::unique_ptr runner(CreatePythonRunner());
+ runner->initialize();
+
+ {
+ const auto objects = runner->load(plugins_folder + "/dummy-iplugin.py");
+
+ // we found one plugin
+ EXPECT_EQ(objects.size(), 1);
+
+ // check that deleting the object actually destroys it
+ bool destroyed = false;
+ QObject::connect(objects[0], &QObject::destroyed, [&destroyed]() {
+ destroyed = true;
+ });
+ delete objects[0];
+ EXPECT_EQ(destroyed, true);
+ }
+
+ // same things but with a parent
+ {
+ QObject* dummy_parent = new QObject();
+ const auto objects = runner->load(plugins_folder + "/dummy-iplugin.py");
+
+ // we found one plugin
+ EXPECT_EQ(objects.size(), 1);
+ objects[0]->setParent(dummy_parent);
+
+ // check that deleting the object actually destroys it
+ bool destroyed = false;
+ QObject::connect(objects[0], &QObject::destroyed, [&destroyed]() {
+ destroyed = true;
+ });
+ delete dummy_parent;
+ EXPECT_EQ(destroyed, true);
+ }
+}
diff --git a/tests/runner/test_organizer.cpp b/tests/runner/test_organizer.cpp
deleted file mode 100644
index ddd5cae..0000000
--- a/tests/runner/test_organizer.cpp
+++ /dev/null
@@ -1,13 +0,0 @@
-#include "gmock/gmock.h"
-#include "gtest/gtest.h"
-
-#include "MockOrganizer.h"
-
-using ::testing::Eq;
-using ::testing::NaggyMock;
-using ::testing::Return;
-
-TEST(Organizer, Basic)
-{
- MockOrganizer mock;
-}