aboutsummaryrefslogtreecommitdiff
path: root/libs/plugin_python
diff options
context:
space:
mode:
authorSulfurNitride <SulfurNitride@users.noreply.github.com>2026-02-11 02:37:39 -0600
committerSulfurNitride <SulfurNitride@users.noreply.github.com>2026-02-11 02:37:39 -0600
commit7ee008e150bc5bcf76082d726f719ee0fdfda982 (patch)
tree27fb39be241fdb5ac2734c574de678977d1856d0 /libs/plugin_python
Fluorine Manager: full Linux port of Mod Organizer 2
Complete native Linux port with FUSE-based virtual filesystem, Proton/umu-run integration, and Flatpak packaging. Key features: - FUSE VFS replacing Windows USVFS (in-process + standalone helper for Flatpak) - Proton/GE-Proton/umu-run launcher with env var forwarding - Flatpak support (sandbox-aware VFS, NXM handler, umu-run) - Wine prefix management UI - Case-insensitive path resolution for Linux filesystems - QSettings-safe INI handling (avoids Bethesda INI corruption) - Portable instance support with auto-generated launcher scripts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'libs/plugin_python')
-rw-r--r--libs/plugin_python/.clang-format21
-rw-r--r--libs/plugin_python/.git-blame-ignore-revs2
-rw-r--r--libs/plugin_python/.gitattributes8
-rw-r--r--libs/plugin_python/.github/workflows/build-and-test.yml38
-rw-r--r--libs/plugin_python/.github/workflows/linting.yml24
-rw-r--r--libs/plugin_python/.gitignore12
-rw-r--r--libs/plugin_python/.pre-commit-config.yaml20
-rw-r--r--libs/plugin_python/CMakeLists.txt51
-rw-r--r--libs/plugin_python/CMakePresets.json73
-rw-r--r--libs/plugin_python/README.md68
-rw-r--r--libs/plugin_python/poetry.lock254
-rw-r--r--libs/plugin_python/pyproject.toml53
-rw-r--r--libs/plugin_python/src/CMakeLists.txt8
-rw-r--r--libs/plugin_python/src/mobase/CMakeLists.txt40
-rw-r--r--libs/plugin_python/src/mobase/README.md110
-rw-r--r--libs/plugin_python/src/mobase/deprecation.cpp55
-rw-r--r--libs/plugin_python/src/mobase/deprecation.h27
-rw-r--r--libs/plugin_python/src/mobase/mobase.cpp86
-rw-r--r--libs/plugin_python/src/mobase/pybind11_all.h146
-rw-r--r--libs/plugin_python/src/mobase/wrappers/basic_classes.cpp952
-rw-r--r--libs/plugin_python/src/mobase/wrappers/game_features.cpp422
-rw-r--r--libs/plugin_python/src/mobase/wrappers/known_folders.h155
-rw-r--r--libs/plugin_python/src/mobase/wrappers/pyfiletree.cpp329
-rw-r--r--libs/plugin_python/src/mobase/wrappers/pyfiletree.h34
-rw-r--r--libs/plugin_python/src/mobase/wrappers/pyplugins.cpp262
-rw-r--r--libs/plugin_python/src/mobase/wrappers/pyplugins.h527
-rw-r--r--libs/plugin_python/src/mobase/wrappers/utils.cpp50
-rw-r--r--libs/plugin_python/src/mobase/wrappers/widgets.cpp77
-rw-r--r--libs/plugin_python/src/mobase/wrappers/wrappers.cpp120
-rw-r--r--libs/plugin_python/src/mobase/wrappers/wrappers.h109
-rw-r--r--libs/plugin_python/src/plugin_python_en.ts83
-rw-r--r--libs/plugin_python/src/proxy/CMakeLists.txt118
-rw-r--r--libs/plugin_python/src/proxy/build_pythoncore.py15
-rw-r--r--libs/plugin_python/src/proxy/proxypython.cpp368
-rw-r--r--libs/plugin_python/src/proxy/proxypython.h80
-rw-r--r--libs/plugin_python/src/pybind11-qt/CMakeLists.txt60
-rw-r--r--libs/plugin_python/src/pybind11-qt/README.md64
-rw-r--r--libs/plugin_python/src/pybind11-qt/include/pybind11_qt/details/pybind11_qt_enum.h59
-rw-r--r--libs/plugin_python/src/pybind11-qt/include/pybind11_qt/details/pybind11_qt_qlist.h52
-rw-r--r--libs/plugin_python/src/pybind11-qt/include/pybind11_qt/details/pybind11_qt_qmap.h70
-rw-r--r--libs/plugin_python/src/pybind11-qt/include/pybind11_qt/details/pybind11_qt_sip.h221
-rw-r--r--libs/plugin_python/src/pybind11-qt/include/pybind11_qt/details/pybind11_qt_utils.h47
-rw-r--r--libs/plugin_python/src/pybind11-qt/include/pybind11_qt/pybind11_qt.h88
-rw-r--r--libs/plugin_python/src/pybind11-qt/include/pybind11_qt/pybind11_qt_basic.h36
-rw-r--r--libs/plugin_python/src/pybind11-qt/include/pybind11_qt/pybind11_qt_containers.h51
-rw-r--r--libs/plugin_python/src/pybind11-qt/include/pybind11_qt/pybind11_qt_enums.h115
-rw-r--r--libs/plugin_python/src/pybind11-qt/include/pybind11_qt/pybind11_qt_holder.h59
-rw-r--r--libs/plugin_python/src/pybind11-qt/include/pybind11_qt/pybind11_qt_objects.h62
-rw-r--r--libs/plugin_python/src/pybind11-qt/include/pybind11_qt/pybind11_qt_qflags.h56
-rw-r--r--libs/plugin_python/src/pybind11-qt/pybind11_qt_basic.cpp156
-rw-r--r--libs/plugin_python/src/pybind11-qt/pybind11_qt_sip.cpp105
-rw-r--r--libs/plugin_python/src/pybind11-qt/pybind11_qt_utils.cpp11
-rw-r--r--libs/plugin_python/src/pybind11-utils/CMakeLists.txt23
-rw-r--r--libs/plugin_python/src/pybind11-utils/README.md50
-rw-r--r--libs/plugin_python/src/pybind11-utils/functional.cpp28
-rw-r--r--libs/plugin_python/src/pybind11-utils/include/pybind11_utils/functional.h155
-rw-r--r--libs/plugin_python/src/pybind11-utils/include/pybind11_utils/generator.h57
-rw-r--r--libs/plugin_python/src/pybind11-utils/include/pybind11_utils/shared_cpp_owner.h90
-rw-r--r--libs/plugin_python/src/pybind11-utils/include/pybind11_utils/smart_variant.h53
-rw-r--r--libs/plugin_python/src/pybind11-utils/include/pybind11_utils/smart_variant_wrapper.h180
-rw-r--r--libs/plugin_python/src/runner/CMakeLists.txt34
-rw-r--r--libs/plugin_python/src/runner/error.h82
-rw-r--r--libs/plugin_python/src/runner/pythonrunner.cpp415
-rw-r--r--libs/plugin_python/src/runner/pythonrunner.h53
-rw-r--r--libs/plugin_python/src/runner/pythonutils.cpp155
-rw-r--r--libs/plugin_python/src/runner/pythonutils.h25
-rw-r--r--libs/plugin_python/tests/CMakeLists.txt4
-rw-r--r--libs/plugin_python/tests/mocks/DummyFileTree.h34
-rw-r--r--libs/plugin_python/tests/mocks/MockOrganizer.h65
-rw-r--r--libs/plugin_python/tests/python/CMakeLists.txt75
-rw-r--r--libs/plugin_python/tests/python/conftest.py12
-rw-r--r--libs/plugin_python/tests/python/test_argument_wrapper.cpp61
-rw-r--r--libs/plugin_python/tests/python/test_argument_wrapper.py62
-rw-r--r--libs/plugin_python/tests/python/test_filetree.cpp22
-rw-r--r--libs/plugin_python/tests/python/test_filetree.py86
-rw-r--r--libs/plugin_python/tests/python/test_functional.cpp38
-rw-r--r--libs/plugin_python/tests/python/test_functional.py55
-rw-r--r--libs/plugin_python/tests/python/test_guessed_string.cpp34
-rw-r--r--libs/plugin_python/tests/python/test_guessed_string.py36
-rw-r--r--libs/plugin_python/tests/python/test_organizer.cpp46
-rw-r--r--libs/plugin_python/tests/python/test_organizer.py14
-rw-r--r--libs/plugin_python/tests/python/test_path_wrappers.py45
-rw-r--r--libs/plugin_python/tests/python/test_qt.cpp155
-rw-r--r--libs/plugin_python/tests/python/test_qt.py119
-rw-r--r--libs/plugin_python/tests/python/test_qt_widgets.cpp85
-rw-r--r--libs/plugin_python/tests/python/test_qt_widgets.py62
-rw-r--r--libs/plugin_python/tests/python/test_shared_cpp_owner.cpp67
-rw-r--r--libs/plugin_python/tests/python/test_shared_cpp_owner.py72
-rw-r--r--libs/plugin_python/tests/runner/CMakeLists.txt65
-rw-r--r--libs/plugin_python/tests/runner/plugins/dummy-diagnose.py37
-rw-r--r--libs/plugin_python/tests/runner/plugins/dummy-filemapper.py32
-rw-r--r--libs/plugin_python/tests/runner/plugins/dummy-game.py45
-rw-r--r--libs/plugin_python/tests/runner/plugins/dummy-installer.py46
-rw-r--r--libs/plugin_python/tests/runner/plugins/dummy-iplugin.py29
-rw-r--r--libs/plugin_python/tests/runner/test_diagnose.cpp61
-rw-r--r--libs/plugin_python/tests/runner/test_filemapper.cpp64
-rw-r--r--libs/plugin_python/tests/runner/test_game.cpp28
-rw-r--r--libs/plugin_python/tests/runner/test_installer.cpp112
-rw-r--r--libs/plugin_python/tests/runner/test_iplugin.cpp43
-rw-r--r--libs/plugin_python/tests/runner/test_lifetime.cpp47
-rw-r--r--libs/plugin_python/typings/generate.py38
-rw-r--r--libs/plugin_python/typings/mobase_tests/argument_wrapper.pyi27
-rw-r--r--libs/plugin_python/typings/mobase_tests/filetree.pyi9
-rw-r--r--libs/plugin_python/typings/mobase_tests/functional.pyi19
-rw-r--r--libs/plugin_python/typings/mobase_tests/guessed_string.pyi16
-rw-r--r--libs/plugin_python/typings/mobase_tests/organizer.pyi7
-rw-r--r--libs/plugin_python/typings/mobase_tests/qt.pyi109
-rw-r--r--libs/plugin_python/typings/mobase_tests/qt_widgets.pyi34
-rw-r--r--libs/plugin_python/typings/mobase_tests/shared_cpp_owner.pyi22
-rw-r--r--libs/plugin_python/vcpkg.json34
110 files changed, 9682 insertions, 0 deletions
diff --git a/libs/plugin_python/.clang-format b/libs/plugin_python/.clang-format
new file mode 100644
index 0000000..c040316
--- /dev/null
+++ b/libs/plugin_python/.clang-format
@@ -0,0 +1,21 @@
+---
+# We'll use defaults from the LLVM style, but with 4 columns indentation.
+BasedOnStyle: LLVM
+IndentWidth: 4
+---
+Language: Cpp
+# Force pointers to the type for C++.
+DerivePointerAlignment: false
+PointerAlignment: Left
+AlignConsecutiveAssignments: true
+AllowShortFunctionsOnASingleLine: Inline
+AllowShortIfStatementsOnASingleLine: Never
+AllowShortLambdasOnASingleLine: Empty
+AlwaysBreakTemplateDeclarations: Yes
+AccessModifierOffset: -4
+AlignTrailingComments: true
+SpacesBeforeTrailingComments: 2
+NamespaceIndentation: All
+MaxEmptyLinesToKeep: 1
+BreakBeforeBraces: Stroustrup
+ColumnLimit: 88
diff --git a/libs/plugin_python/.git-blame-ignore-revs b/libs/plugin_python/.git-blame-ignore-revs
new file mode 100644
index 0000000..6f8a3f9
--- /dev/null
+++ b/libs/plugin_python/.git-blame-ignore-revs
@@ -0,0 +1,2 @@
+1bffcfa8bfba93a9505692b11fdd3f0903983542
+7108d59267b0fe4e9442f9bb146ea5d70e7704a8
diff --git a/libs/plugin_python/.gitattributes b/libs/plugin_python/.gitattributes
new file mode 100644
index 0000000..e613fd7
--- /dev/null
+++ b/libs/plugin_python/.gitattributes
@@ -0,0 +1,8 @@
+# Set the default behavior, in case people don't have core.autocrlf set.
+* text=auto
+
+# Explicitly declare text files you want to always be normalized and converted
+# to native line endings on checkout.
+*.cpp text eol=crlf
+*.h text eol=crlf
+*.py text eol=crlf
diff --git a/libs/plugin_python/.github/workflows/build-and-test.yml b/libs/plugin_python/.github/workflows/build-and-test.yml
new file mode 100644
index 0000000..57e7475
--- /dev/null
+++ b/libs/plugin_python/.github/workflows/build-and-test.yml
@@ -0,0 +1,38 @@
+name: Build & Test Plugin Python
+
+on:
+ push:
+ branches: [master]
+ pull_request:
+ types: [opened, synchronize, reopened]
+
+env:
+ VCPKG_BINARY_SOURCES: ${{ vars.AZ_BLOB_VCPKG_URL != '' && format('clear;x-azblob,{0},{1},readwrite', vars.AZ_BLOB_VCPKG_URL, secrets.AZ_BLOB_SAS) || '' }}
+
+jobs:
+ build:
+ runs-on: windows-2022
+ steps:
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+
+ - name: Configure Plugin Python
+ id: configure-plugin-python
+ uses: ModOrganizer2/build-with-mob-action@master
+ with:
+ mo2-dependencies: uibase
+ mo2-skip-build: true
+
+ - name: Build Plugin Python
+ working-directory: ${{ steps.configure-plugin-python.outputs.working-directory }}
+ run: cmake --build vsbuild --config RelWithDebInfo --verbose `
+ --target python-tests --target runner-tests --target proxy
+
+ - name: Test Plugin Python
+ working-directory: ${{ steps.configure-plugin-python.outputs.working-directory }}
+ run: ctest --test-dir vsbuild -C RelWithDebInfo --output-on-failure
+
+ - name: Install Plugin Python
+ working-directory: ${{ steps.configure-plugin-python.outputs.working-directory }}
+ run: cmake --build vsbuild --config RelWithDebInfo --target INSTALL
diff --git a/libs/plugin_python/.github/workflows/linting.yml b/libs/plugin_python/.github/workflows/linting.yml
new file mode 100644
index 0000000..70b5bca
--- /dev/null
+++ b/libs/plugin_python/.github/workflows/linting.yml
@@ -0,0 +1,24 @@
+name: Lint Plugin Python
+
+on:
+ push:
+ pull_request:
+ types: [opened, synchronize, reopened]
+
+jobs:
+ lint:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+ - name: Check format
+ uses: ModOrganizer2/check-formatting-action@master
+ with:
+ check-path: "."
+ - uses: actions/setup-python@v4
+ with:
+ python-version: "3.12"
+ - uses: abatilo/actions-poetry@v2
+ - name: Check format Python tests
+ run: |
+ poetry install --no-root
+ poetry run poe lint
diff --git a/libs/plugin_python/.gitignore b/libs/plugin_python/.gitignore
new file mode 100644
index 0000000..5fc3c13
--- /dev/null
+++ b/libs/plugin_python/.gitignore
@@ -0,0 +1,12 @@
+# build
+edit
+CMakeLists.txt.user
+/msbuild.log
+/*std*.log
+/*build
+
+# python
+tests/**/__pycache__
+
+# IDE
+.vscode
diff --git a/libs/plugin_python/.pre-commit-config.yaml b/libs/plugin_python/.pre-commit-config.yaml
new file mode 100644
index 0000000..eb8969c
--- /dev/null
+++ b/libs/plugin_python/.pre-commit-config.yaml
@@ -0,0 +1,20 @@
+repos:
+ - repo: https://github.com/pre-commit/pre-commit-hooks
+ rev: v6.0.0
+ hooks:
+ - id: trailing-whitespace
+ - id: end-of-file-fixer
+ - id: check-merge-conflict
+ - id: check-case-conflict
+ - repo: https://github.com/pre-commit/mirrors-clang-format
+ rev: v21.1.8
+ hooks:
+ - id: clang-format
+ 'types_or': [c++, c]
+
+ci:
+ autofix_commit_msg: "[pre-commit.ci] Auto fixes from pre-commit.com hooks."
+ autofix_prs: true
+ autoupdate_commit_msg: "[pre-commit.ci] Pre-commit autoupdate."
+ autoupdate_schedule: quarterly
+ submodules: false
diff --git a/libs/plugin_python/CMakeLists.txt b/libs/plugin_python/CMakeLists.txt
new file mode 100644
index 0000000..411a82e
--- /dev/null
+++ b/libs/plugin_python/CMakeLists.txt
@@ -0,0 +1,51 @@
+cmake_minimum_required(VERSION 3.16)
+
+cmake_policy(SET CMP0144 NEW)
+
+project(plugin_python CXX)
+set(MO2_QT_VERSION_MAJOR 6)
+
+find_package(Python COMPONENTS Interpreter Development REQUIRED)
+find_package(pybind11 CONFIG REQUIRED)
+
+get_filename_component(Python_HOME ${Python_EXECUTABLE} PATH)
+set(Python_DLL_DIR "${Python_HOME}")
+execute_process(
+ COMMAND ${Python_EXECUTABLE} -c "import sysconfig; print(sysconfig.get_path('stdlib'))"
+ OUTPUT_VARIABLE Python_LIB_DIR
+ OUTPUT_STRIP_TRAILING_WHITESPACE
+)
+execute_process(
+ COMMAND ${Python_EXECUTABLE} -c "import sysconfig; print(sysconfig.get_path('purelib'))"
+ OUTPUT_VARIABLE Python_PURELIB_DIR
+ OUTPUT_STRIP_TRAILING_WHITESPACE
+)
+execute_process(
+ COMMAND ${Python_EXECUTABLE} -c "import sysconfig; print(sysconfig.get_path('platlib'))"
+ OUTPUT_VARIABLE Python_PLATLIB_DIR
+ OUTPUT_STRIP_TRAILING_WHITESPACE
+)
+
+set(Python_SHARED_LIBRARY "")
+if(DEFINED Python_LIBRARIES AND Python_LIBRARIES)
+ list(GET Python_LIBRARIES 0 Python_SHARED_LIBRARY)
+elseif(DEFINED Python_LIBRARY_RELEASE AND Python_LIBRARY_RELEASE)
+ set(Python_SHARED_LIBRARY "${Python_LIBRARY_RELEASE}")
+elseif(DEFINED Python_LIBRARY AND Python_LIBRARY)
+ set(Python_SHARED_LIBRARY "${Python_LIBRARY}")
+endif()
+
+mo2_python_install_pyqt()
+
+# useful for naming DLL, zip, etc. (3.10 -> 310)
+set(Python_VERSION_SHORT ${Python_VERSION_MAJOR}${Python_VERSION_MINOR})
+
+# projects
+add_subdirectory(src)
+
+# tests (if requested)
+set(BUILD_TESTING ${BUILD_TESTING} CACHE BOOL "build tests for plugin_python")
+if (BUILD_TESTING)
+ enable_testing()
+ add_subdirectory(tests)
+endif()
diff --git a/libs/plugin_python/CMakePresets.json b/libs/plugin_python/CMakePresets.json
new file mode 100644
index 0000000..6293933
--- /dev/null
+++ b/libs/plugin_python/CMakePresets.json
@@ -0,0 +1,73 @@
+{
+ "configurePresets": [
+ {
+ "errors": {
+ "deprecated": true
+ },
+ "hidden": true,
+ "name": "cmake-dev",
+ "warnings": {
+ "deprecated": true,
+ "dev": true
+ }
+ },
+ {
+ "cacheVariables": {
+ "VCPKG_MANIFEST_NO_DEFAULT_FEATURES": {
+ "type": "BOOL",
+ "value": "ON"
+ }
+ },
+ "toolchainFile": "$env{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake",
+ "hidden": true,
+ "name": "vcpkg"
+ },
+ {
+ "cacheVariables": {
+ "VCPKG_MANIFEST_FEATURES": {
+ "type": "STRING",
+ "value": "testing"
+ }
+ },
+ "hidden": true,
+ "inherits": ["vcpkg"],
+ "name": "vcpkg-dev"
+ },
+ {
+ "binaryDir": "${sourceDir}/vsbuild",
+ "architecture": {
+ "strategy": "set",
+ "value": "x64"
+ },
+ "cacheVariables": {
+ "CMAKE_CXX_FLAGS": "/EHsc /MP /W4",
+ "VCPKG_TARGET_TRIPLET": {
+ "type": "STRING",
+ "value": "x64-windows-static-md"
+ }
+ },
+ "generator": "Visual Studio 17 2022",
+ "inherits": ["cmake-dev", "vcpkg-dev"],
+ "name": "vs2022-windows",
+ "toolset": "v143"
+ },
+ {
+ "cacheVariables": {
+ "VCPKG_MANIFEST_FEATURES": {
+ "type": "STRING",
+ "value": "standalone;testing"
+ }
+ },
+ "inherits": "vs2022-windows",
+ "name": "vs2022-windows-standalone"
+ }
+ ],
+ "buildPresets": [
+ {
+ "name": "vs2022-windows",
+ "resolvePackageReferences": "on",
+ "configurePreset": "vs2022-windows"
+ }
+ ],
+ "version": 4
+}
diff --git a/libs/plugin_python/README.md b/libs/plugin_python/README.md
new file mode 100644
index 0000000..9085360
--- /dev/null
+++ b/libs/plugin_python/README.md
@@ -0,0 +1,68 @@
+# ModOrganizer2 - Python Proxy
+
+This repository contains the Python proxy plugin for ModOrganizer2.
+The proxy plugin allow developers to write Python plugins for ModOrganizer2.
+
+## Setup, build, tests
+
+This repository is part of MO2 main repositories and should usually be build using
+[`mob`](https://github.com/ModOrganizer2/mob).
+
+## Organization
+
+This repositories contains 5 sub-projects in `src`.
+The interface between Python and C++ is done using the
+[`pybind11`](https://github.com/pybind/pybind11) library.
+See the `README` in the subfolder (when there is one) for more details.
+
+- [`src/proxy`](src/proxy/) contains the actual proxy plugin.
+ This project is a simple interface between MO2 and the runner (see below).
+ The CMake code:
+ - generates the `plugin_python.dll` library,
+ - generates the translation file (under `src/`),
+ - installs necessary files for the plugin (Python DLL, Python libraries, etc),
+ including `mobase`.
+- [`src/runner`](src/runner/) contains the Python runner. This is the project that
+ instantiates a Python interpreter and load/unload Python plugins.
+- [`src/pybind11-qt`](src/pybind11-qt/) contains many utility stuff to interface
+ pybind11 with Qt and PyQt.
+- [`src/pybind11-utils`](src/pybind11-utils/) contains some utility stuff pybind11.
+ This project is header-only.
+- [`src/mobase`](src/mobase) contains the Python plugin interface.
+ - This projects generates the `mobase` Python library.
+
+Some (woefully incomplete) tests are available under `tests`, split in three
+sub-directories:
+
+- [`tests/mocks`](tests/mocks/) simply contains mocks of `uibase` interfaces to be used
+ in the two other test projects.
+- [`tests/python`](tests/python/) contains Python tests for `pytest`.
+ This project generates a bunch of Python test libraries that are then imported in
+ Python test files (`test_*.py`) and tested using `pytest`.
+ These tests mostly cover the pybind11 Qt and utility stuff, and some standalone
+ MO2 classes and functions (`IFileTree`, `GuessedString`, etc).
+- [`tests/runner`](tests/runner/) contains C++ tests, using GTest
+ Tests in this project instantiate a Python runner and then use it to check that
+ plugins implemented in Python can be used properly on the C++ side.
+
+## Building & Running tests
+
+Tests are not built by default with `mob`, so you will need to run `cmake` manually
+with the proper arguments.
+
+You need to define `PLUGIN_PYTHON_TESTS` with `-DPLUGIN_PYTHON_TESTS` when running
+the configure step of cmake.
+
+You can then build the tests
+
+```bash
+# replace vsbuild with your build folder
+cmake --build vsbuild --config RelWithDebInfo --target "python-tests" "runner-tests"
+```
+
+To run the tests, use `ctest`
+
+```bash
+# replace vsbuild with your build folder
+ctest.exe --test-dir vsbuild -C RelWithDebInfo
+```
diff --git a/libs/plugin_python/poetry.lock b/libs/plugin_python/poetry.lock
new file mode 100644
index 0000000..0ceb7f6
--- /dev/null
+++ b/libs/plugin_python/poetry.lock
@@ -0,0 +1,254 @@
+# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand.
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+description = "Cross-platform colored terminal text."
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
+files = [
+ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
+ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.0.0"
+description = "brain-dead simple config-ini parsing"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"},
+ {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"},
+]
+
+[[package]]
+name = "mobase-stubs"
+version = "2.5.1a0"
+description = "PEP561 stub files for the mobase Python API."
+optional = false
+python-versions = "<4.0,>=3.12"
+files = [
+ {file = "mobase_stubs-2.5.1a0-py3-none-any.whl", hash = "sha256:bcaecfae038b890d82280fc518f7e44f38d22d35801a8ba7ffa480f7756d6823"},
+ {file = "mobase_stubs-2.5.1a0.tar.gz", hash = "sha256:a8dc5574336ed3b1f673288447781f705a078472cf8808e05a36f129c81c8e20"},
+]
+
+[[package]]
+name = "nodeenv"
+version = "1.9.1"
+description = "Node.js virtual environment builder"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
+files = [
+ {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"},
+ {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"},
+]
+
+[[package]]
+name = "packaging"
+version = "24.1"
+description = "Core utilities for Python packages"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"},
+ {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"},
+]
+
+[[package]]
+name = "pastel"
+version = "0.2.1"
+description = "Bring colors to your terminal."
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+files = [
+ {file = "pastel-0.2.1-py2.py3-none-any.whl", hash = "sha256:4349225fcdf6c2bb34d483e523475de5bb04a5c10ef711263452cb37d7dd4364"},
+ {file = "pastel-0.2.1.tar.gz", hash = "sha256:e6581ac04e973cac858828c6202c1e1e81fee1dc7de7683f3e1ffe0bfd8a573d"},
+]
+
+[[package]]
+name = "pluggy"
+version = "1.5.0"
+description = "plugin and hook calling mechanisms for python"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"},
+ {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"},
+]
+
+[package.extras]
+dev = ["pre-commit", "tox"]
+testing = ["pytest", "pytest-benchmark"]
+
+[[package]]
+name = "poethepoet"
+version = "0.23.0"
+description = "A task runner that works well with poetry."
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "poethepoet-0.23.0-py3-none-any.whl", hash = "sha256:d573ff31d7678e62b6f9bc9a1291ae2009ac14e0eead0a450598f9f05abb27a3"},
+ {file = "poethepoet-0.23.0.tar.gz", hash = "sha256:62a0a6a518df5985c191aee0c1fcd2bb6a0a04eb102997786fcdf118e4147d22"},
+]
+
+[package.dependencies]
+pastel = ">=0.2.1,<0.3.0"
+tomli = ">=1.2.2"
+
+[package.extras]
+poetry-plugin = ["poetry (>=1.0,<2.0)"]
+
+[[package]]
+name = "pybind11-stubgen"
+version = "2.5.1"
+description = "PEP 561 type stubs generator for pybind11 modules"
+optional = false
+python-versions = "~=3.7"
+files = [
+ {file = "pybind11-stubgen-2.5.1.tar.gz", hash = "sha256:4427a67038a00c5ac1637ffa6c65728c67c5b1251ecc23c7704152be0b14cc0b"},
+ {file = "pybind11_stubgen-2.5.1-py3-none-any.whl", hash = "sha256:544d49df57da827c8761e7f6ef6bca996df80a33c9fd21c2521d694d4e72fe8d"},
+]
+
+[[package]]
+name = "pyqt6"
+version = "6.7.0"
+description = "Python bindings for the Qt cross platform application toolkit"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "PyQt6-6.7.0-1-cp38-abi3-macosx_10_14_universal2.whl", hash = "sha256:656734112853fde1be0963f0ad362e5efd87ba6c6ff234cb1f9fe8003ee254e6"},
+ {file = "PyQt6-6.7.0-1-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:fa2d27fc2f5340f3f1e145c815101ef4550771a9e4bfafd4c7c2479fe83d9488"},
+ {file = "PyQt6-6.7.0-1-cp38-abi3-win_amd64.whl", hash = "sha256:6a1f6dfe03752f888b5e628c208f9fd1a03bda7ebda59ffed8c13580289a1892"},
+ {file = "PyQt6-6.7.0-cp38-abi3-macosx_10_14_universal2.whl", hash = "sha256:919ffb01020ece42209228bf94b4f2c156a6b77cc5a69a90a05e358b0333750b"},
+ {file = "PyQt6-6.7.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:e294f025f94493ee12b66efd6893fab309c9063172bb8a5b184f84dfc1ebcc49"},
+ {file = "PyQt6-6.7.0-cp38-abi3-win_amd64.whl", hash = "sha256:9d8865fb6357dba032002c4554a9648e88f2b4706c929cc51fba58edafad91fc"},
+ {file = "PyQt6-6.7.0.tar.gz", hash = "sha256:3d31b2c59dc378ee26e16586d9469842483588142fc377280aad22aaf2fa6235"},
+]
+
+[package.dependencies]
+PyQt6-Qt6 = ">=6.7.0,<6.8.0"
+PyQt6-sip = ">=13.6,<14"
+
+[[package]]
+name = "pyqt6-qt6"
+version = "6.7.2"
+description = "The subset of a Qt installation needed by PyQt6."
+optional = false
+python-versions = "*"
+files = [
+ {file = "PyQt6_Qt6-6.7.2-py3-none-macosx_10_14_x86_64.whl", hash = "sha256:065415589219a2f364aba29d6a98920bb32810286301acbfa157e522d30369e3"},
+ {file = "PyQt6_Qt6-6.7.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7f817efa86a0e8eda9152c85b73405463fbf3266299090f32bbb2266da540ead"},
+ {file = "PyQt6_Qt6-6.7.2-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:fc93945eaef4536d68bd53566535efcbe78a7c05c2a533790a8fd022bac8bfaa"},
+ {file = "PyQt6_Qt6-6.7.2-py3-none-win_amd64.whl", hash = "sha256:b2d7e5ddb1b9764cd60f1d730fa7bf7a1f0f61b2630967c81761d3d0a5a8a2e0"},
+]
+
+[[package]]
+name = "pyqt6-sip"
+version = "13.6.0"
+description = "The sip module support for PyQt6"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "PyQt6_sip-13.6.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d6b5f699aaed0ac1fcd23e8fbca70d8a77965831b7c1ce474b81b1678817a49d"},
+ {file = "PyQt6_sip-13.6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:8c282062125eea5baf830c6998587d98c50be7c3a817a057fb95fef647184012"},
+ {file = "PyQt6_sip-13.6.0-cp310-cp310-win32.whl", hash = "sha256:fa759b6339ff7e25f9afe2a6b651b775f0a36bcb3f5fa85e81a90d3b033c83f4"},
+ {file = "PyQt6_sip-13.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:8f9df9f7ccd8a9f0f1d36948c686f03ce1a1281543a3e636b7b7d5e086e1a436"},
+ {file = "PyQt6_sip-13.6.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5b9c6b6f9cfccb48cbb78a59603145a698fb4ffd176764d7083e5bf47631d8df"},
+ {file = "PyQt6_sip-13.6.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:86a7b67c64436e32bffa9c28c9f21bf14a9faa54991520b12c3f6f435f24df7f"},
+ {file = "PyQt6_sip-13.6.0-cp311-cp311-win32.whl", hash = "sha256:58f68a48400e0b3d1ccb18090090299bad26e3aed7ccb7057c65887b79b8aeea"},
+ {file = "PyQt6_sip-13.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:0dfd22cfedd87e96f9d51e0778ca2ba3dc0be83e424e9e0f98f6994d8d9c90f0"},
+ {file = "PyQt6_sip-13.6.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:3bf03e130fbfd75c9c06e687b86ba375410c7a9e835e4e03285889e61dd4b0c4"},
+ {file = "PyQt6_sip-13.6.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:43fb8551796030aae3d66d6e35e277494071ec6172cd182c9569ab7db268a2f5"},
+ {file = "PyQt6_sip-13.6.0-cp312-cp312-win32.whl", hash = "sha256:13885361ca2cb2f5085d50359ba61b3fabd41b139fb58f37332acbe631ef2357"},
+ {file = "PyQt6_sip-13.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:24441032a29791e82beb7dfd76878339058def0e97fdb7c1cea517f3a0e6e96b"},
+ {file = "PyQt6_sip-13.6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:3075d8b325382750829e6cde6971c943352309d35768a4d4da0587459606d562"},
+ {file = "PyQt6_sip-13.6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:a6ce80bc24618d8a41be8ca51ad9f10e8bc4296dd90ab2809573df30a23ae0e5"},
+ {file = "PyQt6_sip-13.6.0-cp38-cp38-win32.whl", hash = "sha256:fa7b10af7488efc5e53b41dd42c0f421bde6c2865a107af7ae259aff9d841da9"},
+ {file = "PyQt6_sip-13.6.0-cp38-cp38-win_amd64.whl", hash = "sha256:9adf672f9114687533a74d5c2d4c03a9a929ad5ad9c3e88098a7da1a440ab916"},
+ {file = "PyQt6_sip-13.6.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:98bf954103b087162fa63b3a78f30b0b63da22fd6450b610ec1b851dbb798228"},
+ {file = "PyQt6_sip-13.6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:39854dba35f8e5a4288da26ecb5f40b4c5ec1932efffb3f49d5ea435a7f37fb3"},
+ {file = "PyQt6_sip-13.6.0-cp39-cp39-win32.whl", hash = "sha256:747f6ca44af81777a2c696bd501bc4815a53ec6fc94d4e25830e10bc1391f8ab"},
+ {file = "PyQt6_sip-13.6.0-cp39-cp39-win_amd64.whl", hash = "sha256:33ea771fe777eb0d1a2c3ef35bcc3f7a286eb3ff09cd5b2fdd3d87d1f392d7e8"},
+ {file = "PyQt6_sip-13.6.0.tar.gz", hash = "sha256:2486e1588071943d4f6657ba09096dc9fffd2322ad2c30041e78ea3f037b5778"},
+]
+
+[[package]]
+name = "pyright"
+version = "1.1.369"
+description = "Command line wrapper for pyright"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "pyright-1.1.369-py3-none-any.whl", hash = "sha256:06d5167a8d7be62523ced0265c5d2f1e022e110caf57a25d92f50fb2d07bcda0"},
+ {file = "pyright-1.1.369.tar.gz", hash = "sha256:ad290710072d021e213b98cc7a2f90ae3a48609ef5b978f749346d1a47eb9af8"},
+]
+
+[package.dependencies]
+nodeenv = ">=1.6.0"
+
+[package.extras]
+all = ["twine (>=3.4.1)"]
+dev = ["twine (>=3.4.1)"]
+
+[[package]]
+name = "pytest"
+version = "8.2.2"
+description = "pytest: simple powerful testing with Python"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "pytest-8.2.2-py3-none-any.whl", hash = "sha256:c434598117762e2bd304e526244f67bf66bbd7b5d6cf22138be51ff661980343"},
+ {file = "pytest-8.2.2.tar.gz", hash = "sha256:de4bb8104e201939ccdc688b27a89a7be2079b22e2bd2b07f806b6ba71117977"},
+]
+
+[package.dependencies]
+colorama = {version = "*", markers = "sys_platform == \"win32\""}
+iniconfig = "*"
+packaging = "*"
+pluggy = ">=1.5,<2.0"
+
+[package.extras]
+dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"]
+
+[[package]]
+name = "ruff"
+version = "0.2.2"
+description = "An extremely fast Python linter and code formatter, written in Rust."
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "ruff-0.2.2-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:0a9efb032855ffb3c21f6405751d5e147b0c6b631e3ca3f6b20f917572b97eb6"},
+ {file = "ruff-0.2.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d450b7fbff85913f866a5384d8912710936e2b96da74541c82c1b458472ddb39"},
+ {file = "ruff-0.2.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ecd46e3106850a5c26aee114e562c329f9a1fbe9e4821b008c4404f64ff9ce73"},
+ {file = "ruff-0.2.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e22676a5b875bd72acd3d11d5fa9075d3a5f53b877fe7b4793e4673499318ba"},
+ {file = "ruff-0.2.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1695700d1e25a99d28f7a1636d85bafcc5030bba9d0578c0781ba1790dbcf51c"},
+ {file = "ruff-0.2.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:b0c232af3d0bd8f521806223723456ffebf8e323bd1e4e82b0befb20ba18388e"},
+ {file = "ruff-0.2.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f63d96494eeec2fc70d909393bcd76c69f35334cdbd9e20d089fb3f0640216ca"},
+ {file = "ruff-0.2.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a61ea0ff048e06de273b2e45bd72629f470f5da8f71daf09fe481278b175001"},
+ {file = "ruff-0.2.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e1439c8f407e4f356470e54cdecdca1bd5439a0673792dbe34a2b0a551a2fe3"},
+ {file = "ruff-0.2.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:940de32dc8853eba0f67f7198b3e79bc6ba95c2edbfdfac2144c8235114d6726"},
+ {file = "ruff-0.2.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:0c126da55c38dd917621552ab430213bdb3273bb10ddb67bc4b761989210eb6e"},
+ {file = "ruff-0.2.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:3b65494f7e4bed2e74110dac1f0d17dc8e1f42faaa784e7c58a98e335ec83d7e"},
+ {file = "ruff-0.2.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1ec49be4fe6ddac0503833f3ed8930528e26d1e60ad35c2446da372d16651ce9"},
+ {file = "ruff-0.2.2-py3-none-win32.whl", hash = "sha256:d920499b576f6c68295bc04e7b17b6544d9d05f196bb3aac4358792ef6f34325"},
+ {file = "ruff-0.2.2-py3-none-win_amd64.whl", hash = "sha256:cc9a91ae137d687f43a44c900e5d95e9617cb37d4c989e462980ba27039d239d"},
+ {file = "ruff-0.2.2-py3-none-win_arm64.whl", hash = "sha256:c9d15fc41e6054bfc7200478720570078f0b41c9ae4f010bcc16bd6f4d1aacdd"},
+ {file = "ruff-0.2.2.tar.gz", hash = "sha256:e62ed7f36b3068a30ba39193a14274cd706bc486fad521276458022f7bccb31d"},
+]
+
+[[package]]
+name = "tomli"
+version = "2.0.1"
+description = "A lil' TOML parser"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"},
+ {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
+]
+
+[metadata]
+lock-version = "2.0"
+python-versions = "^3.12"
+content-hash = "4a3746f1080ab40c4cfde6777517db502e124fe088e971895bd7f7c245c3fa3e"
diff --git a/libs/plugin_python/pyproject.toml b/libs/plugin_python/pyproject.toml
new file mode 100644
index 0000000..71bb9df
--- /dev/null
+++ b/libs/plugin_python/pyproject.toml
@@ -0,0 +1,53 @@
+[tool.poetry]
+name = "modorganizer-plugin_python"
+version = "3.0.0"
+description = ""
+authors = ["Mikaël Capelle <capelle.mikael@gmail.com>"]
+
+[tool.poetry.dependencies]
+python = "^3.12"
+
+[tool.poetry.group.dev.dependencies]
+pyright = "^1.1.369"
+ruff = "^0.2.1"
+poethepoet = "^0.23.0"
+mobase-stubs = { version = "^2.5.1a0", allow-prereleases = true }
+pyqt6 = "^6.7.0"
+pytest = "^8.2.2"
+pybind11-stubgen = "^2.5.1"
+
+[tool.poe.tasks]
+format-imports = "ruff check --select I tests typings --fix"
+format-ruff = "ruff format tests typings"
+format.sequence = ["format-imports", "format-ruff"]
+lint-ruff = "ruff check tests typings"
+lint-ruff-format = "ruff format --check tests typings"
+lint.sequence = ["lint-ruff", "lint-ruff-format"]
+lint.ignore_fail = "return_non_zero"
+
+[tool.ruff]
+target-version = "py312"
+
+[tool.ruff.lint]
+extend-select = ["B", "Q", "I"]
+
+[tool.ruff.lint.isort.sections]
+mobase = ["mobase"]
+mobase_tests = ["mobase_tests"]
+
+[tool.ruff.lint.isort]
+section-order = [
+ "future",
+ "standard-library",
+ "third-party",
+ "first-party",
+ "mobase",
+ "mobase_tests",
+ "local-folder",
+]
+
+[tool.pyright]
+typeCheckingMode = "strict"
+reportMissingTypeStubs = true
+reportMissingModuleSource = false
+pythonPlatform = "Windows"
diff --git a/libs/plugin_python/src/CMakeLists.txt b/libs/plugin_python/src/CMakeLists.txt
new file mode 100644
index 0000000..533a5ab
--- /dev/null
+++ b/libs/plugin_python/src/CMakeLists.txt
@@ -0,0 +1,8 @@
+cmake_minimum_required(VERSION 3.16)
+
+# order matters
+add_subdirectory(pybind11-qt)
+add_subdirectory(pybind11-utils)
+add_subdirectory(mobase)
+add_subdirectory(runner)
+add_subdirectory(proxy)
diff --git a/libs/plugin_python/src/mobase/CMakeLists.txt b/libs/plugin_python/src/mobase/CMakeLists.txt
new file mode 100644
index 0000000..2fac52f
--- /dev/null
+++ b/libs/plugin_python/src/mobase/CMakeLists.txt
@@ -0,0 +1,40 @@
+cmake_minimum_required(VERSION 3.16)
+
+find_package(Qt6 COMPONENTS Core)
+if(NOT TARGET mo2::uibase)
+ find_package(mo2-uibase CONFIG REQUIRED)
+endif()
+
+pybind11_add_module(mobase MODULE)
+mo2_default_source_group()
+mo2_configure_target(mobase
+ NO_SOURCES
+ WARNINGS 4
+ EXTERNAL_WARNINGS 4
+ AUTOMOC ON
+ TRANSLATIONS OFF
+)
+mo2_target_sources(mobase
+ FOLDER src
+ PRIVATE
+ deprecation.cpp
+ deprecation.h
+ mobase.cpp
+ pybind11_all.h
+)
+mo2_target_sources(mobase
+ FOLDER src/wrappers
+ PRIVATE
+ ./wrappers/basic_classes.cpp
+ ./wrappers/game_features.cpp
+ ./wrappers/known_folders.h
+ ./wrappers/pyfiletree.cpp
+ ./wrappers/pyfiletree.h
+ ./wrappers/pyplugins.cpp
+ ./wrappers/pyplugins.h
+ ./wrappers/utils.cpp
+ ./wrappers/widgets.cpp
+ ./wrappers/wrappers.cpp
+ ./wrappers/wrappers.h
+)
+target_link_libraries(mobase PRIVATE pybind11::qt pybind11::utils mo2::uibase Qt6::Core)
diff --git a/libs/plugin_python/src/mobase/README.md b/libs/plugin_python/src/mobase/README.md
new file mode 100644
index 0000000..b4d184e
--- /dev/null
+++ b/libs/plugin_python/src/mobase/README.md
@@ -0,0 +1,110 @@
+# mobase
+
+`mobase` is a ModOrganizer2 Python API.
+It provides access to the part of
+[`uibase`](https://github.com/ModOrganizer2/modorganizer-uibase) C++ API from
+Python through [`pybind11`](https://github.com/pybind/pybind11).
+
+## Organization
+
+**Important:** All (most) files should include `pybind11_all.h` (either directly
+or through another header) to get proper `type_caster` available.
+
+- `mobase.cpp` contains the `PYBIND11_MODULE` definition of `mobase` but is otherwise
+ the entrypoint for other functions.
+- `wrappers.h` contains the declaration of most functions implemented under
+ `wrappers/`.
+- The other files under `wrappers/` contains bindings and trampoline classes (see
+ below) for `uibase` classes.
+ - `basic_classes.cpp` contains the bindings for most classes that cannot be extended
+ in Python (`IOrganizer`, `IModInterface`, etc.)
+ - `game_features.cpp` contains the bindings and trampoline classes for game features.
+ - `pyfiletree.h` and `pyfiletree.cpp` contains bindings for the `IFileTree`-related
+ classes.
+ - `pyplugins.h` contains the trampoline classes for the `IPluginXXX` classes and
+ `pyplugins.cpp` the bindings.
+ - `pyplugins.h` is required since the trampoline classes are tagged with `Q_OBJECT`,
+ and MOC does not work if the classes are declared in a C++ file.
+ - `pyplugins.cpp` also contains the `extract_plugins` function in `mobase.private`
+ that is used to extract plugins from Python object in the runner.
+ - `widgets.cpp` contains the bindings for the widget classes.
+ - `wrappers.cpp` contains the trampoline and bindings for non-plugin classes that can
+ be extended through Python.
+
+## Updating mobase
+
+### Classes that cannot be extended through Python
+
+Updating or adding classes that cannot be extended through Python is quite easily.
+One simply needs to declare the appropriate `py::class_` or add new `.def()`.
+
+See below for things to remember when creating pybind11 bindings.
+
+### Free functions
+
+Similar to classes that cannot be extended through Python, see above.
+
+### Classes that can be extended through Python: Plugins
+
+To extend plugins, simply update the trampoline classes in `pyplugins.h` and the
+bindings in `pyplugins.cpp`.
+
+**Note:** For new plugins, simply look at the existing one.
+
+### Classes that can be extended through Python: Game Features
+
+To extend or expose game features:
+
+- Create (if there is not already one) a trampoline class for the feature in
+ `game_features.cpp`.
+ - Add implementation of missing functions if required.
+- Add the bindings in `add_game_feature_bindings` in `game_features.cpp`.
+- For new feature, add the feature type to `GameFeaturesHelper::GameFeatures` in
+ `game_features.cpp`.
+
+### Classes that can be extended through Python: Others
+
+Non-plugin classes should be added to the `wrappers.cpp` file and should be exposed
+with `std::shared_ptr<>` or `qobject_holder<>` holders.
+
+- If the classes extends `QObject`, use a `qobject_holder`.
+- Otherwise use a `std::shared_ptr<>` holder and add a `MO2_PYBIND11_SHARED_CPP_HOLDER`
+ declaration in `pybind11_all.h`.
+
+Trampoline can be defined directly in `wrappers.cpp`, and bindings in the appropriate
+function.
+See the existing classes for example.
+
+**Important:**
+You need to make sure that `uibase` manipulates such classes through
+`std::shared_ptr<>` (unless those inherit `QObject`).
+Using `std::unique_ptr<>` is not possible since `std::unique_ptr<>` cannot have custom
+runtime-specified deleters.
+
+## Things to remember
+
+Here are a few things to remember when creating bindings:
+
+- If a function has multiple overloads that can conflict in Python, the more complex
+ one must be defined first as pybind11 will try calling them in order.
+- If a C++ function expect a `QString`, `QFileInfo` or `QDir` that represents a file or
+ a directory, wrapping the function with `wrap_for_filepath` or `wrap_for_directory` is
+ a good idea. This allows Python to call the function with `pathlib.Path`.
+- Most of the C++ function taking a reference to modify in C++ cannot be directly
+ exposed in Python since Python cannot modify reference to simple type (e.g.
+ `QString&` or `int&`).
+ The best way to expose such function is to bind a lambda that returns a variant from
+ Python, e.g.
+
+```cpp
+// assume the C++ function is QString fn(QString& foo, QString const& bar, int& maz);
+
+m.def("function", [](QString& foo, QString const& bar, int& maz) {
+ // call the function
+ auto ret = function(foo, bar, maz);
+
+ // make a tuple containing the return value (if there is one), and the modified
+ // values passed by reference
+ return std::make_tuple(ret, foo, maz);
+});
+```
diff --git a/libs/plugin_python/src/mobase/deprecation.cpp b/libs/plugin_python/src/mobase/deprecation.cpp
new file mode 100644
index 0000000..958712d
--- /dev/null
+++ b/libs/plugin_python/src/mobase/deprecation.cpp
@@ -0,0 +1,55 @@
+#include "deprecation.h"
+
+#include <filesystem>
+#include <set>
+
+#include <pybind11/pybind11.h>
+
+#include <QCoreApplication>
+
+#include <uibase/log.h>
+
+namespace py = pybind11;
+
+namespace mo2::python {
+
+ void show_deprecation_warning(std::string_view name, std::string_view message,
+ bool show_once)
+ {
+
+ // Contains the list of filename / line number for which a deprecation
+ // warning has already been shown.
+ static std::set<std::pair<std::string, int>> DeprecatedLines;
+
+ // Find the caller:
+ auto inspect = py::module_::import("inspect");
+ auto current_frame = inspect.attr("currentframe")();
+ py::sequence callable_frame = inspect.attr("getouterframes")(current_frame, 2);
+ auto last_frame = callable_frame[py::int_(-1)];
+ auto filename = last_frame.attr("filename").cast<std::string>();
+ auto function = last_frame.attr("function").cast<std::string>();
+ auto lineno = last_frame.attr("lineno").cast<int>();
+
+ // Only show once if requested:
+ if (show_once && DeprecatedLines.contains({filename, lineno})) {
+ return;
+ }
+
+ // Register the deprecation:
+ DeprecatedLines.emplace(filename, lineno);
+
+ auto path = relative(std::filesystem::path(filename),
+ QCoreApplication::applicationDirPath().toStdWString());
+
+ // Show the message:
+ if (message.empty()) {
+ MOBase::log::warn("[deprecated] {} in {} [{}:{}].", name, function,
+ path.native(), lineno);
+ }
+ else {
+ MOBase::log::warn("[deprecated] {} in {} [{}:{}]: {}", name, function,
+ path.native(), lineno, message);
+ }
+ }
+
+} // namespace mo2::python
diff --git a/libs/plugin_python/src/mobase/deprecation.h b/libs/plugin_python/src/mobase/deprecation.h
new file mode 100644
index 0000000..d9a445c
--- /dev/null
+++ b/libs/plugin_python/src/mobase/deprecation.h
@@ -0,0 +1,27 @@
+#ifndef PYTHONRUNNER_UTILS_H
+#define PYTHONRUNNER_UTILS_H
+
+#include <string_view>
+
+#include <pybind11/pybind11.h>
+
+namespace mo2::python {
+
+ /**
+ * @brief Show a deprecation warning.
+ *
+ * This methods will print a warning in MO2 log containing the location of
+ * the call to the deprecated function. If show_once is true, the
+ * deprecation warning will only be logged the first time the function is
+ * called at this location.
+ *
+ * @param name Name of the deprecated function.
+ * @param message Deprecation message.
+ * @param show_once Only show the message once per call location.
+ */
+ void show_deprecation_warning(std::string_view name, std::string_view message = "",
+ bool show_once = true);
+
+} // namespace mo2::python
+
+#endif
diff --git a/libs/plugin_python/src/mobase/mobase.cpp b/libs/plugin_python/src/mobase/mobase.cpp
new file mode 100644
index 0000000..b1daa2b
--- /dev/null
+++ b/libs/plugin_python/src/mobase/mobase.cpp
@@ -0,0 +1,86 @@
+#pragma warning(disable : 4100)
+#pragma warning(disable : 4996)
+
+#include <format>
+#include <tuple>
+#include <variant>
+
+#include <pybind11/embed.h>
+
+#include "pybind11_all.h"
+
+#include "wrappers/pyfiletree.h"
+#include "wrappers/wrappers.h"
+
+using namespace MOBase;
+namespace py = pybind11;
+
+PYBIND11_MODULE(mobase, m)
+{
+ using namespace mo2::python;
+
+ m.add_object("PyQt6", py::module_::import("PyQt6"));
+ m.add_object("PyQt6.QtCore", py::module_::import("PyQt6.QtCore"));
+ m.add_object("PyQt6.QtGui", py::module_::import("PyQt6.QtGui"));
+ m.add_object("PyQt6.QtWidgets", py::module_::import("PyQt6.QtWidgets"));
+
+ // bindings
+ //
+ mo2::python::add_basic_bindings(m);
+ mo2::python::add_wrapper_bindings(m);
+
+ // game features must be added before plugins
+ mo2::python::add_game_feature_bindings(m);
+ mo2::python::add_igamefeatures_classes(m);
+
+ mo2::python::add_plugins_bindings(m);
+
+ // widgets
+ //
+ py::module_ widgets = m.def_submodule("widgets");
+ mo2::python::add_widget_bindings(widgets);
+
+ // utils
+ //
+ py::module_ utils = m.def_submodule("utils");
+ mo2::python::add_utils_bindings(utils);
+
+ // functions
+ //
+ m.def("getFileVersion", wrap_for_filepath(&MOBase::getFileVersion),
+ py::arg("filepath"));
+ m.def("getProductVersion", wrap_for_filepath(&MOBase::getProductVersion),
+ py::arg("executable"));
+ m.def("getIconForExecutable", wrap_for_filepath(&MOBase::iconForExecutable),
+ py::arg("executable"));
+
+ // typing stuff to be consistent with stubs and allow plugin developers to properly
+ // type their code if they want
+ {
+ m.add_object("TypeVar", py::module_::import("typing").attr("TypeVar"));
+
+ auto s = m.attr("__dict__");
+
+ // expose MoVariant
+ //
+ // this needs to be defined, otherwise MoVariant is not found when actually
+ // running plugins through MO2, making them crash (if plugins use MoVariant in
+ // their own code)
+ //
+ m.add_object(
+ "MoVariant",
+ py::eval("None | bool | int | str | list[object] | dict[str, object]"));
+
+ // same thing for GameFeatureType
+ //
+ m.add_object("GameFeatureType", py::eval("TypeVar('GameFeatureType')", s));
+ }
+
+ // private stuff for debugging/test
+ py::module_ moprivate = m.def_submodule("private");
+
+ // expose a function to create a particular tree, only for debugging
+ // purpose, not in mobase.
+ mo2::python::add_make_tree_function(moprivate);
+ moprivate.def("extract_plugins", &mo2::python::extract_plugins);
+}
diff --git a/libs/plugin_python/src/mobase/pybind11_all.h b/libs/plugin_python/src/mobase/pybind11_all.h
new file mode 100644
index 0000000..c92d7ff
--- /dev/null
+++ b/libs/plugin_python/src/mobase/pybind11_all.h
@@ -0,0 +1,146 @@
+#ifndef PYTHON_PYBIND11_ALL_H
+#define PYTHON_PYBIND11_ALL_H
+
+#include <filesystem>
+
+#include <QDir>
+
+#include <pybind11/operators.h>
+#include <pybind11/pybind11.h>
+#include <pybind11/stl.h>
+#include <pybind11/stl/filesystem.h>
+
+#include "pybind11_qt/pybind11_qt.h"
+
+#include "pybind11_utils/functional.h"
+#include "pybind11_utils/generator.h"
+#include "pybind11_utils/shared_cpp_owner.h"
+#include "pybind11_utils/smart_variant_wrapper.h"
+
+#include <uibase/game_features/game_feature.h>
+#include <uibase/isavegame.h>
+#include <uibase/pluginrequirements.h>
+
+namespace mo2::python {
+
+ namespace detail {
+
+ template <>
+ struct smart_variant_converter<QString> {
+
+ static QString from(std::filesystem::path const& path)
+ {
+ return QString::fromStdString(path.string());
+ }
+
+ static QString from(QFileInfo const& fileInfo)
+ {
+ return fileInfo.filePath();
+ }
+
+ static QString from(QDir const& dir) { return dir.path(); }
+ };
+
+ template <>
+ struct smart_variant_converter<std::filesystem::path> {
+
+ static std::filesystem::path from(QString const& value)
+ {
+ return {value.toStdWString()};
+ }
+
+ static std::filesystem::path from(QFileInfo const& fileInfo)
+ {
+ return fileInfo.filesystemFilePath();
+ }
+
+ static std::filesystem::path from(QDir const& dir)
+ {
+ return dir.filesystemPath();
+ }
+ };
+
+ // we do not need specialization for QFileInfo and QDir because both of them can
+ // be constructed from std::filesystem::path and QString already
+
+ } // namespace detail
+
+ using FileWrapper = smart_variant<QString, std::filesystem::path, QFileInfo>;
+ using DirectoryWrapper = smart_variant<QString, std::filesystem::path, QDir>;
+
+ // wrap the given function to accept FileWrapper (str | PathLike | QFileInfo) at the
+ // given argument positions (or any valid positions if Is... is empty)
+ //
+ template <std::size_t... Is, class Fn>
+ auto wrap_for_filepath(Fn&& fn)
+ {
+ return mo2::python::wrap_arguments<FileWrapper, Is...>(std::forward<Fn>(fn));
+ }
+
+ // wrap the given function to accept DirectoryWrapper (str | PathLike | QDir)
+ // at the given argument positions (or any valid positions if Is... is empty)
+ //
+ template <std::size_t... Is, class Fn>
+ auto wrap_for_directory(Fn&& fn)
+ {
+ return mo2::python::wrap_arguments<DirectoryWrapper, Is...>(
+ std::forward<Fn>(fn));
+ }
+
+ // wrap a function-like object to return a FileWrapper instead of its return type,
+ // useful to generate proper typing in Python
+ //
+ // note that QFileInfo has a __fspath__ in Python, so it is quite easy to convert
+ // from "FileWrapper", a.k.a., str | os.PathLike | QFileInfo to Path by simply
+ // calling Path() on the return type if necessary
+ //
+ // this should be combined with custom return-value in PYBIND11_OVERRIDE(_PURE), see
+ // ISaveGame binding for an example
+ //
+ template <class Fn>
+ auto wrap_return_for_filepath(Fn&& fn)
+ {
+ return mo2::python::wrap_return<FileWrapper>(std::forward<Fn>(fn));
+ }
+
+ // similar to wrap_return_for_filepath, except it returns a DirectoryWrapper instead
+ // of its return type
+ //
+ // this is much less practical than wrap_return_for_filepath since QDir does not
+ // expose __fspath__, so more complex things need to be done in Python, which is why
+ // this should be used carefully (e.g., should not be used if the return type is
+ // already QDir)
+ //
+ template <class Fn>
+ auto wrap_return_for_directory(Fn&& fn)
+ {
+ return mo2::python::wrap_return<DirectoryWrapper>(std::forward<Fn>(fn));
+ }
+
+ // convert a QList to QStringList - QString must be constructible from QString
+ //
+ template <class T>
+ QStringList toQStringList(QList<T> const& list)
+ {
+ static_assert(std::is_constructible_v<QString, T>,
+ "QString must be constructible from T.");
+ return {list.begin(), list.end()};
+ }
+
+ // convert a QStringList to a QList - T must be constructible from QString
+ //
+ template <class T>
+ QList<T> toQList(QStringList const& list)
+ {
+ static_assert(std::is_constructible_v<T, QString>,
+ "T must be constructible from QString.");
+ return {list.begin(), list.end()};
+ }
+
+} // namespace mo2::python
+
+MO2_PYBIND11_SHARED_CPP_HOLDER(MOBase::IPluginRequirement)
+MO2_PYBIND11_SHARED_CPP_HOLDER(MOBase::ISaveGame)
+MO2_PYBIND11_SHARED_CPP_HOLDER(MOBase::GameFeature)
+
+#endif
diff --git a/libs/plugin_python/src/mobase/wrappers/basic_classes.cpp b/libs/plugin_python/src/mobase/wrappers/basic_classes.cpp
new file mode 100644
index 0000000..ea69a46
--- /dev/null
+++ b/libs/plugin_python/src/mobase/wrappers/basic_classes.cpp
@@ -0,0 +1,952 @@
+#include "wrappers.h"
+
+#include "../pybind11_all.h"
+
+#include <format>
+#include <memory>
+
+#include <pybind11_utils/generator.h>
+#include <uibase/executableinfo.h>
+#include <uibase/filemapping.h>
+#include <uibase/game_features/igamefeatures.h>
+#include <uibase/guessedvalue.h>
+#include <uibase/idownloadmanager.h>
+#include <uibase/iexecutable.h>
+#include <uibase/iexecutableslist.h>
+#include <uibase/iinstallationmanager.h>
+#include <uibase/iinstance.h>
+#include <uibase/iinstancemanager.h>
+#include <uibase/imodinterface.h>
+#include <uibase/imodrepositorybridge.h>
+#include <uibase/imoinfo.h>
+#include <uibase/iplugin.h>
+#include <uibase/iplugindiagnose.h>
+#include <uibase/iplugingame.h>
+#include <uibase/ipluginlist.h>
+#include <uibase/pluginsetting.h>
+#include <uibase/versioninfo.h>
+
+#include "../deprecation.h"
+#include "pyfiletree.h"
+
+using namespace MOBase;
+
+namespace mo2::python {
+
+ namespace py = pybind11;
+
+ using namespace pybind11::literals;
+
+ void add_versioninfo_classes(py::module_ m)
+ {
+ // Version
+ py::class_<Version> pyVersion(m, "Version");
+
+ py::enum_<Version::ReleaseType>(pyVersion, "ReleaseType")
+ .value("DEVELOPMENT", Version::Development)
+ .value("ALPHA", Version::Alpha)
+ .value("BETA", Version::Beta)
+ .value("RELEASE_CANDIDATE", Version::ReleaseCandidate)
+ .export_values();
+
+ py::enum_<Version::ParseMode>(pyVersion, "ParseMode")
+ .value("SEMVER", Version::ParseMode::SemVer)
+ .value("MO2", Version::ParseMode::MO2);
+
+ py::enum_<Version::FormatMode>(pyVersion, "FormatMode", py::arithmetic{})
+ .value("FORCE_SUBPATCH", Version::FormatMode::ForceSubPatch)
+ .value("NO_SEPARATOR", Version::FormatMode::NoSeparator)
+ .value("SHORT_ALPHA_BETA", Version::FormatMode::ShortAlphaBeta)
+ .value("NO_METADATA", Version::FormatMode::NoMetadata)
+ .value("CONDENSED",
+ static_cast<Version::FormatMode>(Version::FormatCondensed.toInt()))
+ .export_values()
+ .def("__xor__",
+ py::overload_cast<Version::FormatMode, Version::FormatModes>(
+ &operator^))
+ .def("__and__",
+ py::overload_cast<Version::FormatMode, Version::FormatModes>(
+ &operator&))
+ .def("__or__", py::overload_cast<Version::FormatMode, Version::FormatModes>(
+ &operator|))
+ .def("__rxor__",
+ py::overload_cast<Version::FormatMode, Version::FormatModes>(
+ &operator^))
+ .def("__rand__",
+ py::overload_cast<Version::FormatMode, Version::FormatModes>(
+ &operator&))
+ .def("__ror__",
+ py::overload_cast<Version::FormatMode, Version::FormatModes>(
+ &operator|));
+
+ pyVersion
+ .def_static("parse", &Version::parse, "value"_a,
+ "mode"_a = Version::ParseMode::SemVer)
+ .def(py::init<int, int, int, QString>(), "major"_a, "minor"_a, "patch"_a,
+ "metadata"_a = "")
+ .def(py::init<int, int, int, int, QString>(), "major"_a, "minor"_a,
+ "patch"_a, "subpatch"_a, "metadata"_a = "")
+ .def(py::init<int, int, int, Version::ReleaseType, QString>(), "major"_a,
+ "minor"_a, "patch"_a, "type"_a, "metadata"_a = "")
+ .def(py::init<int, int, int, int, Version::ReleaseType, QString>(),
+ "major"_a, "minor"_a, "patch"_a, "subpatch"_a, "type"_a,
+ "metadata"_a = "")
+ .def(py::init<int, int, int, Version::ReleaseType, int, QString>(),
+ "major"_a, "minor"_a, "patch"_a, "type"_a, "prerelease"_a,
+ "metadata"_a = "")
+ .def(py::init<int, int, int, int, Version::ReleaseType, int, QString>(),
+ "major"_a, "minor"_a, "patch"_a, "subpatch"_a, "type"_a,
+ "prerelease"_a, "metadata"_a = "")
+ .def(py::init<int, int, int, int,
+ std::vector<std::variant<int, Version::ReleaseType>>,
+ QString>(),
+ "major"_a, "minor"_a, "patch"_a, "subpatch"_a, "prereleases"_a,
+ "metadata"_a = "")
+ .def("isPreRelease", &Version::isPreRelease)
+ .def_property_readonly("major", &Version::major)
+ .def_property_readonly("minor", &Version::minor)
+ .def_property_readonly("patch", &Version::patch)
+ .def_property_readonly("subpatch", &Version::subpatch)
+ .def_property_readonly("prereleases", &Version::preReleases)
+ .def_property_readonly("build_metadata", &Version::buildMetadata)
+ .def("string", &Version::string, "mode"_a = Version::FormatModes{})
+ .def("__str__",
+ [](Version const& version) {
+ return version.string(Version::FormatCondensed);
+ })
+ .def(py::self < py::self)
+ .def(py::self > py::self)
+ .def(py::self <= py::self)
+ .def(py::self >= py::self)
+ .def(py::self != py::self)
+ .def(py::self == py::self);
+
+ // VersionInfo
+ py::enum_<MOBase::VersionInfo::ReleaseType>(m, "ReleaseType")
+ .value("final", MOBase::VersionInfo::RELEASE_FINAL)
+ .value("candidate", MOBase::VersionInfo::RELEASE_CANDIDATE)
+ .value("beta", MOBase::VersionInfo::RELEASE_BETA)
+ .value("alpha", MOBase::VersionInfo::RELEASE_ALPHA)
+ .value("prealpha", MOBase::VersionInfo::RELEASE_PREALPHA)
+
+ .value("FINAL", MOBase::VersionInfo::RELEASE_FINAL)
+ .value("CANDIDATE", MOBase::VersionInfo::RELEASE_CANDIDATE)
+ .value("BETA", MOBase::VersionInfo::RELEASE_BETA)
+ .value("ALPHA", MOBase::VersionInfo::RELEASE_ALPHA)
+ .value("PRE_ALPHA", MOBase::VersionInfo::RELEASE_PREALPHA);
+
+ py::enum_<MOBase::VersionInfo::VersionScheme>(m, "VersionScheme")
+ .value("discover", MOBase::VersionInfo::SCHEME_DISCOVER)
+ .value("regular", MOBase::VersionInfo::SCHEME_REGULAR)
+ .value("decimalmark", MOBase::VersionInfo::SCHEME_DECIMALMARK)
+ .value("numbersandletters", MOBase::VersionInfo::SCHEME_NUMBERSANDLETTERS)
+ .value("date", MOBase::VersionInfo::SCHEME_DATE)
+ .value("literal", MOBase::VersionInfo::SCHEME_LITERAL)
+
+ .value("DISCOVER", MOBase::VersionInfo::SCHEME_DISCOVER)
+ .value("REGULAR", MOBase::VersionInfo::SCHEME_REGULAR)
+ .value("DECIMAL_MARK", MOBase::VersionInfo::SCHEME_DECIMALMARK)
+ .value("NUMBERS_AND_LETTERS", MOBase::VersionInfo::SCHEME_NUMBERSANDLETTERS)
+ .value("DATE", MOBase::VersionInfo::SCHEME_DATE)
+ .value("LITERAL", MOBase::VersionInfo::SCHEME_LITERAL);
+
+ py::class_<VersionInfo>(m, "VersionInfo")
+ .def(py::init<>())
+ .def(py::init<QString, VersionInfo::VersionScheme>(), "value"_a,
+ "scheme"_a = VersionInfo::SCHEME_DISCOVER)
+ // note: order of the two init<> below is important because
+ // ReleaseType is a simple enum with an implicit int conversion.
+ .def(py::init<int, int, int, int, VersionInfo::ReleaseType>(), "major"_a,
+ "minor"_a, "subminor"_a, "subsubminor"_a,
+ "release_type"_a = VersionInfo::RELEASE_FINAL)
+ .def(py::init<int, int, int, VersionInfo::ReleaseType>(), "major"_a,
+ "minor"_a, "subminor"_a, "release_type"_a = VersionInfo::RELEASE_FINAL)
+ .def("clear", &VersionInfo::clear)
+ .def("parse", &VersionInfo::parse, "value"_a,
+ "scheme"_a = VersionInfo::SCHEME_DISCOVER, "is_manual"_a = false)
+ .def("canonicalString", &VersionInfo::canonicalString)
+ .def("displayString", &VersionInfo::displayString, "forced_segments"_a = 2)
+ .def("isValid", &VersionInfo::isValid)
+ .def("scheme", &VersionInfo::scheme)
+ .def("__str__", &VersionInfo::canonicalString)
+ .def(py::self < py::self)
+ .def(py::self > py::self)
+ .def(py::self <= py::self)
+ .def(py::self >= py::self)
+ .def(py::self != py::self)
+ .def(py::self == py::self);
+ }
+
+ void add_executable_classes(py::module_ m)
+ {
+ py::class_<ExecutableInfo>(m, "ExecutableInfo")
+ .def(py::init<const QString&, const FileWrapper&>(), "title"_a, "binary"_a)
+ .def("withArgument", &ExecutableInfo::withArgument, "argument"_a)
+ .def("withWorkingDirectory",
+ wrap_for_directory(&ExecutableInfo::withWorkingDirectory),
+ "directory"_a)
+ .def("withSteamAppId", &ExecutableInfo::withSteamAppId, "app_id"_a)
+ .def("asCustom", &ExecutableInfo::asCustom)
+ .def("isValid", &ExecutableInfo::isValid)
+ .def("title", &ExecutableInfo::title)
+ .def("binary", &ExecutableInfo::binary)
+ .def("arguments", &ExecutableInfo::arguments)
+ .def("workingDirectory", &ExecutableInfo::workingDirectory)
+ .def("steamAppID", &ExecutableInfo::steamAppID)
+ .def("isCustom", &ExecutableInfo::isCustom);
+
+ py::class_<ExecutableForcedLoadSetting>(m, "ExecutableForcedLoadSetting")
+ .def(py::init<const QString&, const QString&>(), "process"_a, "library"_a)
+ .def("withForced", &ExecutableForcedLoadSetting::withForced, "forced"_a)
+ .def("withEnabled", &ExecutableForcedLoadSetting::withEnabled, "enabled"_a)
+ .def("enabled", &ExecutableForcedLoadSetting::enabled)
+ .def("forced", &ExecutableForcedLoadSetting::forced)
+ .def("library", &ExecutableForcedLoadSetting::library)
+ .def("process", &ExecutableForcedLoadSetting::process);
+
+ py::class_<IExecutable>(m, "IExecutable")
+ .def("title", &IExecutable::title)
+ .def("binaryInfo", &IExecutable::binaryInfo)
+ .def("arguments", &IExecutable::arguments)
+ .def("steamAppID", &IExecutable::steamAppID)
+ .def("workingDirectory", &IExecutable::workingDirectory)
+ .def("isShownOnToolbar", &IExecutable::isShownOnToolbar)
+ .def("usesOwnIcon", &IExecutable::usesOwnIcon)
+ .def("minimizeToSystemTray", &IExecutable::minimizeToSystemTray)
+ .def("hide", &IExecutable::hide);
+
+ py::class_<IExecutablesList>(m, "IExecutablesList")
+ .def("executables",
+ [](IExecutablesList* executablesList) {
+ return make_generator(executablesList->executables(),
+ py::return_value_policy::reference);
+ })
+ .def("getByTitle", &IExecutablesList::getByTitle, "title"_a)
+ .def("getByBinary", &IExecutablesList::getByBinary, "info"_a)
+ .def("titleExists", &IExecutablesList::contains, "title"_a);
+ }
+
+ void add_modinterface_classes(py::module_ m)
+ {
+ py::enum_<EndorsedState>(m, "EndorsedState", py::arithmetic())
+ .value("ENDORSED_FALSE", EndorsedState::ENDORSED_FALSE)
+ .value("ENDORSED_TRUE", EndorsedState::ENDORSED_TRUE)
+ .value("ENDORSED_UNKNOWN", EndorsedState::ENDORSED_UNKNOWN)
+ .value("ENDORSED_NEVER", EndorsedState::ENDORSED_NEVER);
+
+ py::enum_<TrackedState>(m, "TrackedState", py::arithmetic())
+ .value("TRACKED_FALSE", TrackedState::TRACKED_FALSE)
+ .value("TRACKED_TRUE", TrackedState::TRACKED_TRUE)
+ .value("TRACKED_UNKNOWN", TrackedState::TRACKED_UNKNOWN);
+
+ py::class_<IModInterface>(m, "IModInterface")
+ .def("name", &IModInterface::name)
+ .def("absolutePath", &IModInterface::absolutePath)
+
+ .def("comments", &IModInterface::comments)
+ .def("notes", &IModInterface::notes)
+ .def("gameName", &IModInterface::gameName)
+ .def("repository", &IModInterface::repository)
+ .def("nexusId", &IModInterface::nexusId)
+ .def("version", &IModInterface::version)
+ .def("newestVersion", &IModInterface::newestVersion)
+ .def("ignoredVersion", &IModInterface::ignoredVersion)
+ .def("installationFile", &IModInterface::installationFile)
+ .def("converted", &IModInterface::converted)
+ .def("validated", &IModInterface::validated)
+ .def("color", &IModInterface::color)
+ .def("url", &IModInterface::url)
+ .def("primaryCategory", &IModInterface::primaryCategory)
+ .def("categories", &IModInterface::categories)
+ .def("author", &IModInterface::author)
+ .def("uploader", &IModInterface::uploader)
+ .def("uploaderUrl", &IModInterface::uploaderUrl)
+ .def("trackedState", &IModInterface::trackedState)
+ .def("endorsedState", &IModInterface::endorsedState)
+ .def("fileTree", &IModInterface::fileTree)
+ .def("isOverwrite", &IModInterface::isOverwrite)
+ .def("isBackup", &IModInterface::isBackup)
+ .def("isSeparator", &IModInterface::isSeparator)
+ .def("isForeign", &IModInterface::isForeign)
+
+ .def("setVersion", &IModInterface::setVersion, "version"_a)
+ .def("setNewestVersion", &IModInterface::setNewestVersion, "version"_a)
+ .def("setIsEndorsed", &IModInterface::setIsEndorsed, "endorsed"_a)
+ .def("setNexusID", &IModInterface::setNexusID, "nexus_id"_a)
+ .def("addNexusCategory", &IModInterface::addNexusCategory, "category_id"_a)
+ .def("addCategory", &IModInterface::addCategory, "name"_a)
+ .def("removeCategory", &IModInterface::removeCategory, "name"_a)
+ .def("setGameName", &IModInterface::setGameName, "name"_a)
+ .def("setUrl", &IModInterface::setUrl, "url"_a)
+ .def("pluginSetting", &IModInterface::pluginSetting, "plugin_name"_a,
+ "key"_a, "default"_a = QVariant())
+ .def("pluginSettings", &IModInterface::pluginSettings, "plugin_name"_a)
+ .def("setPluginSetting", &IModInterface::setPluginSetting, "plugin_name"_a,
+ "key"_a, "value"_a)
+ .def("clearPluginSettings", &IModInterface::clearPluginSettings,
+ "plugin_name"_a);
+ }
+
+ void add_modrepository_classes(py::module_ m)
+ {
+ py::class_<IModRepositoryBridge> iModRepositoryBridge(m,
+ "IModRepositoryBridge");
+ iModRepositoryBridge
+ .def("requestDescription", &IModRepositoryBridge::requestDescription,
+ "game_name"_a, "mod_id"_a, "user_data"_a)
+ .def("requestFiles", &IModRepositoryBridge::requestFiles, "game_name"_a,
+ "mod_id"_a, "user_data"_a)
+ .def("requestFileInfo", &IModRepositoryBridge::requestFileInfo,
+ "game_name"_a, "mod_id"_a, "file_id"_a, "user_data"_a)
+ .def("requestDownloadURL", &IModRepositoryBridge::requestDownloadURL,
+ "game_name"_a, "mod_id"_a, "file_id"_a, "user_data"_a)
+ .def("requestToggleEndorsement",
+ &IModRepositoryBridge::requestToggleEndorsement, "game_name"_a,
+ "mod_id"_a, "mod_version"_a, "endorse"_a, "user_data"_a);
+
+ py::qt::add_qt_delegate<QObject>(iModRepositoryBridge, "_object");
+
+ py::class_<ModRepositoryFileInfo>(m, "ModRepositoryFileInfo")
+ .def(py::init<const ModRepositoryFileInfo&>(), "other"_a)
+ .def(py::init<QString, int, int>(), "game_name"_a = "", "mod_id"_a = 0,
+ "file_id"_a = 0)
+ .def("__str__", &ModRepositoryFileInfo::toString)
+ .def_static("createFromJson", &ModRepositoryFileInfo::createFromJson,
+ "data"_a)
+ .def_readwrite("name", &ModRepositoryFileInfo::name)
+ .def_readwrite("uri", &ModRepositoryFileInfo::uri)
+ .def_readwrite("description", &ModRepositoryFileInfo::description)
+ .def_readwrite("version", &ModRepositoryFileInfo::version)
+ .def_readwrite("newestVersion", &ModRepositoryFileInfo::newestVersion)
+ .def_readwrite("categoryID", &ModRepositoryFileInfo::categoryID)
+ .def_readwrite("modName", &ModRepositoryFileInfo::modName)
+ .def_readwrite("gameName", &ModRepositoryFileInfo::gameName)
+ .def_readwrite("modID", &ModRepositoryFileInfo::modID)
+ .def_readwrite("fileID", &ModRepositoryFileInfo::fileID)
+ .def_readwrite("fileSize", &ModRepositoryFileInfo::fileSize)
+ .def_readwrite("fileName", &ModRepositoryFileInfo::fileName)
+ .def_readwrite("fileCategory", &ModRepositoryFileInfo::fileCategory)
+ .def_readwrite("fileTime", &ModRepositoryFileInfo::fileTime)
+ .def_readwrite("repository", &ModRepositoryFileInfo::repository)
+ .def_readwrite("userData", &ModRepositoryFileInfo::userData)
+ .def_readwrite("author", &ModRepositoryFileInfo::author)
+ .def_readwrite("uploader", &ModRepositoryFileInfo::uploader)
+ .def_readwrite("uploaderUrl", &ModRepositoryFileInfo::uploaderUrl);
+ }
+
+ void add_guessedstring_classes(py::module_ m)
+ {
+ py::enum_<MOBase::EGuessQuality>(m, "GuessQuality")
+ .value("INVALID", MOBase::GUESS_INVALID)
+ .value("FALLBACK", MOBase::GUESS_FALLBACK)
+ .value("GOOD", MOBase::GUESS_GOOD)
+ .value("META", MOBase::GUESS_META)
+ .value("PRESET", MOBase::GUESS_PRESET)
+ .value("USER", MOBase::GUESS_USER);
+
+ py::class_<MOBase::GuessedValue<QString>>(m, "GuessedString")
+ .def(py::init<>())
+ .def(py::init<QString const&, EGuessQuality>(), "value"_a,
+ "quality"_a = EGuessQuality::GUESS_USER)
+ .def("update",
+ py::overload_cast<const QString&>(&GuessedValue<QString>::update),
+ "value"_a)
+ .def("update",
+ py::overload_cast<const QString&, EGuessQuality>(
+ &GuessedValue<QString>::update),
+ "value"_a, "quality"_a)
+
+ // Methods to simulate the assignment operator:
+ .def("reset",
+ [](GuessedValue<QString>* gv) {
+ *gv = GuessedValue<QString>();
+ return gv;
+ })
+ .def(
+ "reset",
+ [](GuessedValue<QString>* gv, const QString& value, EGuessQuality eq) {
+ *gv = GuessedValue<QString>(value, eq);
+ return gv;
+ },
+ "value"_a, "quality"_a)
+ .def(
+ "reset",
+ [](GuessedValue<QString>* gv, const GuessedValue<QString>& other) {
+ *gv = other;
+ return gv;
+ },
+ "other"_a)
+
+ // use an intermediate lambda because we cannot have a function with a
+ // non-const reference in Python - in Python, the function should returned a
+ // bool or the modified value
+ .def(
+ "setFilter",
+ [](GuessedValue<QString>* gv,
+ std::function<std::variant<QString, bool>(QString const&)> fn) {
+ gv->setFilter([fn](QString& s) {
+ auto ret = fn(s);
+ return std::visit(
+ [&s](auto v) {
+ if constexpr (std::is_same_v<decltype(v), QString>) {
+ s = v;
+ return true;
+ }
+ else if constexpr (std::is_same_v<decltype(v), bool>) {
+ return v;
+ }
+ },
+ ret);
+ });
+ },
+ "filter"_a)
+
+ // this makes a copy in python but it more practical than
+ // exposing an iterator
+ .def("variants", &GuessedValue<QString>::variants)
+ .def("__str__", &MOBase::GuessedValue<QString>::operator const QString&);
+
+ // implicit conversion from QString - this allows passing Python string to
+ // function expecting GuessedValue<QString>
+ py::implicitly_convertible<QString, GuessedValue<QString>>();
+ }
+
+ void add_ipluginlist_classes(py::module_ m)
+ {
+ py::enum_<IPluginList::PluginState>(m, "PluginState", py::arithmetic())
+ .value("missing", IPluginList::STATE_MISSING)
+ .value("inactive", IPluginList::STATE_INACTIVE)
+ .value("active", IPluginList::STATE_ACTIVE)
+
+ .value("MISSING", IPluginList::STATE_MISSING)
+ .value("INACTIVE", IPluginList::STATE_INACTIVE)
+ .value("ACTIVE", IPluginList::STATE_ACTIVE);
+
+ py::class_<IPluginList>(m, "IPluginList")
+ .def("state", &IPluginList::state, "name"_a)
+ .def("priority", &IPluginList::priority, "name"_a)
+ .def("setPriority", &IPluginList::setPriority, "name"_a, "priority"_a)
+ .def("loadOrder", &IPluginList::loadOrder, "name"_a)
+ .def("masters", &IPluginList::masters, "name"_a)
+ .def("origin", &IPluginList::origin, "name"_a)
+ .def("onRefreshed", &IPluginList::onRefreshed, "callback"_a)
+ .def("onPluginMoved", &IPluginList::onPluginMoved, "callback"_a)
+
+ .def("isMasterFlagged", &IPluginList::isMasterFlagged, "name"_a)
+ .def("hasMasterExtension", &IPluginList::hasMasterExtension, "name"_a)
+ .def("isMediumFlagged", &IPluginList::isMediumFlagged, "name"_a)
+ .def("isLightFlagged", &IPluginList::isLightFlagged, "name"_a)
+ .def("isBlueprintFlagged", &IPluginList::isBlueprintFlagged, "name"_a)
+ .def("hasLightExtension", &IPluginList::hasLightExtension, "name"_a)
+ .def("hasNoRecords", &IPluginList::hasNoRecords, "name"_a)
+
+ .def("formVersion", &IPluginList::formVersion, "name"_a)
+ .def("headerVersion", &IPluginList::headerVersion, "name"_a)
+ .def("author", &IPluginList::author, "name"_a)
+ .def("description", &IPluginList::description, "name"_a)
+
+ // Kept but deprecated for backward compatibility:
+ .def(
+ "onPluginStateChanged",
+ [](IPluginList* modList,
+ const std::function<void(const QString&, IPluginList::PluginStates)>&
+ fn) {
+ mo2::python::show_deprecation_warning(
+ "onPluginStateChanged",
+ "onPluginStateChanged(Callable[[str, "
+ "IPluginList.PluginStates], None]) is deprecated, "
+ "use onPluginStateChanged(Callable[[Dict[str, "
+ "IPluginList.PluginStates], None]) instead.");
+ return modList->onPluginStateChanged([fn](auto const& map) {
+ for (const auto& entry : map) {
+ fn(entry.first, entry.second);
+ }
+ });
+ },
+ "callback"_a)
+ .def(
+ "isMaster",
+ [](IPluginList* modList, QString const& name) {
+ mo2::python::show_deprecation_warning(
+ "isMaster",
+ "isMaster(str) is deprecated, use isMasterFlagged() or "
+ "hasMasterExtension() instead.");
+ return modList->isMasterFlagged(name);
+ },
+ "name"_a)
+ .def("onPluginStateChanged", &MOBase::IPluginList::onPluginStateChanged,
+ "callback"_a)
+ .def("pluginNames", &MOBase::IPluginList::pluginNames)
+ .def("setState", &MOBase::IPluginList::setState, "name"_a, "state"_a)
+ .def("setLoadOrder", &MOBase::IPluginList::setLoadOrder, "loadorder"_a);
+ }
+
+ void add_imodlist_classes(py::module_ m)
+ {
+ py::enum_<IModList::ModState>(m, "ModState", py::arithmetic())
+ .value("exists", IModList::STATE_EXISTS)
+ .value("active", IModList::STATE_ACTIVE)
+ .value("essential", IModList::STATE_ESSENTIAL)
+ .value("empty", IModList::STATE_EMPTY)
+ .value("endorsed", IModList::STATE_ENDORSED)
+ .value("valid", IModList::STATE_VALID)
+ .value("alternate", IModList::STATE_ALTERNATE)
+
+ .value("EXISTS", IModList::STATE_EXISTS)
+ .value("ACTIVE", IModList::STATE_ACTIVE)
+ .value("ESSENTIAL", IModList::STATE_ESSENTIAL)
+ .value("EMPTY", IModList::STATE_EMPTY)
+ .value("ENDORSED", IModList::STATE_ENDORSED)
+ .value("VALID", IModList::STATE_VALID)
+ .value("ALTERNATE", IModList::STATE_ALTERNATE);
+
+ py::class_<IModList>(m, "IModList")
+ .def("displayName", &MOBase::IModList::displayName, "name"_a)
+ .def("allMods", &MOBase::IModList::allMods)
+ .def("allModsByProfilePriority",
+ &MOBase::IModList::allModsByProfilePriority,
+ "profile"_a = static_cast<IProfile*>(nullptr))
+ .def("getMod", &MOBase::IModList::getMod,
+ py::return_value_policy::reference, "name"_a)
+ .def("removeMod", &MOBase::IModList::removeMod, "mod"_a)
+ .def("renameMod", &MOBase::IModList::renameMod,
+ py::return_value_policy::reference, "mod"_a, "name"_a)
+
+ .def("state", &MOBase::IModList::state, "name"_a)
+ .def("setActive",
+ py::overload_cast<QStringList const&, bool>(
+ &MOBase::IModList::setActive),
+ "names"_a, "active"_a)
+ .def("setActive",
+ py::overload_cast<QString const&, bool>(&MOBase::IModList::setActive),
+ "name"_a, "active"_a)
+ .def("priority", &MOBase::IModList::priority, "name"_a)
+ .def("setPriority", &MOBase::IModList::setPriority, "name"_a, "priority"_a)
+
+ // kept but deprecated for backward compatibility
+ .def(
+ "onModStateChanged",
+ [](IModList* modList,
+ const std::function<void(const QString&, IModList::ModStates)>& fn) {
+ mo2::python::show_deprecation_warning(
+ "onModStateChanged",
+ "onModStateChanged(Callable[[str, IModList.ModStates], None]) "
+ "is deprecated, "
+ "use onModStateChanged(Callable[[Dict[str, "
+ "IModList.ModStates], None]) instead.");
+ return modList->onModStateChanged([fn](auto const& map) {
+ for (const auto& entry : map) {
+ fn(entry.first, entry.second);
+ }
+ });
+ },
+ "callback"_a)
+
+ .def("onModInstalled", &MOBase::IModList::onModInstalled, "callback"_a)
+ .def("onModRemoved", &MOBase::IModList::onModRemoved, "callback"_a)
+ .def("onModStateChanged", &MOBase::IModList::onModStateChanged,
+ "callback"_a)
+ .def("onModMoved", &MOBase::IModList::onModMoved, "callback"_a);
+ }
+
+ void add_iorganizer_classes(py::module_ m)
+ {
+ // define INVALID_HANDLE_VALUE for startApplication, etc.
+ m.attr("INVALID_HANDLE_VALUE") = py::int_((std::uintptr_t)INVALID_HANDLE_VALUE);
+
+ py::class_<IOrganizer::FileInfo>(m, "FileInfo")
+ .def(py::init<>())
+ .def_readwrite("filePath", &IOrganizer::FileInfo::filePath)
+ .def_readwrite("archive", &IOrganizer::FileInfo::archive)
+ .def_readwrite("origins", &IOrganizer::FileInfo::origins);
+
+ py::class_<IOrganizer>(m, "IOrganizer")
+ .def("createNexusBridge", &IOrganizer::createNexusBridge,
+ py::return_value_policy::reference)
+ .def("instanceName", &IOrganizer::instanceName)
+ .def("profileName", &IOrganizer::profileName)
+ .def("profilePath", &IOrganizer::profilePath)
+ .def("downloadsPath", &IOrganizer::downloadsPath)
+ .def("overwritePath", &IOrganizer::overwritePath)
+ .def("basePath", &IOrganizer::basePath)
+ .def("modsPath", &IOrganizer::modsPath)
+ .def("appVersion",
+ [](IOrganizer& o) {
+ mo2::python::show_deprecation_warning(
+ "appVersion", "IOrganizer::appVersion() is deprecated, use "
+ "IOrganizer::version() instead.");
+#pragma warning(push)
+#pragma warning(disable : 4996)
+ return o.appVersion();
+#pragma warning(pop)
+ })
+ .def("version", &IOrganizer::version)
+ .def("createMod", &IOrganizer::createMod,
+ py::return_value_policy::reference, "name"_a)
+ .def("getGame", &IOrganizer::getGame, py::return_value_policy::reference,
+ "name"_a)
+ .def("modDataChanged", &IOrganizer::modDataChanged, "mod"_a)
+ .def("isPluginEnabled",
+ py::overload_cast<IPlugin*>(&IOrganizer::isPluginEnabled, py::const_),
+ "plugin"_a)
+ .def("isPluginEnabled",
+ py::overload_cast<QString const&>(&IOrganizer::isPluginEnabled,
+ py::const_),
+ "plugin"_a)
+ .def("pluginSetting", &IOrganizer::pluginSetting, "plugin_name"_a, "key"_a)
+ .def("setPluginSetting", &IOrganizer::setPluginSetting, "plugin_name"_a,
+ "key"_a, "value"_a)
+ .def("persistent", &IOrganizer::persistent, "plugin_name"_a, "key"_a,
+ "default"_a = QVariant())
+ .def("setPersistent", &IOrganizer::setPersistent, "plugin_name"_a, "key"_a,
+ "value"_a, "sync"_a = true)
+ .def("pluginDataPath", &IOrganizer::pluginDataPath)
+ .def("installMod", wrap_for_filepath<1>(&IOrganizer::installMod),
+ py::return_value_policy::reference, "filename"_a,
+ "name_suggestion"_a = "")
+ .def("resolvePath", wrap_for_filepath(&IOrganizer::resolvePath),
+ "filename"_a)
+ .def("listDirectories", &IOrganizer::listDirectories, "directory"_a)
+
+ // "provide multiple overloads of findFiles
+ .def(
+ "findFiles",
+ [](const IOrganizer* o, DirectoryWrapper const& p,
+ std::function<bool(QString const&)> const& f) {
+ return o->findFiles(p, f);
+ },
+ "path"_a, "filter"_a)
+
+ // in C++, it is possible to create a QStringList implicitly from
+ // a single QString, in Python is not possible with the current
+ // converters in python (and I do not think it is a good idea to
+ // have it everywhere), but here it is nice to be able to
+ // pass a single string, so we add an extra overload
+ //
+ // important: the order matters, because a Python string can be
+ // converted to a QStringList since it is a sequence of
+ // single-character strings:
+ .def(
+ "findFiles",
+ [](const IOrganizer* o, DirectoryWrapper const& p,
+ const QStringList& gf) {
+ return o->findFiles(p, gf);
+ },
+ "path"_a, "patterns"_a)
+ .def(
+ "findFiles",
+ [](const IOrganizer* o, DirectoryWrapper const& p, const QString& f) {
+ return o->findFiles(p, QStringList{f});
+ },
+ "path"_a, "pattern"_a)
+
+ .def("getFileOrigins", &IOrganizer::getFileOrigins, "filename"_a)
+ .def("findFileInfos", wrap_for_directory(&IOrganizer::findFileInfos),
+ "path"_a, "filter"_a)
+
+ .def("virtualFileTree", &IOrganizer::virtualFileTree)
+
+ .def("instanceManager", &IOrganizer::instanceManager,
+ py::return_value_policy::reference)
+ .def("downloadManager", &IOrganizer::downloadManager,
+ py::return_value_policy::reference)
+ .def("pluginList", &IOrganizer::pluginList,
+ py::return_value_policy::reference)
+ .def("modList", &IOrganizer::modList, py::return_value_policy::reference)
+ .def("executablesList", &IOrganizer::executablesList,
+ py::return_value_policy::reference)
+ .def("gameFeatures", &IOrganizer::gameFeatures,
+ py::return_value_policy::reference)
+ .def("profile", &IOrganizer::profile)
+ .def("profileNames", &IOrganizer::profileNames)
+ .def("getProfile", &IOrganizer::getProfile, "name"_a)
+
+ // custom implementation for startApplication and
+ // waitForApplication because 1) HANDLE (= void*) is not properly
+ // converted from/to python, and 2) we need to convert the by-ptr
+ // argument to a return-tuple for waitForApplication
+ .def(
+ "startApplication",
+ [](IOrganizer* o, const FileWrapper& executable,
+ const QStringList& args, const DirectoryWrapper& cwd,
+ const QString& profile, const QString& forcedCustomOverwrite,
+ bool ignoreCustomOverwrite) -> std::uintptr_t {
+ return (std::uintptr_t)o->startApplication(
+ executable, args, cwd, profile, forcedCustomOverwrite,
+ ignoreCustomOverwrite);
+ },
+ "executable"_a, "args"_a = QStringList(), "cwd"_a = "",
+ "profile"_a = "", "forcedCustomOverwrite"_a = "",
+ "ignoreCustomOverwrite"_a = false)
+ .def(
+ "waitForApplication",
+ [](IOrganizer* o, std::uintptr_t handle, bool refresh) {
+ DWORD returnCode;
+ bool result =
+ o->waitForApplication((HANDLE)handle, refresh, &returnCode);
+
+ // we force signed return code because it's probably what's expected
+ // in Python
+ return std::make_tuple(
+ result, static_cast<std::make_signed_t<DWORD>>(returnCode));
+ },
+ "handle"_a, "refresh"_a = true)
+
+ .def("refresh", &IOrganizer::refresh, "save_changes"_a = true)
+ .def("managedGame", &IOrganizer::managedGame,
+ py::return_value_policy::reference)
+
+ .def("onAboutToRun",
+ py::overload_cast<std::function<bool(QString const&, const QDir&,
+ const QString&)> const&>(
+ &IOrganizer::onAboutToRun),
+ "callback"_a)
+ .def("onAboutToRun",
+ py::overload_cast<std::function<bool(QString const&)> const&>(
+ &IOrganizer::onAboutToRun),
+ "callback"_a)
+ .def("onFinishedRun", &IOrganizer::onFinishedRun, "callback"_a)
+ .def("onUserInterfaceInitialized", &IOrganizer::onUserInterfaceInitialized,
+ "callback"_a)
+ .def("onNextRefresh", &IOrganizer::onNextRefresh, "callback"_a,
+ "immediate_if_possible"_a = true)
+ .def("onProfileCreated", &IOrganizer::onProfileCreated, "callback"_a)
+ .def("onProfileRenamed", &IOrganizer::onProfileRenamed, "callback"_a)
+ .def("onProfileRemoved", &IOrganizer::onProfileRemoved, "callback"_a)
+ .def("onProfileChanged", &IOrganizer::onProfileChanged, "callback"_a)
+
+ .def("onPluginSettingChanged", &IOrganizer::onPluginSettingChanged,
+ "callback"_a)
+ .def(
+ "onPluginEnabled",
+ [](IOrganizer* o, std::function<void(const IPlugin*)> const& func) {
+ o->onPluginEnabled(func);
+ },
+ "callback"_a)
+ .def(
+ "onPluginEnabled",
+ [](IOrganizer* o, QString const& name,
+ std::function<void()> const& func) {
+ o->onPluginEnabled(name, func);
+ },
+ "name"_a, "callback"_a)
+ .def(
+ "onPluginDisabled",
+ [](IOrganizer* o, std::function<void(const IPlugin*)> const& func) {
+ o->onPluginDisabled(func);
+ },
+ "callback"_a)
+ .def(
+ "onPluginDisabled",
+ [](IOrganizer* o, QString const& name,
+ std::function<void()> const& func) {
+ o->onPluginDisabled(name, func);
+ },
+ "name"_a, "callback"_a)
+
+ // DEPRECATED:
+ .def(
+ "getMod",
+ [](IOrganizer* o, QString const& name) {
+ mo2::python::show_deprecation_warning(
+ "getMod", "IOrganizer::getMod(str) is deprecated, use "
+ "IModList::getMod(str) instead.");
+ return o->modList()->getMod(name);
+ },
+ py::return_value_policy::reference, "name"_a)
+ .def(
+ "removeMod",
+ [](IOrganizer* o, IModInterface* mod) {
+ mo2::python::show_deprecation_warning(
+ "removeMod",
+ "IOrganizer::removeMod(IModInterface) is deprecated, use "
+ "IModList::removeMod(IModInterface) instead.");
+ return o->modList()->removeMod(mod);
+ },
+ "mod"_a)
+ .def("modsSortedByProfilePriority",
+ [](IOrganizer* o) {
+ mo2::python::show_deprecation_warning(
+ "modsSortedByProfilePriority",
+ "IOrganizer::modsSortedByProfilePriority() is deprecated, use "
+ "IModList::allModsByProfilePriority() instead.");
+ return o->modList()->allModsByProfilePriority();
+ })
+ .def(
+ "refreshModList",
+ [](IOrganizer* o, bool s) {
+ mo2::python::show_deprecation_warning(
+ "refreshModList",
+ "IOrganizer::refreshModList(bool) is deprecated, use "
+ "IOrganizer::refresh(bool) instead.");
+ o->refresh(s);
+ },
+ "save_changes"_a = true)
+ .def(
+ "onModInstalled",
+ [](IOrganizer* organizer,
+ const std::function<void(QString const&)>& func) {
+ mo2::python::show_deprecation_warning(
+ "onModInstalled",
+ "IOrganizer::onModInstalled(Callable[[str], None]) is "
+ "deprecated, "
+ "use IModList::onModInstalled(Callable[[IModInterface], None]) "
+ "instead.");
+ return organizer->modList()->onModInstalled(
+ [func](MOBase::IModInterface* m) {
+ func(m->name());
+ });
+ ;
+ },
+ "callback"_a)
+
+ .def_static("getPluginDataPath", &IOrganizer::getPluginDataPath);
+ }
+
+ void add_iinstance_manager_classes(py::module_ m)
+ {
+ py::class_<IInstance, std::shared_ptr<IInstance>>(m, "IInstance")
+ .def("displayName", &IInstance::displayName)
+ .def("gameName", &IInstance::gameName)
+ .def("gameDirectory", &IInstance::gameDirectory)
+ .def("isPortable", &IInstance::isPortable);
+
+ py::class_<IInstanceManager>(m, "IInstanceManager")
+ .def("currentInstance", &IInstanceManager::currentInstance)
+ .def("globalInstancePaths", &IInstanceManager::globalInstancePaths)
+ .def("getGlobalInstance", &IInstanceManager::getGlobalInstance);
+ }
+
+ void add_idownload_manager_classes(py::module_ m)
+ {
+ py::class_<IDownloadManager>(m, "IDownloadManager")
+ .def("startDownloadURLs", &IDownloadManager::startDownloadURLs, "urls"_a)
+ .def("startDownloadNexusFile", &IDownloadManager::startDownloadNexusFile,
+ "mod_id"_a, "file_id"_a)
+ .def("startDownloadNexusFileForGame",
+ &IDownloadManager::startDownloadNexusFileForGame, "game_name"_a,
+ "mod_id"_a, "file_id"_a)
+ .def("downloadPath", &IDownloadManager::downloadPath, "id"_a)
+ .def("onDownloadComplete", &IDownloadManager::onDownloadComplete,
+ "callback"_a)
+ .def("onDownloadPaused", &IDownloadManager::onDownloadPaused, "callback"_a)
+ .def("onDownloadFailed", &IDownloadManager::onDownloadFailed, "callback"_a)
+ .def("onDownloadRemoved", &IDownloadManager::onDownloadRemoved,
+ "callback"_a);
+ }
+
+ void add_iinstallation_manager_classes(py::module_ m)
+ {
+ // add this here to get proper typing
+ py::enum_<IPluginInstaller::EInstallResult>(m, "InstallResult")
+ .value("SUCCESS", IPluginInstaller::RESULT_SUCCESS)
+ .value("FAILED", IPluginInstaller::RESULT_FAILED)
+ .value("CANCELED", IPluginInstaller::RESULT_CANCELED)
+ .value("MANUAL_REQUESTED", IPluginInstaller::RESULT_MANUALREQUESTED)
+ .value("NOT_ATTEMPTED", IPluginInstaller::RESULT_NOTATTEMPTED);
+
+ py::class_<IInstallationManager>(m, "IInstallationManager")
+ .def("getSupportedExtensions",
+ &IInstallationManager::getSupportedExtensions)
+ .def("extractFile", &IInstallationManager::extractFile, "entry"_a,
+ "silent"_a = false)
+ .def("extractFiles", &IInstallationManager::extractFiles, "entries"_a,
+ "silent"_a = false)
+ .def("createFile", &IInstallationManager::createFile, "entry"_a)
+
+ // return a tuple to get back the mod name and the mod ID
+ .def(
+ "installArchive",
+ [](IInstallationManager* m, GuessedValue<QString> modName,
+ FileWrapper archive, int modId) {
+ auto result = m->installArchive(modName, archive, modId);
+ return std::make_tuple(result, static_cast<QString>(modName),
+ modId);
+ },
+ "mod_name"_a, "archive"_a, "mod_id"_a = 0);
+ }
+
+ void add_basic_bindings(py::module_ m)
+ {
+ add_versioninfo_classes(m);
+ add_executable_classes(m);
+ add_guessedstring_classes(m);
+
+ add_ifiletree_bindings(m);
+
+ add_modinterface_classes(m);
+ add_modrepository_classes(m);
+
+ py::class_<PluginSetting>(m, "PluginSetting")
+ .def(py::init<const QString&, const QString&, const QVariant&>(), "key"_a,
+ "description"_a, "default_value"_a)
+ .def_readwrite("key", &PluginSetting::key)
+ .def_readwrite("description", &PluginSetting::description)
+ .def_readwrite("default_value", &PluginSetting::defaultValue);
+
+ py::class_<PluginRequirementFactory>(m, "PluginRequirementFactory")
+ // pluginDependency
+ .def_static("pluginDependency",
+ py::overload_cast<QStringList const&>(
+ &PluginRequirementFactory::pluginDependency),
+ "plugins"_a)
+ .def_static("pluginDependency",
+ py::overload_cast<QString const&>(
+ &PluginRequirementFactory::pluginDependency),
+ "plugin"_a)
+ // gameDependency
+ .def_static("gameDependency",
+ py::overload_cast<QStringList const&>(
+ &PluginRequirementFactory::gameDependency),
+ "games"_a)
+ .def_static("gameDependency",
+ py::overload_cast<QString const&>(
+ &PluginRequirementFactory::gameDependency),
+ "game"_a)
+ // diagnose
+ .def_static("diagnose", &PluginRequirementFactory::diagnose, "diagnose"_a)
+ // basic
+ .def_static("basic", &PluginRequirementFactory::basic, "checker"_a,
+ "description"_a);
+
+ py::class_<Mapping>(m, "Mapping")
+ .def(py::init<>())
+ .def(py::init([](QString src, QString dst, bool dir, bool crt) -> Mapping {
+ return {src, dst, dir, crt};
+ }),
+ "source"_a, "destination"_a, "is_directory"_a,
+ "create_target"_a = false)
+ .def_readwrite("source", &Mapping::source)
+ .def_readwrite("destination", &Mapping::destination)
+ .def_readwrite("isDirectory", &Mapping::isDirectory)
+ .def_readwrite("createTarget", &Mapping::createTarget)
+ .def("__str__", [](Mapping const& m) {
+ return std::format(L"Mapping({}, {}, {}, {})", m.source.toStdWString(),
+ m.destination.toStdWString(), m.isDirectory,
+ m.createTarget);
+ });
+
+ // must be done BEFORE imodlist because there is a default argument to a
+ // IProfile* in the modlist class
+ py::class_<IProfile, std::shared_ptr<IProfile>>(m, "IProfile")
+ .def("name", &IProfile::name)
+ .def("absolutePath", &IProfile::absolutePath)
+ .def("localSavesEnabled", &IProfile::localSavesEnabled)
+ .def("localSettingsEnabled", &IProfile::localSettingsEnabled)
+ .def("invalidationActive",
+ [](const IProfile* p) {
+ bool supported;
+ bool active = p->invalidationActive(&supported);
+ return std::make_tuple(active, supported);
+ })
+ .def("absoluteIniFilePath", &IProfile::absoluteIniFilePath, "inifile"_a);
+
+ add_ipluginlist_classes(m);
+ add_imodlist_classes(m);
+ add_iinstance_manager_classes(m);
+ add_idownload_manager_classes(m);
+ add_iinstallation_manager_classes(m);
+ add_iorganizer_classes(m);
+ }
+
+} // namespace mo2::python
diff --git a/libs/plugin_python/src/mobase/wrappers/game_features.cpp b/libs/plugin_python/src/mobase/wrappers/game_features.cpp
new file mode 100644
index 0000000..655334b
--- /dev/null
+++ b/libs/plugin_python/src/mobase/wrappers/game_features.cpp
@@ -0,0 +1,422 @@
+#include "wrappers.h"
+
+#include <tuple>
+
+#include "../pybind11_all.h"
+
+#include <uibase/ipluginlist.h>
+#include <uibase/isavegameinfowidget.h>
+
+#include <uibase/game_features/bsainvalidation.h>
+#include <uibase/game_features/dataarchives.h>
+#include <uibase/game_features/gameplugins.h>
+#include <uibase/game_features/igamefeatures.h>
+#include <uibase/game_features/localsavegames.h>
+#include <uibase/game_features/moddatachecker.h>
+#include <uibase/game_features/moddatacontent.h>
+#include <uibase/game_features/savegameinfo.h>
+#include <uibase/game_features/scriptextender.h>
+#include <uibase/game_features/unmanagedmods.h>
+
+#include "pyfiletree.h"
+
+namespace py = pybind11;
+
+using namespace MOBase;
+using namespace pybind11::literals;
+
+namespace mo2::python {
+
+ class PyBSAInvalidation : public BSAInvalidation {
+ public:
+ bool isInvalidationBSA(const QString& bsaName) override
+ {
+ PYBIND11_OVERRIDE_PURE(bool, BSAInvalidation, isInvalidationBSA, bsaName);
+ }
+ void deactivate(MOBase::IProfile* profile) override
+ {
+ PYBIND11_OVERRIDE_PURE(void, BSAInvalidation, deactivate, profile);
+ }
+ void activate(MOBase::IProfile* profile) override
+ {
+ PYBIND11_OVERRIDE_PURE(void, BSAInvalidation, activate, profile);
+ }
+ bool prepareProfile(MOBase::IProfile* profile) override
+ {
+ PYBIND11_OVERRIDE_PURE(bool, BSAInvalidation, prepareProfile, profile);
+ }
+ };
+
+ class PyDataArchives : public DataArchives {
+ public:
+ QStringList vanillaArchives() const override
+ {
+ PYBIND11_OVERRIDE_PURE(QStringList, DataArchives, vanillaArchives, );
+ }
+ QStringList archives(const MOBase::IProfile* profile) const override
+ {
+ PYBIND11_OVERRIDE_PURE(QStringList, DataArchives, archives, profile);
+ }
+ void addArchive(MOBase::IProfile* profile, int index,
+ const QString& archiveName) override
+ {
+ PYBIND11_OVERRIDE_PURE(void, DataArchives, addArchive, profile, index,
+ archiveName);
+ }
+ void removeArchive(MOBase::IProfile* profile,
+ const QString& archiveName) override
+ {
+ PYBIND11_OVERRIDE_PURE(void, DataArchives, removeArchive, profile,
+ archiveName);
+ }
+ };
+
+ class PyGamePlugins : public GamePlugins {
+ public:
+ void writePluginLists(const MOBase::IPluginList* pluginList) override
+ {
+ PYBIND11_OVERRIDE_PURE(void, GamePlugins, writePluginLists, pluginList);
+ }
+ void readPluginLists(MOBase::IPluginList* pluginList) override
+ {
+ // TODO: cannot update plugin list or create one from Python so this is
+ // useless
+ PYBIND11_OVERRIDE_PURE(void, GamePlugins, readPluginLists, pluginList);
+ }
+ QStringList getLoadOrder() override
+ {
+ PYBIND11_OVERRIDE_PURE(QStringList, GamePlugins, getLoadOrder, );
+ }
+ bool lightPluginsAreSupported() override
+ {
+ PYBIND11_OVERRIDE(bool, GamePlugins, lightPluginsAreSupported, );
+ }
+ bool mediumPluginsAreSupported() override
+ {
+ PYBIND11_OVERRIDE(bool, GamePlugins, mediumPluginsAreSupported, );
+ }
+ bool blueprintPluginsAreSupported() override
+ {
+ PYBIND11_OVERRIDE(bool, GamePlugins, blueprintPluginsAreSupported, );
+ }
+ };
+
+ class PyLocalSavegames : public LocalSavegames {
+ public:
+ MappingType mappings(const QDir& profileSaveDir) const override
+ {
+ PYBIND11_OVERRIDE_PURE(MappingType, LocalSavegames, mappings,
+ profileSaveDir);
+ }
+ bool prepareProfile(MOBase::IProfile* profile) override
+ {
+ PYBIND11_OVERRIDE_PURE(bool, LocalSavegames, prepareProfile, profile);
+ }
+ };
+
+ class PyModDataChecker : public ModDataChecker {
+ public:
+ CheckReturn
+ dataLooksValid(std::shared_ptr<const MOBase::IFileTree> fileTree) const override
+ {
+ PYBIND11_OVERRIDE_PURE(CheckReturn, ModDataChecker, dataLooksValid,
+ fileTree);
+ }
+
+ std::shared_ptr<MOBase::IFileTree>
+ fix(std::shared_ptr<MOBase::IFileTree> fileTree) const override
+ {
+ PYBIND11_OVERRIDE(std::shared_ptr<MOBase::IFileTree>, ModDataChecker, fix,
+ fileTree);
+ }
+ };
+
+ class PyModDataContent : public ModDataContent {
+ public:
+ std::vector<Content> getAllContents() const override
+ {
+ PYBIND11_OVERRIDE_PURE(std::vector<Content>, ModDataContent,
+ getAllContents, );
+ ;
+ }
+ std::vector<int>
+ getContentsFor(std::shared_ptr<const MOBase::IFileTree> fileTree) const override
+ {
+ PYBIND11_OVERRIDE_PURE(std::vector<int>, ModDataContent, getContentsFor,
+ fileTree);
+ }
+ };
+
+ class PySaveGameInfo : public SaveGameInfo {
+ public:
+ MissingAssets getMissingAssets(MOBase::ISaveGame const& save) const override
+ {
+ PYBIND11_OVERRIDE_PURE(MissingAssets, SaveGameInfo, getMissingAssets,
+ &save);
+ }
+ ISaveGameInfoWidget* getSaveGameWidget(QWidget* parent = 0) const override
+ {
+ PYBIND11_OVERRIDE_PURE(ISaveGameInfoWidget*, SaveGameInfo,
+ getSaveGameWidget, parent);
+ }
+ };
+
+ class PyScriptExtender : public ScriptExtender {
+ public:
+ QString BinaryName() const override
+ {
+ PYBIND11_OVERRIDE_PURE(QString, ScriptExtender, binaryName, );
+ }
+
+ QString PluginPath() const override
+ {
+ PYBIND11_OVERRIDE_PURE(DirectoryWrapper, ScriptExtender, pluginPath, );
+ }
+
+ QString loaderName() const override
+ {
+ PYBIND11_OVERRIDE_PURE(QString, ScriptExtender, loaderName, );
+ }
+
+ QString loaderPath() const override
+ {
+ PYBIND11_OVERRIDE_PURE(FileWrapper, ScriptExtender, loaderPath, );
+ }
+
+ QString savegameExtension() const override
+ {
+ PYBIND11_OVERRIDE_PURE(QString, ScriptExtender, savegameExtension, );
+ }
+
+ bool isInstalled() const override
+ {
+ PYBIND11_OVERRIDE_PURE(bool, ScriptExtender, isInstalled, );
+ }
+
+ QString getExtenderVersion() const override
+ {
+ PYBIND11_OVERRIDE_PURE(QString, ScriptExtender, getExtenderVersion, );
+ }
+
+ WORD getArch() const override
+ {
+ PYBIND11_OVERRIDE_PURE(WORD, ScriptExtender, getArch, );
+ }
+ };
+
+ class PyUnmanagedMods : public UnmanagedMods {
+ public:
+ QStringList mods(bool onlyOfficial) const override
+ {
+ PYBIND11_OVERRIDE_PURE(QStringList, UnmanagedMods, mods, onlyOfficial);
+ }
+ QString displayName(const QString& modName) const override
+ {
+ PYBIND11_OVERRIDE_PURE(QString, UnmanagedMods, displayName, modName);
+ }
+ QFileInfo referenceFile(const QString& modName) const override
+ {
+ PYBIND11_OVERRIDE_PURE(FileWrapper, UnmanagedMods, referenceFile, modName);
+ }
+ QStringList secondaryFiles(const QString& modName) const override
+ {
+ return toQStringList([&] {
+ PYBIND11_OVERRIDE_PURE(QList<FileWrapper>, UnmanagedMods,
+ secondaryFiles, modName);
+ }());
+ }
+ };
+
+ void add_game_feature_bindings(pybind11::module_ m)
+ {
+ // this is just to allow accepting GameFeature in function, we do not expose
+ // anything from game feature to Python since typeInfo() is useless in Python
+ //
+ py::class_<GameFeature, std::shared_ptr<GameFeature>>(m, "GameFeature");
+
+ // BSAInvalidation
+
+ py::class_<BSAInvalidation, GameFeature, PyBSAInvalidation,
+ std::shared_ptr<BSAInvalidation>>(m, "BSAInvalidation")
+ .def(py::init<>())
+ .def("isInvalidationBSA", &BSAInvalidation::isInvalidationBSA, "name"_a)
+ .def("deactivate", &BSAInvalidation::deactivate, "profile"_a)
+ .def("activate", &BSAInvalidation::activate, "profile"_a);
+
+ // DataArchives
+
+ py::class_<DataArchives, GameFeature, PyDataArchives,
+ std::shared_ptr<DataArchives>>(m, "DataArchives")
+ .def(py::init<>())
+ .def("vanillaArchives", &DataArchives::vanillaArchives)
+ .def("archives", &DataArchives::archives, "profile"_a)
+ .def("addArchive", &DataArchives::addArchive, "profile"_a, "index"_a,
+ "name"_a)
+ .def("removeArchive", &DataArchives::removeArchive, "profile"_a, "name"_a);
+
+ // GamePlugins
+
+ py::class_<GamePlugins, GameFeature, PyGamePlugins,
+ std::shared_ptr<GamePlugins>>(m, "GamePlugins")
+ .def(py::init<>())
+ .def("writePluginLists", &GamePlugins::writePluginLists, "plugin_list"_a)
+ .def("readPluginLists", &GamePlugins::readPluginLists, "plugin_list"_a)
+ .def("getLoadOrder", &GamePlugins::getLoadOrder)
+ .def("lightPluginsAreSupported", &GamePlugins::lightPluginsAreSupported)
+ .def("mediumPluginsAreSupported", &GamePlugins::mediumPluginsAreSupported)
+ .def("blueprintPluginsAreSupported",
+ &GamePlugins::blueprintPluginsAreSupported);
+
+ // LocalSavegames
+
+ py::class_<LocalSavegames, GameFeature, PyLocalSavegames,
+ std::shared_ptr<LocalSavegames>>(m, "LocalSavegames")
+ .def(py::init<>())
+ .def("mappings", &LocalSavegames::mappings, "profile_save_dir"_a)
+ .def("prepareProfile", &LocalSavegames::prepareProfile, "profile"_a);
+
+ // ModDataChecker
+
+ py::class_<ModDataChecker, GameFeature, PyModDataChecker,
+ std::shared_ptr<ModDataChecker>>
+ pyModDataChecker(m, "ModDataChecker");
+
+ py::enum_<ModDataChecker::CheckReturn>(pyModDataChecker, "CheckReturn")
+ .value("INVALID", ModDataChecker::CheckReturn::INVALID)
+ .value("FIXABLE", ModDataChecker::CheckReturn::FIXABLE)
+ .value("VALID", ModDataChecker::CheckReturn::VALID)
+ .export_values();
+
+ pyModDataChecker.def(py::init<>())
+ .def("dataLooksValid", &ModDataChecker::dataLooksValid, "filetree"_a)
+ .def("fix", &ModDataChecker::fix, "filetree"_a);
+
+ // ModDataContent
+ py::class_<ModDataContent, GameFeature, PyModDataContent,
+ std::shared_ptr<ModDataContent>>
+ pyModDataContent(m, "ModDataContent");
+
+ py::class_<ModDataContent::Content>(pyModDataContent, "Content")
+ .def(py::init<int, QString, QString, bool>(), "id"_a, "name"_a, "icon"_a,
+ "filter_only"_a = false)
+ .def_property_readonly("id", &ModDataContent::Content::id)
+ .def_property_readonly("name", &ModDataContent::Content::name)
+ .def_property_readonly("icon", &ModDataContent::Content::icon)
+ .def("isOnlyForFilter", &ModDataContent::Content::isOnlyForFilter);
+
+ pyModDataContent.def(py::init<>())
+ .def("getAllContents", &ModDataContent::getAllContents)
+ .def("getContentsFor", &ModDataContent::getContentsFor, "filetree"_a);
+
+ // SaveGameInfo
+
+ py::class_<SaveGameInfo, GameFeature, PySaveGameInfo,
+ std::shared_ptr<SaveGameInfo>>(m, "SaveGameInfo")
+ .def(py::init<>())
+ .def("getMissingAssets", &SaveGameInfo::getMissingAssets, "save"_a)
+ .def("getSaveGameWidget", &SaveGameInfo::getSaveGameWidget,
+ py::return_value_policy::reference, "parent"_a);
+
+ // ScriptExtender
+
+ py::class_<ScriptExtender, GameFeature, PyScriptExtender,
+ std::shared_ptr<ScriptExtender>>(m, "ScriptExtender")
+ .def(py::init<>())
+ .def("binaryName", &ScriptExtender::BinaryName)
+ .def("pluginPath", wrap_return_for_directory(&ScriptExtender::PluginPath))
+ .def("loaderName", &ScriptExtender::loaderName)
+ .def("loaderPath", wrap_return_for_filepath(&ScriptExtender::loaderPath))
+ .def("savegameExtension", &ScriptExtender::savegameExtension)
+ .def("isInstalled", &ScriptExtender::isInstalled)
+ .def("getExtenderVersion", &ScriptExtender::getExtenderVersion)
+ .def("getArch", &ScriptExtender::getArch);
+
+ // UnmanagedMods
+
+ py::class_<UnmanagedMods, GameFeature, PyUnmanagedMods,
+ std::shared_ptr<UnmanagedMods>>(m, "UnmanagedMods")
+ .def(py::init<>())
+ .def("mods", &UnmanagedMods::mods, "official_only"_a)
+ .def("displayName", &UnmanagedMods::displayName, "mod_name"_a)
+ .def("referenceFile",
+ wrap_return_for_filepath(&UnmanagedMods::referenceFile), "mod_name"_a)
+ .def(
+ "secondaryFiles",
+ [](UnmanagedMods* m, const QString& modName) -> QList<FileWrapper> {
+ auto result = m->secondaryFiles(modName);
+ return {result.begin(), result.end()};
+ },
+ "mod_name"_a);
+ }
+
+ void add_igamefeatures_classes(py::module_ m)
+ {
+ py::class_<IGameFeatures>(m, "IGameFeatures")
+ .def("registerFeature",
+ py::overload_cast<QStringList const&, std::shared_ptr<GameFeature>,
+ int, bool>(&IGameFeatures::registerFeature),
+ "games"_a, "feature"_a, "priority"_a, "replace"_a = false)
+ .def("registerFeature",
+ py::overload_cast<MOBase::IPluginGame*, std::shared_ptr<GameFeature>,
+ int, bool>(&IGameFeatures::registerFeature),
+ "game"_a, "feature"_a, "priority"_a, "replace"_a = false)
+ .def("registerFeature",
+ py::overload_cast<std::shared_ptr<GameFeature>, int, bool>(
+ &IGameFeatures::registerFeature),
+ "feature"_a, "priority"_a, "replace"_a = false)
+ .def("unregisterFeature", &IGameFeatures::unregisterFeature, "feature"_a)
+ .def("unregisterFeatures", &unregister_feature, "feature_type"_a)
+ .def("gameFeature", &extract_feature, "feature_type"_a,
+ py ::return_value_policy::reference);
+ }
+
+} // namespace mo2::python
+
+namespace mo2::python {
+
+ class GameFeaturesHelper {
+ template <class F, std::size_t... Is>
+ static void helper(F&& f, std::index_sequence<Is...>)
+ {
+ (f(static_cast<
+ std::tuple_element_t<Is, MOBase::details::BaseGameFeaturesP>>(
+ nullptr)),
+ ...);
+ }
+
+ public:
+ // apply the function f on a null-pointer of type Feature* for each game
+ // feature
+ template <class F>
+ static void apply(F&& f)
+ {
+ helper(f, std::make_index_sequence<
+ std::tuple_size_v<MOBase::details::BaseGameFeaturesP>>{});
+ }
+ };
+
+ pybind11::object extract_feature(IGameFeatures const& gameFeatures,
+ pybind11::object type)
+ {
+ py::object py_feature = py::none();
+ GameFeaturesHelper::apply([&]<class Feature>(Feature*) {
+ if (py::type::of<Feature>().is(type)) {
+ py_feature = py::cast(gameFeatures.gameFeature<Feature>(),
+ py::return_value_policy::reference);
+ }
+ });
+ return py_feature;
+ }
+
+ int unregister_feature(MOBase::IGameFeatures& gameFeatures, pybind11::object type)
+ {
+ int count = 0;
+ GameFeaturesHelper::apply([&]<class Feature>(Feature*) {
+ if (py::type::of<Feature>().is(type)) {
+ count = gameFeatures.unregisterFeatures<Feature>();
+ }
+ });
+ return count;
+ }
+
+} // namespace mo2::python
diff --git a/libs/plugin_python/src/mobase/wrappers/known_folders.h b/libs/plugin_python/src/mobase/wrappers/known_folders.h
new file mode 100644
index 0000000..f74238b
--- /dev/null
+++ b/libs/plugin_python/src/mobase/wrappers/known_folders.h
@@ -0,0 +1,155 @@
+#include <KnownFolders.h>
+
+namespace mo2::python {
+
+ struct KnownFolder {
+ const char* name;
+ KNOWNFOLDERID guid;
+ };
+
+ const std::array<KnownFolder, 142> KNOWN_FOLDERS{{
+ {"AccountPictures", FOLDERID_AccountPictures},
+ {"AddNewPrograms", FOLDERID_AddNewPrograms},
+ {"AdminTools", FOLDERID_AdminTools},
+ {"AllAppMods", FOLDERID_AllAppMods},
+ {"AppCaptures", FOLDERID_AppCaptures},
+ {"AppDataDesktop", FOLDERID_AppDataDesktop},
+ {"AppDataDocuments", FOLDERID_AppDataDocuments},
+ {"AppDataFavorites", FOLDERID_AppDataFavorites},
+ {"AppDataProgramData", FOLDERID_AppDataProgramData},
+ {"ApplicationShortcuts", FOLDERID_ApplicationShortcuts},
+ {"AppsFolder", FOLDERID_AppsFolder},
+ {"AppUpdates", FOLDERID_AppUpdates},
+ {"CameraRoll", FOLDERID_CameraRoll},
+ {"CameraRollLibrary", FOLDERID_CameraRollLibrary},
+ {"CDBurning", FOLDERID_CDBurning},
+ {"ChangeRemovePrograms", FOLDERID_ChangeRemovePrograms},
+ {"CommonAdminTools", FOLDERID_CommonAdminTools},
+ {"CommonOEMLinks", FOLDERID_CommonOEMLinks},
+ {"CommonPrograms", FOLDERID_CommonPrograms},
+ {"CommonStartMenu", FOLDERID_CommonStartMenu},
+ {"CommonStartMenuPlaces", FOLDERID_CommonStartMenuPlaces},
+ {"CommonStartup", FOLDERID_CommonStartup},
+ {"CommonTemplates", FOLDERID_CommonTemplates},
+ {"ComputerFolder", FOLDERID_ComputerFolder},
+ {"ConflictFolder", FOLDERID_ConflictFolder},
+ {"ConnectionsFolder", FOLDERID_ConnectionsFolder},
+ {"Contacts", FOLDERID_Contacts},
+ {"ControlPanelFolder", FOLDERID_ControlPanelFolder},
+ {"Cookies", FOLDERID_Cookies},
+ {"CurrentAppMods", FOLDERID_CurrentAppMods},
+ {"Desktop", FOLDERID_Desktop},
+ {"DevelopmentFiles", FOLDERID_DevelopmentFiles},
+ {"Device", FOLDERID_Device},
+ {"DeviceMetadataStore", FOLDERID_DeviceMetadataStore},
+ {"Documents", FOLDERID_Documents},
+ {"DocumentsLibrary", FOLDERID_DocumentsLibrary},
+ {"Downloads", FOLDERID_Downloads},
+ {"Favorites", FOLDERID_Favorites},
+ {"Fonts", FOLDERID_Fonts},
+ {"Games", FOLDERID_Games},
+ {"GameTasks", FOLDERID_GameTasks},
+ {"History", FOLDERID_History},
+ {"HomeGroup", FOLDERID_HomeGroup},
+ {"HomeGroupCurrentUser", FOLDERID_HomeGroupCurrentUser},
+ {"ImplicitAppShortcuts", FOLDERID_ImplicitAppShortcuts},
+ {"InternetCache", FOLDERID_InternetCache},
+ {"InternetFolder", FOLDERID_InternetFolder},
+ {"Libraries", FOLDERID_Libraries},
+ {"Links", FOLDERID_Links},
+ {"LocalAppData", FOLDERID_LocalAppData},
+ {"LocalAppDataLow", FOLDERID_LocalAppDataLow},
+ {"LocalDocuments", FOLDERID_LocalDocuments},
+ {"LocalDownloads", FOLDERID_LocalDownloads},
+ {"LocalizedResourcesDir", FOLDERID_LocalizedResourcesDir},
+ {"LocalMusic", FOLDERID_LocalMusic},
+ {"LocalPictures", FOLDERID_LocalPictures},
+ {"LocalStorage", FOLDERID_LocalStorage},
+ {"LocalVideos", FOLDERID_LocalVideos},
+ {"Music", FOLDERID_Music},
+ {"MusicLibrary", FOLDERID_MusicLibrary},
+ {"NetHood", FOLDERID_NetHood},
+ {"NetworkFolder", FOLDERID_NetworkFolder},
+ {"Objects3D", FOLDERID_Objects3D},
+ {"OneDrive", FOLDERID_OneDrive},
+ {"OriginalImages", FOLDERID_OriginalImages},
+ {"PhotoAlbums", FOLDERID_PhotoAlbums},
+ {"Pictures", FOLDERID_Pictures},
+ {"PicturesLibrary", FOLDERID_PicturesLibrary},
+ {"Playlists", FOLDERID_Playlists},
+ {"PrintersFolder", FOLDERID_PrintersFolder},
+ {"PrintHood", FOLDERID_PrintHood},
+ {"Profile", FOLDERID_Profile},
+ {"ProgramData", FOLDERID_ProgramData},
+ {"ProgramFiles", FOLDERID_ProgramFiles},
+ {"ProgramFilesCommon", FOLDERID_ProgramFilesCommon},
+ {"ProgramFilesCommonX64", FOLDERID_ProgramFilesCommonX64},
+ {"ProgramFilesCommonX86", FOLDERID_ProgramFilesCommonX86},
+ {"ProgramFilesX64", FOLDERID_ProgramFilesX64},
+ {"ProgramFilesX86", FOLDERID_ProgramFilesX86},
+ {"Programs", FOLDERID_Programs},
+ {"Public", FOLDERID_Public},
+ {"PublicDesktop", FOLDERID_PublicDesktop},
+ {"PublicDocuments", FOLDERID_PublicDocuments},
+ {"PublicDownloads", FOLDERID_PublicDownloads},
+ {"PublicGameTasks", FOLDERID_PublicGameTasks},
+ {"PublicLibraries", FOLDERID_PublicLibraries},
+ {"PublicMusic", FOLDERID_PublicMusic},
+ {"PublicPictures", FOLDERID_PublicPictures},
+ {"PublicRingtones", FOLDERID_PublicRingtones},
+ {"PublicUserTiles", FOLDERID_PublicUserTiles},
+ {"PublicVideos", FOLDERID_PublicVideos},
+ {"QuickLaunch", FOLDERID_QuickLaunch},
+ {"Recent", FOLDERID_Recent},
+ {"RecordedCalls", FOLDERID_RecordedCalls},
+ {"RecordedTVLibrary", FOLDERID_RecordedTVLibrary},
+ {"RecycleBinFolder", FOLDERID_RecycleBinFolder},
+ {"ResourceDir", FOLDERID_ResourceDir},
+ {"RetailDemo", FOLDERID_RetailDemo},
+ {"Ringtones", FOLDERID_Ringtones},
+ {"RoamedTileImages", FOLDERID_RoamedTileImages},
+ {"RoamingAppData", FOLDERID_RoamingAppData},
+ {"RoamingTiles", FOLDERID_RoamingTiles},
+ {"SampleMusic", FOLDERID_SampleMusic},
+ {"SamplePictures", FOLDERID_SamplePictures},
+ {"SamplePlaylists", FOLDERID_SamplePlaylists},
+ {"SampleVideos", FOLDERID_SampleVideos},
+ {"SavedGames", FOLDERID_SavedGames},
+ {"SavedPictures", FOLDERID_SavedPictures},
+ {"SavedPicturesLibrary", FOLDERID_SavedPicturesLibrary},
+ {"SavedSearches", FOLDERID_SavedSearches},
+ {"Screenshots", FOLDERID_Screenshots},
+ {"SEARCH_CSC", FOLDERID_SEARCH_CSC},
+ {"SEARCH_MAPI", FOLDERID_SEARCH_MAPI},
+ {"SearchHistory", FOLDERID_SearchHistory},
+ {"SearchHome", FOLDERID_SearchHome},
+ {"SearchTemplates", FOLDERID_SearchTemplates},
+ {"SendTo", FOLDERID_SendTo},
+ {"SidebarDefaultParts", FOLDERID_SidebarDefaultParts},
+ {"SidebarParts", FOLDERID_SidebarParts},
+ {"SkyDrive", FOLDERID_SkyDrive},
+ {"SkyDriveCameraRoll", FOLDERID_SkyDriveCameraRoll},
+ {"SkyDriveDocuments", FOLDERID_SkyDriveDocuments},
+ {"SkyDriveMusic", FOLDERID_SkyDriveMusic},
+ {"SkyDrivePictures", FOLDERID_SkyDrivePictures},
+ {"StartMenu", FOLDERID_StartMenu},
+ {"StartMenuAllPrograms", FOLDERID_StartMenuAllPrograms},
+ {"Startup", FOLDERID_Startup},
+ {"SyncManagerFolder", FOLDERID_SyncManagerFolder},
+ {"SyncResultsFolder", FOLDERID_SyncResultsFolder},
+ {"SyncSetupFolder", FOLDERID_SyncSetupFolder},
+ {"System", FOLDERID_System},
+ {"SystemX86", FOLDERID_SystemX86},
+ {"Templates", FOLDERID_Templates},
+ {"UserPinned", FOLDERID_UserPinned},
+ {"UserProfiles", FOLDERID_UserProfiles},
+ {"UserProgramFiles", FOLDERID_UserProgramFiles},
+ {"UserProgramFilesCommon", FOLDERID_UserProgramFilesCommon},
+ {"UsersFiles", FOLDERID_UsersFiles},
+ {"UsersLibraries", FOLDERID_UsersLibraries},
+ {"Videos", FOLDERID_Videos},
+ {"VideosLibrary", FOLDERID_VideosLibrary},
+ {"Windows", FOLDERID_Windows},
+ }};
+
+} // namespace mo2::python
diff --git a/libs/plugin_python/src/mobase/wrappers/pyfiletree.cpp b/libs/plugin_python/src/mobase/wrappers/pyfiletree.cpp
new file mode 100644
index 0000000..6dacf26
--- /dev/null
+++ b/libs/plugin_python/src/mobase/wrappers/pyfiletree.cpp
@@ -0,0 +1,329 @@
+#include "pyfiletree.h"
+
+#include <tuple>
+#include <variant>
+
+#include "../pybind11_all.h"
+
+#include <uibase/ifiletree.h>
+#include <uibase/log.h>
+
+namespace py = pybind11;
+using namespace MOBase;
+
+namespace mo2::detail {
+
+ // filetree implementation for testing purpose
+ //
+ class PyFileTree : public IFileTree {
+ public:
+ using callback_t = std::function<bool(QString, bool)>;
+
+ PyFileTree(std::shared_ptr<const IFileTree> parent, QString name,
+ callback_t callback)
+ : FileTreeEntry(parent, name), IFileTree(), m_Callback(callback)
+ {
+ }
+
+ std::shared_ptr<FileTreeEntry> addFile(QString name, bool) override
+ {
+ if (m_Callback && !m_Callback(name, false)) {
+ throw UnsupportedOperationException("File rejected by callback.");
+ }
+ return IFileTree::addFile(name);
+ }
+
+ std::shared_ptr<IFileTree> addDirectory(QString name) override
+ {
+ if (m_Callback && !m_Callback(name, true)) {
+ throw UnsupportedOperationException("Directory rejected by callback.");
+ }
+ return IFileTree::addDirectory(name);
+ }
+
+ protected:
+ std::shared_ptr<IFileTree>
+ makeDirectory(std::shared_ptr<const IFileTree> parent,
+ QString name) const override
+ {
+ return std::make_shared<PyFileTree>(parent, name, m_Callback);
+ }
+
+ bool doPopulate([[maybe_unused]] std::shared_ptr<const IFileTree> parent,
+ std::vector<std::shared_ptr<FileTreeEntry>>&) const override
+ {
+ return true;
+ }
+ std::shared_ptr<IFileTree> doClone() const override
+ {
+ return std::make_shared<PyFileTree>(nullptr, name(), m_Callback);
+ }
+
+ private:
+ callback_t m_Callback;
+ };
+
+} // namespace mo2::detail
+
+#pragma optimize("", off)
+
+namespace pybind11 {
+ const void* polymorphic_type_hook<FileTreeEntry>::get(const FileTreeEntry* src,
+ const std::type_info*& type)
+ {
+ if (auto p = dynamic_cast<const IFileTree*>(src)) {
+ type = &typeid(IFileTree);
+ return p;
+ }
+ return src;
+ }
+} // namespace pybind11
+
+namespace mo2::python {
+
+ void add_ifiletree_bindings(pybind11::module_& m)
+ {
+ // FileTreeEntry class:
+ auto fileTreeEntryClass =
+ py::class_<FileTreeEntry, std::shared_ptr<FileTreeEntry>>(m,
+ "FileTreeEntry");
+
+ // IFileTree class:
+ auto iFileTreeClass =
+ py::class_<IFileTree, FileTreeEntry, std::shared_ptr<IFileTree>>(
+ m, "IFileTree", py::multiple_inheritance());
+
+ // this is FILE_OR_DIRECTORY but as a FileType since we kind of cheat for the
+ // exposure in Python and this help pybind11 creates proper typing
+
+ const auto FILE_OR_DIRECTORY = static_cast<FileTreeEntry::FileType>(
+ FileTreeEntry::FILE_OR_DIRECTORY.toInt());
+
+ // we do not use the enum directly, we will mostly bind the FileTypes
+ // (with an S)
+ py::enum_<FileTreeEntry::FileType>(fileTreeEntryClass, "FileTypes",
+ py::arithmetic{})
+ .value("FILE", FileTreeEntry::FileType::FILE)
+ .value("DIRECTORY", FileTreeEntry::FileType::DIRECTORY)
+ .value("FILE_OR_DIRECTORY", FILE_OR_DIRECTORY)
+ .export_values();
+
+ fileTreeEntryClass
+
+ .def("isFile", &FileTreeEntry::isFile)
+ .def("isDir", &FileTreeEntry::isDir)
+ .def("fileType", &FileTreeEntry::fileType)
+ .def("name", &FileTreeEntry::name)
+ .def("suffix", &FileTreeEntry::suffix)
+ .def(
+ "hasSuffix",
+ [](FileTreeEntry* entry, QStringList suffixes) {
+ return entry->hasSuffix(suffixes);
+ },
+ py::arg("suffixes"))
+ .def(
+ "hasSuffix",
+ [](FileTreeEntry* entry, QString suffix) {
+ return entry->hasSuffix(suffix);
+ },
+ py::arg("suffix"))
+ .def("parent", py::overload_cast<>(&FileTreeEntry::parent))
+ .def("path", &FileTreeEntry::path, py::arg("sep") = "\\")
+ .def("pathFrom", &FileTreeEntry::pathFrom, py::arg("tree"),
+ py::arg("sep") = "\\")
+
+ // Mutable operation:
+ .def("detach", &FileTreeEntry::detach)
+ .def("moveTo", &FileTreeEntry::moveTo, py::arg("tree"))
+
+ // Special methods:
+ .def("__eq__",
+ [](const FileTreeEntry* entry, QString other) {
+ return entry->compare(other) == 0;
+ })
+ .def("__eq__",
+ [](const FileTreeEntry* entry, std::shared_ptr<FileTreeEntry> other) {
+ return entry == other.get();
+ })
+
+ // Special methods for debug:
+ .def("__repr__", [](const FileTreeEntry* entry) {
+ return "FileTreeEntry(\"" + entry->name() + "\")";
+ });
+
+ py::enum_<IFileTree::InsertPolicy>(iFileTreeClass, "InsertPolicy")
+ .value("FAIL_IF_EXISTS", IFileTree::InsertPolicy::FAIL_IF_EXISTS)
+ .value("REPLACE", IFileTree::InsertPolicy::REPLACE)
+ .value("MERGE", IFileTree::InsertPolicy::MERGE)
+ .export_values();
+
+ py::enum_<IFileTree::WalkReturn>(iFileTreeClass, "WalkReturn")
+ .value("CONTINUE", IFileTree::WalkReturn::CONTINUE)
+ .value("STOP", IFileTree::WalkReturn::STOP)
+ .value("SKIP", IFileTree::WalkReturn::SKIP)
+ .export_values();
+
+ // in C++ this is not an inner enum due to the conditional feature of glob(),
+ // but in Python this makes more sense as a inner enum
+ py::enum_<GlobPatternType>(iFileTreeClass, "GlobPatternType")
+ .value("GLOB", GlobPatternType::GLOB)
+ .value("REGEX", GlobPatternType::REGEX)
+ .export_values();
+
+ // Non-mutable operations:
+ iFileTreeClass.def("exists",
+ py::overload_cast<QString, IFileTree::FileTypes>(
+ &IFileTree::exists, py::const_),
+ py::arg("path"), py::arg("type") = FILE_OR_DIRECTORY);
+ iFileTreeClass.def(
+ "find", py::overload_cast<QString, IFileTree::FileTypes>(&IFileTree::find),
+ py::arg("path"), py::arg("type") = FILE_OR_DIRECTORY);
+ iFileTreeClass.def("pathTo", &IFileTree::pathTo, py::arg("entry"),
+ py::arg("sep") = "\\");
+
+ iFileTreeClass.def(
+ "walk",
+ py::overload_cast<
+ std::function<IFileTree::WalkReturn(
+ QString const&, std::shared_ptr<const FileTreeEntry>)>,
+ QString>(&IFileTree::walk, py::const_),
+ py::arg("callback"), py::arg("sep") = "\\");
+
+ // the walk() and glob() generator version are free functions in C++ due to the
+ // conditional nature, but in Python, it makes more sense to have them as method
+ // of IFileTree directly
+
+ iFileTreeClass.def("walk", [](std::shared_ptr<const IFileTree> tree) {
+ return make_generator(walk(tree));
+ });
+
+ iFileTreeClass.def(
+ "glob",
+ [](std::shared_ptr<const IFileTree> tree, QString pattern,
+ GlobPatternType patternType) {
+ return make_generator(glob(tree, pattern, patternType));
+ },
+ py::arg("pattern"), py::arg("type") = GlobPatternType::GLOB);
+
+ // Kind-of-static operations:
+ iFileTreeClass.def("createOrphanTree", &IFileTree::createOrphanTree,
+ py::arg("name") = "");
+
+ // addFile() and addDirectory throws exception instead of returning null
+ // pointer in order to have better traces.
+ iFileTreeClass.def(
+ "addFile",
+ [](IFileTree* w, QString path, bool replaceIfExists) {
+ auto result = w->addFile(path, replaceIfExists);
+ if (result == nullptr) {
+ throw std::logic_error("addFile failed");
+ }
+ return result;
+ },
+ py::arg("path"), py::arg("replace_if_exists") = false);
+ iFileTreeClass.def(
+ "addDirectory",
+ [](IFileTree* w, QString path) {
+ auto result = w->addDirectory(path);
+ if (result == nullptr) {
+ throw std::logic_error("addDirectory failed");
+ }
+ return result;
+ },
+ py::arg("path"));
+
+ // Merge needs custom return types depending if the user wants overrides
+ // or not. A failure is translated into an exception for easier tracing
+ // and handling.
+ iFileTreeClass.def(
+ "merge",
+ [](IFileTree* p, std::shared_ptr<IFileTree> other, bool returnOverwrites)
+ -> std::variant<IFileTree::OverwritesType, std::size_t> {
+ IFileTree::OverwritesType overwrites;
+ auto result = p->merge(other, returnOverwrites ? &overwrites : nullptr);
+ if (result == IFileTree::MERGE_FAILED) {
+ throw std::logic_error("merge failed");
+ }
+ if (returnOverwrites) {
+ return {overwrites};
+ }
+ return {result};
+ },
+ py::arg("other"), py::arg("overwrites") = false);
+
+ // Insert and erase returns an iterator, which makes no sense in python,
+ // so we convert it to bool. Erase is also renamed "remove" since
+ // "erase" is very C++.
+ iFileTreeClass.def(
+ "insert",
+ [](IFileTree* p, std::shared_ptr<FileTreeEntry> entry,
+ IFileTree::InsertPolicy insertPolicy) {
+ return p->insert(entry, insertPolicy) == p->end();
+ },
+ py::arg("entry"),
+ py::arg("policy") = IFileTree::InsertPolicy::FAIL_IF_EXISTS);
+
+ iFileTreeClass.def(
+ "remove",
+ [](IFileTree* p, QString name) {
+ return p->erase(name).first != p->end();
+ },
+ py::arg("name"));
+ iFileTreeClass.def(
+ "remove",
+ [](IFileTree* p, std::shared_ptr<FileTreeEntry> entry) {
+ return p->erase(entry) != p->end();
+ },
+ py::arg("entry"));
+
+ iFileTreeClass.def("move", &IFileTree::move, py::arg("entry"), py::arg("path"),
+ py::arg("policy") = IFileTree::InsertPolicy::FAIL_IF_EXISTS);
+ iFileTreeClass.def(
+ "copy",
+ [](IFileTree* w, std::shared_ptr<FileTreeEntry> entry, QString path,
+ IFileTree::InsertPolicy insertPolicy) {
+ auto result = w->copy(entry, path, insertPolicy);
+ if (result == nullptr) {
+ throw std::logic_error("copy failed");
+ }
+ return result;
+ },
+ py::arg("entry"), py::arg("path") = "",
+ py::arg("insert_policy") = IFileTree::InsertPolicy::FAIL_IF_EXISTS);
+
+ iFileTreeClass.def("clear", &IFileTree::clear);
+ iFileTreeClass.def("removeAll", &IFileTree::removeAll, py::arg("names"));
+ iFileTreeClass.def("removeIf", &IFileTree::removeIf, py::arg("filter"));
+
+ // Special methods:
+ iFileTreeClass.def("__getitem__",
+ py::overload_cast<std::size_t>(&IFileTree::at));
+
+ iFileTreeClass.def("__iter__", [](IFileTree* tree) {
+ return py::make_iterator(*tree);
+ });
+ iFileTreeClass.def("__len__", &IFileTree::size);
+ iFileTreeClass.def(
+ "__bool__", +[](const IFileTree* tree) {
+ return !tree->empty();
+ });
+ iFileTreeClass.def(
+ "__repr__", +[](const IFileTree* entry) {
+ return "IFileTree(\"" + entry->name() + "\")";
+ });
+ }
+
+ void add_make_tree_function(pybind11::module_& m)
+ {
+ m.def(
+ "makeTree",
+ [](mo2::detail::PyFileTree::callback_t callback)
+ -> std::shared_ptr<IFileTree> {
+ return std::make_shared<mo2::detail::PyFileTree>(nullptr, "", callback);
+ },
+ py::arg("callback") = mo2::detail::PyFileTree::callback_t{});
+ }
+
+} // namespace mo2::python
+
+#pragma optimize("", on)
diff --git a/libs/plugin_python/src/mobase/wrappers/pyfiletree.h b/libs/plugin_python/src/mobase/wrappers/pyfiletree.h
new file mode 100644
index 0000000..0e94665
--- /dev/null
+++ b/libs/plugin_python/src/mobase/wrappers/pyfiletree.h
@@ -0,0 +1,34 @@
+#ifndef MO2_PYTHON_FILETREE_H
+#define MO2_PYTHON_FILETREE_H
+
+#include "../pybind11_all.h"
+
+#include <uibase/ifiletree.h>
+
+namespace pybind11 {
+ template <>
+ struct polymorphic_type_hook<MOBase::FileTreeEntry> {
+ static const void* get(const MOBase::FileTreeEntry* src,
+ const std::type_info*& type);
+ };
+} // namespace pybind11
+
+namespace mo2::python {
+
+ /**
+ * @brief Add bindings for FileTreeEntry andIFileTree to the given module.
+ *
+ * @param mobase Module to add the bindings to.
+ */
+ void add_ifiletree_bindings(pybind11::module_& m);
+
+ /**
+ * @brief Add makeTree() function to the given module, useful for debugging.
+ *
+ * @param mobase Module to add the function to.
+ */
+ void add_make_tree_function(pybind11::module_& m);
+
+} // namespace mo2::python
+
+#endif
diff --git a/libs/plugin_python/src/mobase/wrappers/pyplugins.cpp b/libs/plugin_python/src/mobase/wrappers/pyplugins.cpp
new file mode 100644
index 0000000..bcce0df
--- /dev/null
+++ b/libs/plugin_python/src/mobase/wrappers/pyplugins.cpp
@@ -0,0 +1,262 @@
+#include "wrappers.h"
+
+#include <tuple>
+
+#include "pyplugins.h"
+
+namespace py = pybind11;
+using namespace pybind11::literals;
+using namespace MOBase;
+
+namespace mo2::python {
+
+ // this one is kind of big so it has its own function
+ void add_iplugingame_bindings(pybind11::module_ m)
+ {
+ py::enum_<IPluginGame::LoadOrderMechanism>(m, "LoadOrderMechanism")
+ .value("None", IPluginGame::LoadOrderMechanism::None)
+ .value("FileTime", IPluginGame::LoadOrderMechanism::FileTime)
+ .value("PluginsTxt", IPluginGame::LoadOrderMechanism::PluginsTxt)
+
+ .value("NONE", IPluginGame::LoadOrderMechanism::None)
+ .value("FILE_TIME", IPluginGame::LoadOrderMechanism::FileTime)
+ .value("PLUGINS_TXT", IPluginGame::LoadOrderMechanism::PluginsTxt);
+
+ py::enum_<IPluginGame::SortMechanism>(m, "SortMechanism")
+ .value("NONE", IPluginGame::SortMechanism::NONE)
+ .value("MLOX", IPluginGame::SortMechanism::MLOX)
+ .value("BOSS", IPluginGame::SortMechanism::BOSS)
+ .value("LOOT", IPluginGame::SortMechanism::LOOT);
+
+ // this does not actually do the conversion, but might be convenient
+ // for accessing the names for enum bits
+ py::enum_<IPluginGame::ProfileSetting>(m, "ProfileSetting", py::arithmetic())
+ .value("mods", IPluginGame::MODS)
+ .value("configuration", IPluginGame::CONFIGURATION)
+ .value("savegames", IPluginGame::SAVEGAMES)
+ .value("preferDefaults", IPluginGame::PREFER_DEFAULTS)
+
+ .value("MODS", IPluginGame::MODS)
+ .value("CONFIGURATION", IPluginGame::CONFIGURATION)
+ .value("SAVEGAMES", IPluginGame::SAVEGAMES)
+ .value("PREFER_DEFAULTS", IPluginGame::PREFER_DEFAULTS);
+
+ py::class_<IPluginGame, PyPluginGame, IPlugin,
+ std::unique_ptr<IPluginGame, py::nodelete>>(
+ m, "IPluginGame", py::multiple_inheritance())
+ .def(py::init<>())
+ .def("detectGame", &IPluginGame::detectGame)
+ .def("gameName", &IPluginGame::gameName)
+ .def("displayGameName", &IPluginGame::displayGameName)
+ .def("initializeProfile", &IPluginGame::initializeProfile, "directory"_a,
+ "settings"_a)
+ .def("listSaves", &IPluginGame::listSaves, "folder"_a)
+ .def("isInstalled", &IPluginGame::isInstalled)
+ .def("gameIcon", &IPluginGame::gameIcon)
+ .def("gameDirectory", &IPluginGame::gameDirectory)
+ .def("dataDirectory", &IPluginGame::dataDirectory)
+ .def("modDataDirectory", &IPluginGame::modDataDirectory)
+ .def("secondaryDataDirectories", &IPluginGame::secondaryDataDirectories)
+ .def("setGamePath", &IPluginGame::setGamePath, "path"_a)
+ .def("documentsDirectory", &IPluginGame::documentsDirectory)
+ .def("savesDirectory", &IPluginGame::savesDirectory)
+ .def("executables", &IPluginGame::executables)
+ .def("executableForcedLoads", &IPluginGame::executableForcedLoads)
+ .def("steamAPPId", &IPluginGame::steamAPPId)
+ .def("primaryPlugins", &IPluginGame::primaryPlugins)
+ .def("enabledPlugins", &IPluginGame::enabledPlugins)
+ .def("gameVariants", &IPluginGame::gameVariants)
+ .def("setGameVariant", &IPluginGame::setGameVariant, "variant"_a)
+ .def("binaryName", &IPluginGame::binaryName)
+ .def("gameShortName", &IPluginGame::gameShortName)
+ .def("lootGameName", &IPluginGame::lootGameName)
+ .def("primarySources", &IPluginGame::primarySources)
+ .def("validShortNames", &IPluginGame::validShortNames)
+ .def("gameNexusName", &IPluginGame::gameNexusName)
+ .def("iniFiles", &IPluginGame::iniFiles)
+ .def("DLCPlugins", &IPluginGame::DLCPlugins)
+ .def("CCPlugins", &IPluginGame::CCPlugins)
+ .def("loadOrderMechanism", &IPluginGame::loadOrderMechanism)
+ .def("sortMechanism", &IPluginGame::sortMechanism)
+ .def("nexusModOrganizerID", &IPluginGame::nexusModOrganizerID)
+ .def("nexusGameID", &IPluginGame::nexusGameID)
+ .def("looksValid", &IPluginGame::looksValid, "directory"_a)
+ .def("gameVersion", &IPluginGame::gameVersion)
+ .def("getLauncherName", &IPluginGame::getLauncherName)
+ .def("getSupportURL", &IPluginGame::getSupportURL)
+ .def("getModMappings", &IPluginGame::getModMappings);
+ }
+
+ // multiple installers
+ void add_iplugininstaller_bindings(pybind11::module_ m)
+ {
+ // this is bind but should not be inherited in Python - does not make sense,
+ // having it makes it simpler to bind the Simple and Custom installers
+ py::class_<IPluginInstaller, PyPluginInstallerBase<IPluginInstaller>, IPlugin,
+ std::unique_ptr<IPluginInstaller, py::nodelete>>(
+ m, "IPluginInstaller", py::multiple_inheritance())
+ .def("isArchiveSupported", &IPluginInstaller::isArchiveSupported, "tree"_a)
+ .def("priority", &IPluginInstaller::priority)
+ .def("onInstallationStart", &IPluginInstaller::onInstallationStart,
+ "archive"_a, "reinstallation"_a, "current_mod"_a)
+ .def("onInstallationEnd", &IPluginInstaller::onInstallationEnd, "result"_a,
+ "new_mod"_a)
+ .def("isManualInstaller", &IPluginInstaller::isManualInstaller)
+ .def("setParentWidget", &IPluginInstaller::setParentWidget, "parent"_a)
+ .def("setInstallationManager", &IPluginInstaller::setInstallationManager,
+ "manager"_a)
+ .def("_parentWidget",
+ &PyPluginInstallerBase<IPluginInstaller>::parentWidget)
+ .def("_manager", &PyPluginInstallerBase<IPluginInstaller>::manager,
+ py::return_value_policy::reference);
+
+ py::class_<IPluginInstallerSimple, PyPluginInstallerSimple, IPluginInstaller,
+ IPlugin, std::unique_ptr<IPluginInstallerSimple, py::nodelete>>(
+ m, "IPluginInstallerSimple", py::multiple_inheritance())
+ .def(py::init<>())
+
+ // note: keeping the variant here even if we always return a tuple
+ // to be consistent with the wrapper and have proper stubs generation.
+ .def(
+ "install",
+ [](IPluginInstallerSimple* p, GuessedValue<QString>& modName,
+ std::shared_ptr<IFileTree>& tree, QString& version,
+ int& nexusID) -> PyPluginInstallerSimple::py_install_return_type {
+ auto result = p->install(modName, tree, version, nexusID);
+ return std::make_tuple(result, tree, version, nexusID);
+ },
+ "name"_a, "tree"_a, "version"_a, "nexus_id"_a);
+
+ py::class_<IPluginInstallerCustom, PyPluginInstallerCustom, IPluginInstaller,
+ IPlugin, std::unique_ptr<IPluginInstallerCustom, py::nodelete>>(
+ m, "IPluginInstallerCustom", py::multiple_inheritance())
+ .def(py::init<>())
+ .def("isArchiveSupported", &IPluginInstallerCustom::isArchiveSupported,
+ "archive_name"_a)
+ .def("supportedExtensions", &IPluginInstallerCustom::supportedExtensions)
+ .def("install", &IPluginInstallerCustom::install, "mod_name"_a,
+ "game_name"_a, "archive_name"_a, "version"_a, "nexus_id"_a);
+ }
+
+ void add_plugins_bindings(pybind11::module_ m)
+ {
+ py::class_<IPlugin, PyPlugin, std::unique_ptr<IPlugin, py::nodelete>>(
+ m, "IPluginBase", py::multiple_inheritance())
+ .def(py::init<>())
+ .def("init", &IPlugin::init, "organizer"_a)
+ .def("name", &IPlugin::name)
+ .def("localizedName", &IPlugin::localizedName)
+ .def("master", &IPlugin::master)
+ .def("author", &IPlugin::author)
+ .def("description", &IPlugin::description)
+ .def("version", &IPlugin::version)
+ .def("requirements", &IPlugin::requirements)
+ .def("settings", &IPlugin::settings)
+ .def("enabledByDefault", &IPlugin::enabledByDefault);
+
+ py::class_<IPyPlugin, PyPlugin, IPlugin,
+ std::unique_ptr<IPyPlugin, py::nodelete>>(m, "IPlugin",
+ py::multiple_inheritance())
+ .def(py::init<>());
+
+ py::class_<IPyPluginFileMapper, PyPluginFileMapper, IPlugin,
+ std::unique_ptr<IPyPluginFileMapper, py::nodelete>>(
+ m, "IPluginFileMapper", py::multiple_inheritance())
+ .def(py::init<>())
+ .def("mappings", &IPluginFileMapper::mappings);
+
+ py::class_<IPyPluginDiagnose, PyPluginDiagnose, IPlugin,
+ std::unique_ptr<IPyPluginDiagnose, py::nodelete>>(
+ m, "IPluginDiagnose", py::multiple_inheritance())
+ .def(py::init<>())
+ .def("activeProblems", &IPluginDiagnose::activeProblems)
+ .def("shortDescription", &IPluginDiagnose::shortDescription, "key"_a)
+ .def("fullDescription", &IPluginDiagnose::fullDescription, "key"_a)
+ .def("hasGuidedFix", &IPluginDiagnose::hasGuidedFix, "key"_a)
+ .def("startGuidedFix", &IPluginDiagnose::startGuidedFix, "key"_a)
+ .def("_invalidate", &PyPluginDiagnose::invalidate);
+
+ py::class_<IPluginTool, PyPluginTool, IPlugin,
+ std::unique_ptr<IPluginTool, py::nodelete>>(
+ m, "IPluginTool", py::multiple_inheritance())
+ .def(py::init<>())
+ .def("displayName", &IPluginTool::displayName)
+ .def("tooltip", &IPluginTool::tooltip)
+ .def("icon", &IPluginTool::icon)
+ .def("display", &IPluginTool::display)
+ .def("setParentWidget", &IPluginTool::setParentWidget)
+ .def("_parentWidget", &PyPluginTool::parentWidget);
+
+ py::class_<IPluginPreview, PyPluginPreview, IPlugin,
+ std::unique_ptr<IPluginPreview, py::nodelete>>(
+ m, "IPluginPreview", py::multiple_inheritance())
+ .def(py::init<>())
+ .def("supportedExtensions", &IPluginPreview::supportedExtensions)
+ .def("supportsArchives", &IPluginPreview::supportsArchives)
+ .def("genFilePreview", &IPluginPreview::genFilePreview, "filename"_a,
+ "max_size"_a)
+ .def("genDataPreview", &IPluginPreview::genDataPreview, "file_data"_a,
+ "filename"_a, "max_size"_a);
+
+ py::class_<IPluginModPage, PyPluginModPage, IPlugin,
+ std::unique_ptr<IPluginModPage, py::nodelete>>(
+ m, "IPluginModPage", py::multiple_inheritance())
+ .def(py::init<>())
+ .def("displayName", &IPluginModPage::displayName)
+ .def("icon", &IPluginModPage::icon)
+ .def("pageURL", &IPluginModPage::pageURL)
+ .def("useIntegratedBrowser", &IPluginModPage::useIntegratedBrowser)
+ .def("handlesDownload", &IPluginModPage::handlesDownload, "page_url"_a,
+ "download_url"_a, "fileinfo"_a)
+ .def("setParentWidget", &IPluginModPage::setParentWidget, "parent"_a)
+ .def("_parentWidget", &PyPluginModPage::parentWidget);
+
+ add_iplugingame_bindings(m);
+ add_iplugininstaller_bindings(m);
+ }
+
+ struct extract_plugins_helper {
+ QList<QObject*> objects;
+
+ template <class IPluginClass>
+ void append_if_instance(pybind11::object plugin_obj)
+ {
+ if (py::isinstance<IPluginClass>(plugin_obj)) {
+ objects.append(plugin_obj.cast<IPluginClass*>());
+ }
+ }
+ };
+
+ QList<QObject*> extract_plugins(pybind11::object plugin_obj)
+ {
+ extract_plugins_helper helper;
+
+ // we need to check the trampoline class for these since the interfaces do not
+ // extend IPlugin
+ helper.append_if_instance<IPyPluginFileMapper>(plugin_obj);
+ helper.append_if_instance<IPyPluginDiagnose>(plugin_obj);
+
+ helper.append_if_instance<IPluginModPage>(plugin_obj);
+ helper.append_if_instance<IPluginPreview>(plugin_obj);
+ helper.append_if_instance<IPluginTool>(plugin_obj);
+
+ helper.append_if_instance<IPluginGame>(plugin_obj);
+
+ // we need to check the two installer types because IPluginInstaller does not
+ // inherit QObject, and the trampoline do not have a common ancestor
+ helper.append_if_instance<IPluginInstallerSimple>(plugin_obj);
+ helper.append_if_instance<IPluginInstallerCustom>(plugin_obj);
+
+ if (helper.objects.isEmpty()) {
+ helper.append_if_instance<IPyPlugin>(plugin_obj);
+ }
+
+ // tie the lifetime of the Python object to the lifetime of the QObject
+ for (auto* object : helper.objects) {
+ py::qt::set_qt_owner(object, plugin_obj);
+ }
+
+ return helper.objects;
+ }
+
+} // namespace mo2::python
diff --git a/libs/plugin_python/src/mobase/wrappers/pyplugins.h b/libs/plugin_python/src/mobase/wrappers/pyplugins.h
new file mode 100644
index 0000000..17f7ab5
--- /dev/null
+++ b/libs/plugin_python/src/mobase/wrappers/pyplugins.h
@@ -0,0 +1,527 @@
+#ifndef PYTHON_WRAPPERS_PYPLUGINS_H
+#define PYTHON_WRAPPERS_PYPLUGINS_H
+
+#include "../pybind11_all.h"
+
+#include <uibase/iinstallationmanager.h>
+
+#include <uibase/iplugin.h>
+#include <uibase/iplugindiagnose.h>
+#include <uibase/ipluginfilemapper.h>
+#include <uibase/iplugingame.h>
+#include <uibase/iplugininstaller.h>
+#include <uibase/iplugininstallercustom.h>
+#include <uibase/iplugininstallersimple.h>
+#include <uibase/ipluginmodpage.h>
+#include <uibase/ipluginpreview.h>
+#include <uibase/iplugintool.h>
+
+// these needs to be defined in a header file for automoc - this file is included only
+// in pyplugins.cpp
+namespace mo2::python {
+
+ using namespace MOBase;
+
+ // we need two base trampoline because IPluginGame has some final methods.
+ template <class PluginBase>
+ class PyPluginBaseNoFinal : public PluginBase {
+ public:
+ using PluginBase::PluginBase;
+
+ PyPluginBaseNoFinal(PyPluginBaseNoFinal const&) = delete;
+ PyPluginBaseNoFinal(PyPluginBaseNoFinal&&) = delete;
+ PyPluginBaseNoFinal& operator=(PyPluginBaseNoFinal const&) = delete;
+ PyPluginBaseNoFinal& operator=(PyPluginBaseNoFinal&&) = delete;
+
+ bool init(IOrganizer* organizer) override
+ {
+ PYBIND11_OVERRIDE_PURE(bool, PluginBase, init, organizer);
+ }
+ QString name() const override
+ {
+ PYBIND11_OVERRIDE_PURE(QString, PluginBase, name, );
+ }
+ QString localizedName() const override
+ {
+ PYBIND11_OVERRIDE(QString, PluginBase, localizedName, );
+ }
+ QString master() const override
+ {
+ PYBIND11_OVERRIDE(QString, PluginBase, master, );
+ }
+ QString author() const override
+ {
+ PYBIND11_OVERRIDE_PURE(QString, PluginBase, author, );
+ }
+ QString description() const override
+ {
+ PYBIND11_OVERRIDE_PURE(QString, PluginBase, description, );
+ }
+ VersionInfo version() const override
+ {
+ PYBIND11_OVERRIDE_PURE(VersionInfo, PluginBase, version, );
+ }
+ QList<PluginSetting> settings() const override
+ {
+ PYBIND11_OVERRIDE_PURE(QList<PluginSetting>, PluginBase, settings, );
+ }
+ };
+
+ template <class PluginBase>
+ class PyPluginBase : public PyPluginBaseNoFinal<PluginBase> {
+ public:
+ using PyPluginBaseNoFinal<PluginBase>::PyPluginBaseNoFinal;
+
+ std::vector<std::shared_ptr<const IPluginRequirement>> requirements() const
+ {
+ PYBIND11_OVERRIDE(std::vector<std::shared_ptr<const IPluginRequirement>>,
+ PluginBase, requirements, );
+ }
+ bool enabledByDefault() const override
+ {
+ PYBIND11_OVERRIDE(bool, PluginBase, enabledByDefault, );
+ }
+ };
+
+ // these classes do not inherit IPlugin or QObject so we need intermediate class to
+ // get proper bindings
+ class IPyPlugin : public QObject, public IPlugin {};
+ class IPyPluginFileMapper : public IPyPlugin, public IPluginFileMapper {};
+ class IPyPluginDiagnose : public IPyPlugin, public IPluginDiagnose {};
+
+ // PyXXX classes - trampoline classes for the plugins
+
+ class PyPlugin : public PyPluginBase<IPyPlugin> {
+ Q_OBJECT
+ Q_INTERFACES(MOBase::IPlugin)
+ };
+
+ class PyPluginFileMapper : public PyPluginBase<IPyPluginFileMapper> {
+ Q_OBJECT
+ Q_INTERFACES(MOBase::IPlugin MOBase::IPluginFileMapper)
+ public:
+ MappingType mappings() const override
+ {
+ PYBIND11_OVERRIDE_PURE(MappingType, IPyPluginFileMapper, mappings, );
+ }
+ };
+
+ class PyPluginDiagnose : public PyPluginBase<IPyPluginDiagnose> {
+ Q_OBJECT
+ Q_INTERFACES(MOBase::IPlugin MOBase::IPluginDiagnose)
+ public:
+ std::vector<unsigned int> activeProblems() const
+ {
+ PYBIND11_OVERRIDE_PURE(std::vector<unsigned int>, IPyPluginDiagnose,
+ activeProblems, );
+ }
+
+ QString shortDescription(unsigned int key) const
+ {
+ PYBIND11_OVERRIDE_PURE(QString, IPyPluginDiagnose, shortDescription, key);
+ }
+
+ QString fullDescription(unsigned int key) const
+ {
+ PYBIND11_OVERRIDE_PURE(QString, IPyPluginDiagnose, fullDescription, key);
+ }
+
+ bool hasGuidedFix(unsigned int key) const
+ {
+ PYBIND11_OVERRIDE_PURE(bool, IPyPluginDiagnose, hasGuidedFix, key);
+ }
+
+ void startGuidedFix(unsigned int key) const
+ {
+ PYBIND11_OVERRIDE_PURE(void, IPyPluginDiagnose, startGuidedFix, key);
+ }
+
+ // we need to bring this in public scope
+ using IPluginDiagnose::invalidate;
+ };
+
+ class PyPluginTool : public PyPluginBase<IPluginTool> {
+ Q_OBJECT
+ Q_INTERFACES(MOBase::IPlugin MOBase::IPluginTool)
+ public:
+ QString displayName() const override
+ {
+ PYBIND11_OVERRIDE_PURE(QString, IPluginTool, displayName, );
+ }
+ QString tooltip() const override
+ {
+ PYBIND11_OVERRIDE_PURE(QString, IPluginTool, tooltip, );
+ }
+ QIcon icon() const override
+ {
+ PYBIND11_OVERRIDE_PURE(QIcon, IPluginTool, icon, );
+ }
+ void setParentWidget(QWidget* widget) override
+ {
+ PYBIND11_OVERRIDE(void, IPluginTool, setParentWidget, widget);
+ }
+ void display() const override
+ {
+ PYBIND11_OVERRIDE_PURE(void, IPluginTool, display, );
+ }
+
+ // we need to bring this in public scope
+ using IPluginTool::parentWidget;
+ };
+
+ class PyPluginPreview : public PyPluginBase<IPluginPreview> {
+ Q_OBJECT
+ Q_INTERFACES(MOBase::IPlugin MOBase::IPluginPreview)
+ public:
+ std::set<QString> supportedExtensions() const override
+ {
+ PYBIND11_OVERRIDE_PURE(std::set<QString>, IPluginPreview,
+ supportedExtensions, );
+ }
+
+ bool supportsArchives() const override
+ {
+ PYBIND11_OVERRIDE(bool, IPluginPreview, supportsArchives, );
+ }
+
+ QWidget* genFilePreview(const QString& fileName,
+ const QSize& maxSize) const override
+ {
+ PYBIND11_OVERRIDE_PURE(QWidget*, IPluginPreview, genFilePreview, fileName,
+ maxSize);
+ }
+
+ QWidget* genDataPreview(const QByteArray& fileData, const QString& fileName,
+ const QSize& maxSize) const override
+ {
+ PYBIND11_OVERRIDE(QWidget*, IPluginPreview, genDataPreview, fileData,
+ fileName, maxSize);
+ }
+ };
+
+ class PyPluginModPage : public PyPluginBase<IPluginModPage> {
+ Q_OBJECT
+ Q_INTERFACES(MOBase::IPlugin MOBase::IPluginModPage)
+ public:
+ QString displayName() const override
+ {
+ PYBIND11_OVERRIDE_PURE(QString, IPluginModPage, displayName, );
+ }
+
+ QIcon icon() const override
+ {
+ PYBIND11_OVERRIDE_PURE(QIcon, IPluginModPage, icon, );
+ }
+
+ QUrl pageURL() const override
+ {
+ PYBIND11_OVERRIDE_PURE(QUrl, IPluginModPage, pageURL, );
+ }
+
+ bool useIntegratedBrowser() const override
+ {
+ PYBIND11_OVERRIDE_PURE(bool, IPluginModPage, useIntegratedBrowser, );
+ }
+
+ bool handlesDownload(const QUrl& pageURL, const QUrl& downloadURL,
+ ModRepositoryFileInfo& fileInfo) const override
+ {
+ // TODO: cannot modify fileInfo from Python
+ PYBIND11_OVERRIDE_PURE(bool, IPluginModPage, handlesDownload, pageURL,
+ downloadURL, &fileInfo);
+ }
+
+ void setParentWidget(QWidget* widget) override
+ {
+ PYBIND11_OVERRIDE(void, IPluginModPage, setParentWidget, widget);
+ }
+
+ // we need to bring this in public scope
+ using IPluginModPage::parentWidget;
+ };
+
+ // installers
+ template <class PluginInstallerBase>
+ class PyPluginInstallerBase : public PyPluginBase<PluginInstallerBase> {
+ public:
+ using PyPluginBase<PluginInstallerBase>::PyPluginBase;
+
+ unsigned int priority() const override
+ {
+ PYBIND11_OVERRIDE_PURE(unsigned int, PluginInstallerBase, priority);
+ }
+
+ bool isManualInstaller() const override
+ {
+ PYBIND11_OVERRIDE_PURE(bool, PluginInstallerBase, isManualInstaller, );
+ }
+
+ void onInstallationStart(QString const& archive, bool reinstallation,
+ IModInterface* currentMod)
+ {
+ PYBIND11_OVERRIDE(void, PluginInstallerBase, onInstallationStart, archive,
+ reinstallation, currentMod);
+ }
+
+ void onInstallationEnd(IPluginInstaller::EInstallResult result,
+ IModInterface* newMod)
+ {
+ PYBIND11_OVERRIDE(void, PluginInstallerBase, onInstallationEnd, result,
+ newMod);
+ }
+
+ bool isArchiveSupported(std::shared_ptr<const IFileTree> tree) const override
+ {
+ PYBIND11_OVERRIDE_PURE(bool, PluginInstallerBase, isArchiveSupported, tree);
+ }
+
+ // we need to bring these in public scope
+ using PluginInstallerBase::manager;
+ using PluginInstallerBase::parentWidget;
+ };
+
+ class PyPluginInstallerCustom
+ : public PyPluginInstallerBase<IPluginInstallerCustom> {
+ Q_OBJECT
+ Q_INTERFACES(
+ MOBase::IPlugin MOBase::IPluginInstaller MOBase::IPluginInstallerCustom)
+ public:
+ bool isArchiveSupported(const QString& archiveName) const
+ {
+ PYBIND11_OVERRIDE_PURE(bool, IPluginInstallerCustom, isArchiveSupported,
+ archiveName);
+ }
+
+ std::set<QString> supportedExtensions() const
+ {
+ PYBIND11_OVERRIDE_PURE(std::set<QString>, IPluginInstallerCustom,
+ supportedExtensions, );
+ }
+
+ EInstallResult install(GuessedValue<QString>& modName, QString gameName,
+ const QString& archiveName, const QString& version,
+ int nexusID) override
+ {
+ PYBIND11_OVERRIDE_PURE(EInstallResult, IPluginInstallerCustom, install,
+ &modName, gameName, archiveName, version, nexusID);
+ }
+ };
+
+ class PyPluginInstallerSimple
+ : public PyPluginInstallerBase<IPluginInstallerSimple> {
+ Q_OBJECT
+ Q_INTERFACES(
+ MOBase::IPlugin MOBase::IPluginInstaller MOBase::IPluginInstallerSimple)
+ public:
+ using py_install_return_type =
+ std::variant<IPluginInstaller::EInstallResult, std::shared_ptr<IFileTree>,
+ std::tuple<IPluginInstaller::EInstallResult,
+ std::shared_ptr<IFileTree>, QString, int>>;
+
+ EInstallResult install(GuessedValue<QString>& modName,
+ std::shared_ptr<IFileTree>& tree, QString& version,
+ int& nexusID) override
+ {
+ const auto result = [&, this]() {
+ PYBIND11_OVERRIDE_PURE(py_install_return_type, IPluginInstallerSimple,
+ install, &modName, tree, version, nexusID);
+ }();
+
+ return std::visit(
+ [&tree, &version, &nexusID](auto const& t) {
+ using type = std::decay_t<decltype(t)>;
+ if constexpr (std::is_same_v<type, EInstallResult>) {
+ return t;
+ }
+ else if constexpr (std::is_same_v<type,
+ std::shared_ptr<IFileTree>>) {
+ tree = t;
+ return RESULT_SUCCESS;
+ }
+ else if constexpr (std::is_same_v<
+ type, std::tuple<EInstallResult,
+ std::shared_ptr<IFileTree>,
+ QString, int>>) {
+ tree = std::get<1>(t);
+ version = std::get<2>(t);
+ nexusID = std::get<3>(t);
+ return std::get<0>(t);
+ }
+ },
+ result);
+ }
+ };
+
+ // game
+ class PyPluginGame : public PyPluginBaseNoFinal<IPluginGame> {
+ Q_OBJECT
+ Q_INTERFACES(MOBase::IPlugin MOBase::IPluginGame)
+ public:
+ void detectGame() override
+ {
+ PYBIND11_OVERRIDE_PURE(void, IPluginGame, detectGame, );
+ }
+ QString gameName() const override
+ {
+ PYBIND11_OVERRIDE_PURE(QString, IPluginGame, gameName, );
+ }
+ QString displayGameName() const override
+ {
+ PYBIND11_OVERRIDE(QString, IPluginGame, displayGameName, );
+ }
+ void initializeProfile(const QDir& directory,
+ ProfileSettings settings) const override
+ {
+ PYBIND11_OVERRIDE_PURE(void, IPluginGame, initializeProfile, directory,
+ settings);
+ }
+ std::vector<std::shared_ptr<const ISaveGame>>
+ listSaves(QDir folder) const override
+ {
+ PYBIND11_OVERRIDE_PURE(std::vector<std::shared_ptr<const ISaveGame>>,
+ IPluginGame, listSaves, folder);
+ }
+ bool isInstalled() const override
+ {
+ PYBIND11_OVERRIDE_PURE(bool, IPluginGame, isInstalled, );
+ }
+ QIcon gameIcon() const override
+ {
+ PYBIND11_OVERRIDE_PURE(QIcon, IPluginGame, gameIcon, );
+ }
+ QDir gameDirectory() const override
+ {
+ PYBIND11_OVERRIDE_PURE(QDir, IPluginGame, gameDirectory, );
+ }
+ QDir dataDirectory() const override
+ {
+ PYBIND11_OVERRIDE_PURE(QDir, IPluginGame, dataDirectory, );
+ }
+ QString modDataDirectory() const override
+ {
+ PYBIND11_OVERRIDE(QString, IPluginGame, modDataDirectory, );
+ }
+ QMap<QString, QDir> secondaryDataDirectories() const override
+ {
+ using string_dir_map = QMap<QString, QDir>;
+ PYBIND11_OVERRIDE(string_dir_map, IPluginGame, secondaryDataDirectories, );
+ }
+ void setGamePath(const QString& path) override
+ {
+ PYBIND11_OVERRIDE_PURE(void, IPluginGame, setGamePath, path);
+ }
+ QDir documentsDirectory() const override
+ {
+ PYBIND11_OVERRIDE_PURE(QDir, IPluginGame, documentsDirectory, );
+ }
+ QDir savesDirectory() const override
+ {
+ PYBIND11_OVERRIDE_PURE(QDir, IPluginGame, savesDirectory, );
+ }
+ QList<ExecutableInfo> executables() const override
+ {
+ PYBIND11_OVERRIDE(QList<ExecutableInfo>, IPluginGame, executables, );
+ }
+ QList<ExecutableForcedLoadSetting> executableForcedLoads() const override
+ {
+ PYBIND11_OVERRIDE_PURE(QList<ExecutableForcedLoadSetting>, IPluginGame,
+ executableForcedLoads, );
+ }
+ QString steamAPPId() const override
+ {
+ PYBIND11_OVERRIDE(QString, IPluginGame, steamAPPId, );
+ }
+ QStringList primaryPlugins() const override
+ {
+ PYBIND11_OVERRIDE(QStringList, IPluginGame, primaryPlugins, );
+ }
+ QStringList enabledPlugins() const override
+ {
+ PYBIND11_OVERRIDE(QStringList, IPluginGame, enabledPlugins, );
+ }
+ QStringList gameVariants() const override
+ {
+ PYBIND11_OVERRIDE(QStringList, IPluginGame, gameVariants, );
+ }
+ void setGameVariant(const QString& variant) override
+ {
+ PYBIND11_OVERRIDE_PURE(void, IPluginGame, setGameVariant, variant);
+ }
+ QString binaryName() const override
+ {
+ PYBIND11_OVERRIDE_PURE(QString, IPluginGame, binaryName, );
+ }
+ QString gameShortName() const override
+ {
+ PYBIND11_OVERRIDE_PURE(QString, IPluginGame, gameShortName, );
+ }
+ QString lootGameName() const override
+ {
+ PYBIND11_OVERRIDE(QString, IPluginGame, lootGameName, );
+ }
+ QStringList primarySources() const override
+ {
+ PYBIND11_OVERRIDE(QStringList, IPluginGame, primarySources, );
+ }
+ QStringList validShortNames() const override
+ {
+ PYBIND11_OVERRIDE(QStringList, IPluginGame, validShortNames, );
+ }
+ QString gameNexusName() const override
+ {
+ PYBIND11_OVERRIDE(QString, IPluginGame, gameNexusName, );
+ }
+ QStringList iniFiles() const override
+ {
+ PYBIND11_OVERRIDE(QStringList, IPluginGame, iniFiles, );
+ }
+ QStringList DLCPlugins() const override
+ {
+ PYBIND11_OVERRIDE(QStringList, IPluginGame, DLCPlugins, );
+ }
+ QStringList CCPlugins() const override
+ {
+ PYBIND11_OVERRIDE(QStringList, IPluginGame, CCPlugins, );
+ }
+ LoadOrderMechanism loadOrderMechanism() const override
+ {
+ PYBIND11_OVERRIDE(LoadOrderMechanism, IPluginGame, loadOrderMechanism, );
+ }
+ SortMechanism sortMechanism() const override
+ {
+ PYBIND11_OVERRIDE(SortMechanism, IPluginGame, sortMechanism, );
+ }
+ int nexusModOrganizerID() const override
+ {
+ PYBIND11_OVERRIDE(int, IPluginGame, nexusModOrganizerID, );
+ }
+ int nexusGameID() const override
+ {
+ PYBIND11_OVERRIDE_PURE(int, IPluginGame, nexusGameID, );
+ }
+ bool looksValid(QDir const& dir) const override
+ {
+ PYBIND11_OVERRIDE_PURE(bool, IPluginGame, looksValid, dir);
+ }
+ QString gameVersion() const override
+ {
+ PYBIND11_OVERRIDE_PURE(QString, IPluginGame, gameVersion, );
+ }
+ QString getLauncherName() const override
+ {
+ PYBIND11_OVERRIDE_PURE(QString, IPluginGame, getLauncherName, );
+ }
+ QString getSupportURL() const override
+ {
+ PYBIND11_OVERRIDE(QString, IPluginGame, getSupportURL, );
+ }
+ QMap<QString, QStringList> getModMappings() const override
+ {
+ using vfs_map = QMap<QString, QStringList>;
+ PYBIND11_OVERRIDE(vfs_map, IPluginGame, getModMappings, );
+ }
+ };
+
+} // namespace mo2::python
+
+#endif
diff --git a/libs/plugin_python/src/mobase/wrappers/utils.cpp b/libs/plugin_python/src/mobase/wrappers/utils.cpp
new file mode 100644
index 0000000..95111fa
--- /dev/null
+++ b/libs/plugin_python/src/mobase/wrappers/utils.cpp
@@ -0,0 +1,50 @@
+#include "wrappers.h"
+
+#include "../pybind11_all.h"
+
+#include <uibase/report.h>
+#include <uibase/utility.h>
+
+#ifdef _WIN32
+#include "known_folders.h"
+#endif
+
+namespace py = pybind11;
+using namespace MOBase;
+
+namespace mo2::python {
+
+ void add_utils_bindings(pybind11::module_ m)
+ {
+#ifdef _WIN32
+ py::class_<KnownFolder> pyKnownFolder(m, "KnownFolder");
+ for (std::size_t i = 0; i < KNOWN_FOLDERS.size(); ++i) {
+ pyKnownFolder.attr(KNOWN_FOLDERS[i].name) = py::int_(i);
+ }
+
+ m.def(
+ "getKnownFolder",
+ [](std::size_t knownFolderId, QString what) {
+ return getKnownFolder(KNOWN_FOLDERS.at(knownFolderId).guid, what);
+ },
+ py::arg("known_folder"), py::arg("what") = "");
+
+ m.def(
+ "getOptionalKnownFolder",
+ [](std::size_t knownFolderId) {
+ const auto r =
+ getOptionalKnownFolder(KNOWN_FOLDERS.at(knownFolderId).guid);
+ return r.isEmpty() ? py::none{} : py::cast(r);
+ },
+ py::arg("known_folder"));
+#endif
+
+ m.def("getFileVersion", wrap_for_filepath(&MOBase::getFileVersion),
+ py::arg("filepath"));
+ m.def("getProductVersion", wrap_for_filepath(&MOBase::getProductVersion),
+ py::arg("executable"));
+ m.def("getIconForExecutable", wrap_for_filepath(&MOBase::iconForExecutable),
+ py::arg("executable"));
+ }
+
+} // namespace mo2::python
diff --git a/libs/plugin_python/src/mobase/wrappers/widgets.cpp b/libs/plugin_python/src/mobase/wrappers/widgets.cpp
new file mode 100644
index 0000000..e626540
--- /dev/null
+++ b/libs/plugin_python/src/mobase/wrappers/widgets.cpp
@@ -0,0 +1,77 @@
+#include "wrappers.h"
+
+#include "../pybind11_all.h"
+
+#include <uibase/report.h>
+
+namespace py = pybind11;
+using namespace MOBase;
+
+namespace mo2::python {
+
+ void add_widget_bindings(pybind11::module_ m)
+ {
+ // TaskDialog is also in Windows System.
+ using TaskDialog = MOBase::TaskDialog;
+
+ // TaskDialog
+ py::class_<TaskDialogButton>(m, "TaskDialogButton")
+ .def(py::init<QString, QString, QMessageBox::StandardButton>(),
+ py::arg("text"), py::arg("description"), py::arg("button"))
+ .def(py::init<QString, QMessageBox::StandardButton>(), py::arg("text"),
+ py::arg("button"))
+ .def_readwrite("text", &TaskDialogButton::text)
+ .def_readwrite("description", &TaskDialogButton::description)
+ .def_readwrite("button", &TaskDialogButton::button);
+
+ py::class_<TaskDialog>(m, "TaskDialog")
+ .def(py::init([](QWidget* parent, QString const& title, QString const& main,
+ QString const& content, QString const& details,
+ QMessageBox::Icon icon,
+ std::vector<TaskDialogButton> const& buttons,
+ std::variant<QString, std::tuple<QString, QString>> const&
+ remember) {
+ auto* dialog = new TaskDialog(parent, title);
+ dialog->main(main).content(content).details(details).icon(icon);
+
+ for (auto& button : buttons) {
+ dialog->button(button);
+ }
+
+ std::visit(
+ [dialog](auto const& item) {
+ QString action, file;
+ if constexpr (std::is_same_v<std::decay_t<decltype(item)>,
+ QString>) {
+ action = item;
+ }
+ else {
+ action = std::get<0>(item);
+ file = std::get<1>(item);
+ }
+ dialog->remember(action, file);
+ },
+ remember);
+
+ return dialog;
+ }),
+ py::return_value_policy::take_ownership,
+ py::arg("parent") = static_cast<QWidget*>(nullptr),
+ py::arg("title") = "", py::arg("main") = "", py::arg("content") = "",
+ py::arg("details") = "", py::arg("icon") = QMessageBox::NoIcon,
+ py::arg("buttons") = std::vector<TaskDialogButton>{},
+ py::arg("remember") = "")
+ .def("setTitle", &TaskDialog::title, py::arg("title"))
+ .def("setMain", &TaskDialog::main, py::arg("main"))
+ .def("setContent", &TaskDialog::content, py::arg("content"))
+ .def("setDetails", &TaskDialog::details, py::arg("details"))
+ .def("setIcon", &TaskDialog::icon, py::arg("icon"))
+ .def("addButton", &TaskDialog::button, py::arg("button"))
+ .def("setRemember", &TaskDialog::remember, py::arg("action"),
+ py::arg("file") = "")
+ .def("setWidth", &TaskDialog::setWidth, py::arg("width"))
+ .def("addContent", &TaskDialog::addContent, py::arg("widget"))
+ .def("exec", &TaskDialog::exec);
+ }
+
+} // namespace mo2::python
diff --git a/libs/plugin_python/src/mobase/wrappers/wrappers.cpp b/libs/plugin_python/src/mobase/wrappers/wrappers.cpp
new file mode 100644
index 0000000..10f043d
--- /dev/null
+++ b/libs/plugin_python/src/mobase/wrappers/wrappers.cpp
@@ -0,0 +1,120 @@
+
+#include "wrappers.h"
+
+#include "../pybind11_all.h"
+
+#include <QDir>
+#include <QIcon>
+#include <QString>
+#include <QUrl>
+
+// IOrganizer must be bring here to properly compile the Python bindings of
+// plugin requirements
+#include <uibase/imoinfo.h>
+#include <uibase/isavegame.h>
+#include <uibase/isavegameinfowidget.h>
+#include <uibase/pluginrequirements.h>
+
+using namespace pybind11::literals;
+namespace py = pybind11;
+using namespace MOBase;
+
+namespace mo2::python {
+
+ class PyPluginRequirement : public IPluginRequirement {
+ public:
+ std::optional<Problem> check(IOrganizer* organizer) const override
+ {
+ PYBIND11_OVERRIDE_PURE(std::optional<Problem>, IPluginRequirement, check,
+ organizer);
+ };
+ };
+
+ class PySaveGame : public ISaveGame {
+ public:
+ QString getFilepath() const override
+ {
+ PYBIND11_OVERRIDE_PURE(FileWrapper, ISaveGame, getFilepath, );
+ }
+
+ QDateTime getCreationTime() const override
+ {
+ PYBIND11_OVERRIDE_PURE(QDateTime, ISaveGame, getCreationTime, );
+ }
+
+ QString getName() const override
+ {
+ PYBIND11_OVERRIDE_PURE(QString, ISaveGame, getName, );
+ }
+
+ QString getSaveGroupIdentifier() const override
+ {
+ PYBIND11_OVERRIDE_PURE(QString, ISaveGame, getSaveGroupIdentifier, );
+ }
+
+ QStringList allFiles() const override
+ {
+ return toQStringList([&] {
+ PYBIND11_OVERRIDE_PURE(QList<FileWrapper>, ISaveGame, allFiles, );
+ }());
+ }
+
+ ~PySaveGame() { std::cout << "~PySaveGame()" << std::endl; }
+ };
+
+ class PySaveGameInfoWidget : public ISaveGameInfoWidget {
+ public:
+ // Bring the constructor:
+ using ISaveGameInfoWidget::ISaveGameInfoWidget;
+
+ void setSave(ISaveGame const& save) override
+ {
+ PYBIND11_OVERRIDE_PURE(void, ISaveGameInfoWidget, setSave, &save);
+ }
+
+ ~PySaveGameInfoWidget() { std::cout << "~PySaveGameInfoWidget()" << std::endl; }
+ };
+
+ void add_wrapper_bindings(pybind11::module_ m)
+ {
+ // ISaveGame - custom type_caster<> for shared_ptr<> to keep the Python object
+ // alive when returned from Python (see shared_cpp_owner.h)
+
+ py::class_<ISaveGame, PySaveGame, std::shared_ptr<ISaveGame>>(m, "ISaveGame")
+ .def(py::init<>())
+ .def("getFilepath", wrap_return_for_filepath(&ISaveGame::getFilepath))
+ .def("getCreationTime", &ISaveGame::getCreationTime)
+ .def("getName", &ISaveGame::getName)
+ .def("getSaveGroupIdentifier", &ISaveGame::getSaveGroupIdentifier)
+ .def("allFiles", [](ISaveGame* s) -> QList<FileWrapper> {
+ const auto result = s->allFiles();
+ return {result.begin(), result.end()};
+ });
+
+ // ISaveGameInfoWidget - custom holder to keep the Python object alive alongside
+ // the widget
+
+ py::class_<ISaveGameInfoWidget, PySaveGameInfoWidget,
+ py::qt::qobject_holder<ISaveGameInfoWidget>>
+ iSaveGameInfoWidget(m, "ISaveGameInfoWidget");
+ iSaveGameInfoWidget.def(py::init<QWidget*>(), "parent"_a = (QWidget*)nullptr)
+ .def("setSave", &ISaveGameInfoWidget::setSave, "save"_a);
+ py::qt::add_qt_delegate<QWidget>(iSaveGameInfoWidget, "_widget");
+
+ // IPluginRequirement - custom type_caster<> for shared_ptr<> to keep the Python
+ // object alive when returned from Python (see shared_cpp_owner.h)
+
+ py::class_<IPluginRequirement, std::shared_ptr<IPluginRequirement>,
+ PyPluginRequirement>
+ iPluginRequirementClass(m, "IPluginRequirement");
+
+ py::class_<IPluginRequirement::Problem>(iPluginRequirementClass, "Problem")
+ .def(py::init<QString, QString>(), "short_description"_a,
+ "long_description"_a = "")
+ .def("shortDescription", &IPluginRequirement::Problem::shortDescription)
+ .def("longDescription", &IPluginRequirement::Problem::longDescription);
+
+ iPluginRequirementClass.def("check", &IPluginRequirement::check, "organizer"_a);
+ }
+
+} // namespace mo2::python
diff --git a/libs/plugin_python/src/mobase/wrappers/wrappers.h b/libs/plugin_python/src/mobase/wrappers/wrappers.h
new file mode 100644
index 0000000..43dd574
--- /dev/null
+++ b/libs/plugin_python/src/mobase/wrappers/wrappers.h
@@ -0,0 +1,109 @@
+#ifndef PYTHON_WRAPPERS_WRAPPERS_H
+#define PYTHON_WRAPPERS_WRAPPERS_H
+
+#include <any>
+#include <map>
+#include <typeindex>
+
+#include <pybind11/pybind11.h>
+
+#include <QList>
+#include <QObject>
+
+#include <uibase/iplugingame.h>
+
+namespace mo2::python {
+
+ /**
+ * @brief Add bindings for the various classes in uibase that are not
+ * wrappers (i.e., cannot be extended from Python).
+ *
+ * @param m Python module to add bindings to.
+ */
+ void add_basic_bindings(pybind11::module_ m);
+
+ /**
+ * @brief Add bindings for the various custom widget classes in uibase that
+ * cannot be extended from Python.
+ *
+ * @param m Python module to add bindings to.
+ */
+ void add_widget_bindings(pybind11::module_ m);
+
+ /**
+ * @brief Add bindings for the various utilities classes and functions in uibase
+ * that cannot be extended from Python.
+ *
+ * @param m Python module to add bindings to.
+ */
+ void add_utils_bindings(pybind11::module_ m);
+
+ /**
+ * @brief Add bindings for the uibase wrappers to the given module. uibase
+ * wrappers include classes from uibase that can be extended from Python but
+ * are neither plugins nor game features (e.g., ISaveGame).
+ *
+ * @param m Python module to add bindings to.
+ */
+ void add_wrapper_bindings(pybind11::module_ m);
+
+ /**
+ * @brief Add bindings for the various plugin classes in uibase that can be
+ * extended from Python.
+ *
+ * @param m Python module to add bindings to.
+ */
+ void add_plugins_bindings(pybind11::module_ m);
+
+ /**
+ * @brief Extract plugins from the given object. For each plugin implemented, an
+ * object is returned.
+ *
+ * The returned QObject* are set as owner of the given object so that the Python
+ * object lifetime does not end immediately after returning to C++.
+ *
+ * @param object Python object to extract plugins from.
+ *
+ * @return a QObject* for each plugin implemented by the given object.
+ */
+ QList<QObject*> extract_plugins(pybind11::object object);
+
+ /**
+ * @brief Add bindings for the various game features classes in uibase that
+ * can be extended from Python.
+ *
+ * @param m Python module to add bindings to.
+ */
+ void add_game_feature_bindings(pybind11::module_ m);
+
+ /**
+ * @brief Add bindings for IGameFeatures.
+ *
+ * @param m Python module to add bindings to.
+ */
+ void add_igamefeatures_classes(pybind11::module_ m);
+
+ /**
+ * @brief Extract the game feature corresponding to the given Python type.
+ *
+ * @param gameFeatures Game features to extract the feature from.
+ * @param type Type of the feature to extract.
+ *
+ * @return the feature from the game, or None is the game as no such feature.
+ */
+ pybind11::object extract_feature(MOBase::IGameFeatures const& gameFeatures,
+ pybind11::object type);
+
+ /**
+ * @brief Unregister the game feature corresponding to the given Python type.
+ *
+ * @param gameFeatures Game features to unregister the feature from.
+ * @param type Type of the feature to unregister.
+ *
+ * @return the feature from the game, or None is the game as no such feature.
+ */
+ int unregister_feature(MOBase::IGameFeatures& gameFeatures, pybind11::object type);
+
+} // namespace mo2::python
+
+#endif // PYTHON_WRAPPERS_WRAPPERS_H
diff --git a/libs/plugin_python/src/plugin_python_en.ts b/libs/plugin_python/src/plugin_python_en.ts
new file mode 100644
index 0000000..9ef7836
--- /dev/null
+++ b/libs/plugin_python/src/plugin_python_en.ts
@@ -0,0 +1,83 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!DOCTYPE TS>
+<TS version="2.1" language="en_US">
+<context>
+ <name>ProxyPython</name>
+ <message>
+ <location filename="proxy/proxypython.cpp" line="88"/>
+ <source>Python Initialization failed</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="proxy/proxypython.cpp" line="89"/>
+ <source>On a previous start the Python Plugin failed to initialize.
+Do you want to try initializing python again (at the risk of another crash)?
+ Suggestion: Select &quot;no&quot;, and click the warning sign for further help.Afterwards you have to re-enable the python plugin.</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="proxy/proxypython.cpp" line="162"/>
+ <source>Python Proxy</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="proxy/proxypython.cpp" line="172"/>
+ <source>Proxy Plugin to allow plugins written in python to be loaded</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="proxy/proxypython.cpp" line="244"/>
+ <source>ModOrganizer path contains a semicolon</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="proxy/proxypython.cpp" line="246"/>
+ <source>Python DLL not found</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="proxy/proxypython.cpp" line="248"/>
+ <source>Invalid Python DLL</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="proxy/proxypython.cpp" line="250"/>
+ <source>Initializing Python failed</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="proxy/proxypython.cpp" line="252"/>
+ <location filename="proxy/proxypython.cpp" line="281"/>
+ <source>invalid problem key %1</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="proxy/proxypython.cpp" line="260"/>
+ <source>The path to Mod Organizer (%1) contains a semicolon.&lt;br&gt;While this is legal on NTFS drives, many applications do not handle it correctly.&lt;br&gt;Unfortunately MO depends on libraries that seem to fall into that group.&lt;br&gt;As a result the python plugin cannot be loaded, and the only solution we can offer is to remove the semicolon or move MO to a path without a semicolon.</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="proxy/proxypython.cpp" line="270"/>
+ <source>The Python plugin DLL was not found, maybe your antivirus deleted it. Re-installing MO2 might fix the problem.</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="proxy/proxypython.cpp" line="273"/>
+ <source>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.</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="proxy/proxypython.cpp" line="278"/>
+ <source>The initialization of the Python plugin DLL failed, unfortunately without any details.</source>
+ <translation type="unfinished"></translation>
+ </message>
+</context>
+<context>
+ <name>QObject</name>
+ <message>
+ <location filename="runner/error.h" line="76"/>
+ <source>An unknown exception was thrown in python code.</source>
+ <translation type="unfinished"></translation>
+ </message>
+</context>
+</TS>
diff --git a/libs/plugin_python/src/proxy/CMakeLists.txt b/libs/plugin_python/src/proxy/CMakeLists.txt
new file mode 100644
index 0000000..ed212a3
--- /dev/null
+++ b/libs/plugin_python/src/proxy/CMakeLists.txt
@@ -0,0 +1,118 @@
+cmake_minimum_required(VERSION 3.16)
+
+if(NOT TARGET mo2::uibase)
+ find_package(mo2-uibase CONFIG REQUIRED)
+endif()
+
+set(PLUGIN_NAME "plugin_python")
+
+add_library(proxy SHARED proxypython.cpp proxypython.h)
+mo2_configure_plugin(proxy
+ NO_SOURCES
+ WARNINGS 4
+ EXTERNAL_WARNINGS 4
+ TRANSLATIONS OFF
+ EXTRA_TRANSLATIONS
+ ${CMAKE_CURRENT_SOURCE_DIR}/../runner
+ ${CMAKE_CURRENT_SOURCE_DIR}/../mobase
+ ${CMAKE_CURRENT_SOURCE_DIR}/../pybind11-qt)
+mo2_default_source_group()
+target_link_libraries(proxy PRIVATE runner mo2::uibase)
+if(NOT WIN32)
+ target_compile_definitions(proxy PRIVATE
+ MO2_PYTHON_PURELIB_DIR="${Python_PURELIB_DIR}"
+ MO2_PYTHON_PLATLIB_DIR="${Python_PLATLIB_DIR}")
+endif()
+set_target_properties(proxy PROPERTIES OUTPUT_NAME ${PLUGIN_NAME})
+mo2_install_plugin(proxy FOLDER)
+
+set(PLUGIN_PYTHON_DIR bin/plugins/${PLUGIN_NAME})
+
+# install runner
+if(WIN32)
+ target_link_options(proxy PRIVATE "/DELAYLOAD:runner.dll")
+ install(FILES $<TARGET_FILE:runner> DESTINATION ${PLUGIN_PYTHON_DIR}/dlls)
+endif()
+
+# translations (custom location)
+mo2_add_translations(proxy
+ TS_FILE ${CMAKE_CURRENT_SOURCE_DIR}/../${PLUGIN_NAME}_en.ts
+ SOURCES
+ ${CMAKE_CURRENT_SOURCE_DIR}
+ ${CMAKE_CURRENT_SOURCE_DIR}/../runner
+ ${CMAKE_CURRENT_SOURCE_DIR}/../mobase
+ ${CMAKE_CURRENT_SOURCE_DIR}/../pybind11-qt)
+
+# install DLLs files needed
+set(DLL_DIRS ${PLUGIN_PYTHON_DIR}/dlls)
+if(WIN32)
+ file(GLOB dlls_to_install
+ ${Python_HOME}/dlls/libffi*.dll
+ ${Python_HOME}/dlls/sqlite*.dll
+ ${Python_HOME}/dlls/libssl*.dll
+ ${Python_HOME}/dlls/libcrypto*.dll
+ ${Python_HOME}/python${Python_VERSION_MAJOR}*.dll)
+ install(FILES ${dlls_to_install} DESTINATION ${DLL_DIRS})
+endif()
+
+# install Python .pyd files
+set(PYLIB_DIR ${PLUGIN_PYTHON_DIR}/libs)
+if(WIN32)
+ file(GLOB libs_to_install ${Python_DLL_DIR}/*.pyd)
+ install(FILES ${libs_to_install} DESTINATION ${PYLIB_DIR})
+endif()
+
+# generate + install standard library
+if(WIN32)
+ set(pythoncore_zip "${CMAKE_CURRENT_BINARY_DIR}/pythoncore.zip")
+ add_custom_command(
+ TARGET proxy POST_BUILD
+ COMMAND ${Python_EXECUTABLE}
+ "${CMAKE_CURRENT_SOURCE_DIR}/build_pythoncore.py"
+ ${pythoncore_zip}
+ )
+ install(FILES ${pythoncore_zip} DESTINATION ${PYLIB_DIR})
+endif()
+
+# install mobase
+install(TARGETS mobase DESTINATION ${PYLIB_DIR})
+
+# install PyQt6
+if(WIN32)
+ install(
+ DIRECTORY ${CMAKE_BINARY_DIR}/pylibs/PyQt${MO2_QT_VERSION_MAJOR}
+ DESTINATION ${PYLIB_DIR}
+ PATTERN "*.pyd"
+ PATTERN "*.pyi"
+ PATTERN "__pycache__" EXCLUDE
+ PATTERN "bindings" EXCLUDE
+ PATTERN "lupdate" EXCLUDE
+ PATTERN "Qt6" EXCLUDE
+ PATTERN "uic" EXCLUDE
+ )
+endif()
+
+if(NOT WIN32)
+ # Build-tree runtime layout expected by proxypython.cpp.
+ add_custom_command(TARGET proxy POST_BUILD
+ COMMAND ${CMAKE_COMMAND} -E make_directory "${CMAKE_BINARY_DIR}/src/src/plugins"
+ COMMAND ${CMAKE_COMMAND} -E make_directory "${CMAKE_BINARY_DIR}/src/src/plugins/dlls"
+ COMMAND ${CMAKE_COMMAND} -E make_directory "${CMAKE_BINARY_DIR}/src/src/plugins/libs"
+ COMMAND ${CMAKE_COMMAND} -E make_directory "${CMAKE_BINARY_DIR}/src/src/plugins/plugin_python/dlls"
+ COMMAND ${CMAKE_COMMAND} -E make_directory "${CMAKE_BINARY_DIR}/src/src/plugins/plugin_python/libs"
+ COMMAND ${CMAKE_COMMAND} -E copy_if_different
+ "$<TARGET_FILE:proxy>"
+ "${CMAKE_BINARY_DIR}/src/src/plugins/$<TARGET_FILE_NAME:proxy>"
+ COMMAND ${CMAKE_COMMAND} -E copy_if_different
+ "$<TARGET_FILE:runner>"
+ "${CMAKE_BINARY_DIR}/src/src/plugins/dlls/$<TARGET_FILE_NAME:runner>"
+ COMMAND ${CMAKE_COMMAND} -E copy_if_different
+ "$<TARGET_FILE:runner>"
+ "${CMAKE_BINARY_DIR}/src/src/plugins/plugin_python/dlls/$<TARGET_FILE_NAME:runner>"
+ COMMAND ${CMAKE_COMMAND} -E copy_if_different
+ "$<TARGET_FILE:mobase>"
+ "${CMAKE_BINARY_DIR}/src/src/plugins/libs/$<TARGET_FILE_NAME:mobase>"
+ COMMAND ${CMAKE_COMMAND} -E copy_if_different
+ "$<TARGET_FILE:mobase>"
+ "${CMAKE_BINARY_DIR}/src/src/plugins/plugin_python/libs/$<TARGET_FILE_NAME:mobase>")
+endif()
diff --git a/libs/plugin_python/src/proxy/build_pythoncore.py b/libs/plugin_python/src/proxy/build_pythoncore.py
new file mode 100644
index 0000000..01c42f9
--- /dev/null
+++ b/libs/plugin_python/src/proxy/build_pythoncore.py
@@ -0,0 +1,15 @@
+import sys
+import sysconfig
+import zipfile
+from pathlib import Path
+
+_EXCLUDE_MODULES = ["ensurepip", "idlelib", "test", "tkinter", "turtle_demo", "venv"]
+
+libdir = Path(sysconfig.get_path("stdlib"))
+assert libdir.exists()
+
+with zipfile.PyZipFile(sys.argv[1], optimize=2, mode="w") as fp:
+ fp.writepy(libdir) # pyright: ignore[reportArgumentType]
+ for path in libdir.iterdir():
+ if path.is_dir() and path.name not in _EXCLUDE_MODULES:
+ fp.writepy(path) # pyright: ignore[reportArgumentType]
diff --git a/libs/plugin_python/src/proxy/proxypython.cpp b/libs/plugin_python/src/proxy/proxypython.cpp
new file mode 100644
index 0000000..af897db
--- /dev/null
+++ b/libs/plugin_python/src/proxy/proxypython.cpp
@@ -0,0 +1,368 @@
+/*
+Copyright (C) 2022 Sebastian Herbord & MO2 Team. All rights reserved.
+
+This file is part of python proxy plugin for MO
+
+python proxy plugin is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+Python proxy plugin is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with python proxy plugin. If not, see <http://www.gnu.org/licenses/>.
+*/
+#include "proxypython.h"
+
+#include <filesystem>
+#include <cerrno>
+#include <cstring>
+#include <cstdlib>
+
+#ifdef _WIN32
+#include <Windows.h>
+#else
+#include <dlfcn.h>
+#endif
+
+#include <QCoreApplication>
+#include <QDirIterator>
+#include <QMessageBox>
+#include <QWidget>
+#include <QtPlugin>
+
+#include <uibase/log.h>
+#include <uibase/utility.h>
+#include <uibase/versioninfo.h>
+
+namespace fs = std::filesystem;
+using namespace MOBase;
+
+// retrieve the path to the folder containing the proxy DLL
+fs::path getPluginFolder()
+{
+#ifdef _WIN32
+ wchar_t path[MAX_PATH];
+ HMODULE hm = NULL;
+
+ if (GetModuleHandleEx(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS |
+ GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
+ (LPCWSTR)&getPluginFolder, &hm) == 0) {
+ return {};
+ }
+ if (GetModuleFileName(hm, path, sizeof(path)) == 0) {
+ return {};
+ }
+
+ return fs::path(path).parent_path();
+#else
+ Dl_info info;
+ if (dladdr((void*)&getPluginFolder, &info) == 0 || info.dli_fname == nullptr) {
+ return {};
+ }
+ return fs::path(info.dli_fname).parent_path();
+#endif
+}
+
+ProxyPython::ProxyPython()
+ : m_MOInfo{nullptr}, m_RunnerLib{nullptr}, m_Runner{nullptr},
+ m_LoadFailure(FailureType::NONE)
+{
+}
+
+bool ProxyPython::init(IOrganizer* moInfo)
+{
+ m_MOInfo = moInfo;
+
+ if (m_MOInfo && !m_MOInfo->isPluginEnabled(this)) {
+ return false;
+ }
+
+ if (QCoreApplication::applicationDirPath().contains(';')) {
+ m_LoadFailure = FailureType::SEMICOLON;
+ return true;
+ }
+
+ const auto pluginFolder = getPluginFolder();
+#ifdef _WIN32
+ const auto pluginDataRoot =
+ fs::exists(pluginFolder / "plugin_python") ? (pluginFolder / "plugin_python")
+ : pluginFolder;
+#else
+ // Linux portable installs can contain a legacy Windows "plugin_python"
+ // payload (pythoncore.zip/.pyd) that is incompatible with our bundled
+ // Linux runtime. Always use the plugin root on Linux.
+ const auto pluginDataRoot = pluginFolder;
+#endif
+
+ if (pluginFolder.empty()) {
+#ifdef _WIN32
+ DWORD error = ::GetLastError();
+ m_LoadFailure = FailureType::DLL_NOT_FOUND;
+ log::error("failed to resolve Python proxy directory ({}): {}", error,
+ qUtf8Printable(windowsErrorString(::GetLastError())));
+#else
+ m_LoadFailure = FailureType::DLL_NOT_FOUND;
+ log::error("failed to resolve Python proxy directory: {}",
+ std::strerror(errno));
+#endif
+ return false;
+ }
+
+ if (m_MOInfo && m_MOInfo->persistent(name(), "tryInit", false).toBool()) {
+ m_LoadFailure = FailureType::INITIALIZATION;
+ if (QMessageBox::question(
+ parentWidget(), tr("Python Initialization failed"),
+ tr("On a previous start the Python Plugin failed to initialize.\n"
+ "Do you want to try initializing python again (at the risk of "
+ "another crash)?\n "
+ "Suggestion: Select \"no\", and click the warning sign for further "
+ "help.Afterwards you have to re-enable the python plugin."),
+ QMessageBox::Yes | QMessageBox::No,
+ QMessageBox::No) == QMessageBox::No) {
+ // we force enabled here (note: this is a persistent settings since MO2 2.4
+ // or something), plugin
+ // usually should not handle enabled/disabled themselves but this is a base
+ // plugin so...
+ m_MOInfo->setPersistent(name(), "enabled", false, true);
+ return true;
+ }
+ }
+
+ if (m_MOInfo) {
+ m_MOInfo->setPersistent(name(), "tryInit", true);
+ }
+
+ // load the pythonrunner library, this is done in multiple steps:
+ //
+ // 1. we set the dlls/ subfolder (from the plugin) as the DLL directory so Windows
+ // will look for DLLs in it, this is required to find the Python and libffi DLL, but
+ // also the runner DLL
+ //
+ const auto dllPaths = pluginDataRoot / "dlls";
+#ifdef _WIN32
+ if (SetDllDirectoryW(dllPaths.c_str()) == 0) {
+ DWORD error = ::GetLastError();
+ m_LoadFailure = FailureType::DLL_NOT_FOUND;
+ log::error("failed to add python DLL directory ({}): {}", error,
+ qUtf8Printable(windowsErrorString(::GetLastError())));
+ return false;
+ }
+#endif
+
+ // 2. we create the Python runner, we do not need to use ::LinkLibrary and
+ // ::GetProcAddress because we use delayed load for the runner DLL (see the
+ // CMakeLists.txt)
+ //
+ m_Runner = mo2::python::createPythonRunner();
+
+ if (m_Runner) {
+ const auto libpath = pluginDataRoot / "libs";
+#ifdef _WIN32
+ const std::vector<fs::path> paths{
+ libpath / "pythoncore.zip", libpath,
+ std::filesystem::path{IOrganizer::getPluginDataPath().toStdWString()}};
+ m_Runner->initialize(paths);
+#else
+ // On Linux, rely on the unpacked stdlib from PYTHONHOME (AppRun sets
+ // MO2_PYTHON_DIR/PYTHONHOME). Do not prepend pythoncore.zip here:
+ // forcing zipimport can fail when zlib is unavailable in embedded mode.
+ std::vector<fs::path> paths{
+ libpath,
+ std::filesystem::path{IOrganizer::getPluginDataPath().toStdWString()}};
+
+ // Allow portable/AppImage builds to ship Python packages next to the app.
+ // MO2_PYTHON_DIR (set by AppRun) points to the writable python/ dir
+ // next to the AppImage; fall back to <exe_dir>/python.
+ fs::path pythonDir;
+ const char* envPy = std::getenv("MO2_PYTHON_DIR");
+ if (envPy && envPy[0] != '\0') {
+ pythonDir = fs::path(envPy);
+ } else {
+ pythonDir = fs::path(QCoreApplication::applicationDirPath().toStdWString()) / "python";
+ }
+ paths.emplace_back(pythonDir / "site-packages");
+ paths.emplace_back(pythonDir);
+
+#ifdef MO2_PYTHON_PURELIB_DIR
+ if (std::strlen(MO2_PYTHON_PURELIB_DIR) > 0) {
+ paths.emplace_back(MO2_PYTHON_PURELIB_DIR);
+ }
+#endif
+#ifdef MO2_PYTHON_PLATLIB_DIR
+ if (std::strlen(MO2_PYTHON_PLATLIB_DIR) > 0 &&
+ std::strcmp(MO2_PYTHON_PLATLIB_DIR, MO2_PYTHON_PURELIB_DIR) != 0) {
+ paths.emplace_back(MO2_PYTHON_PLATLIB_DIR);
+ }
+#endif
+ m_Runner->initialize(paths);
+#endif
+ }
+
+ if (m_MOInfo) {
+ m_MOInfo->setPersistent(name(), "tryInit", false);
+ }
+
+ // reset DLL directory
+#ifdef _WIN32
+ SetDllDirectoryW(NULL);
+#endif
+
+ if (!m_Runner || !m_Runner->isInitialized()) {
+ m_LoadFailure = FailureType::INITIALIZATION;
+ }
+ else {
+ m_Runner->addDllSearchPath(pluginDataRoot / "dlls");
+ }
+
+ return true;
+}
+
+QString ProxyPython::name() const
+{
+ return "Python Proxy";
+}
+
+QString ProxyPython::localizedName() const
+{
+ return tr("Python Proxy");
+}
+
+QString ProxyPython::author() const
+{
+ return "AnyOldName3, Holt59, Silarn, Tannin";
+}
+
+QString ProxyPython::description() const
+{
+ return tr("Proxy Plugin to allow plugins written in python to be loaded");
+}
+
+VersionInfo ProxyPython::version() const
+{
+ return VersionInfo(3, 0, 0, VersionInfo::RELEASE_FINAL);
+}
+
+QList<PluginSetting> ProxyPython::settings() const
+{
+ return {};
+}
+
+QStringList ProxyPython::pluginList(const QDir& pluginPath) const
+{
+ QDir dir(pluginPath);
+ dir.setFilter(dir.filter() | QDir::NoDotAndDotDot);
+ QDirIterator iter(dir);
+
+ // Note: We put python script (.py) and directory names, not the __init__.py
+ // files in those since it is easier for the runner to import them.
+ QStringList result;
+ while (iter.hasNext()) {
+ QString name = iter.next();
+ QFileInfo info = iter.fileInfo();
+ const QString baseName = info.fileName();
+
+ if (info.isFile() && name.endsWith(".py")) {
+ // Compatibility shims and disabled plugins staged on Linux; they are not
+ // loaded as MO2 plugins.
+ if (baseName == "winreg.py" || baseName == "lzokay.py" ||
+ baseName == "FNISPatches.py" || baseName == "FNISTool.py" ||
+ baseName == "FNISToolReset.py") {
+ continue;
+ }
+ result.append(name);
+ }
+ else if (info.isDir() && QDir(info.absoluteFilePath()).exists("__init__.py")) {
+ result.append(name);
+ }
+ }
+
+ return result;
+}
+
+QList<QObject*> ProxyPython::load(const QString& identifier)
+{
+ if (!m_Runner) {
+ return {};
+ }
+ return m_Runner->load(identifier);
+}
+
+void ProxyPython::unload(const QString& identifier)
+{
+ if (m_Runner) {
+ return m_Runner->unload(identifier);
+ }
+}
+
+std::vector<unsigned int> ProxyPython::activeProblems() const
+{
+ auto failure = m_LoadFailure;
+
+ // don't know how this could happen but wth
+ if (m_Runner && !m_Runner->isInitialized()) {
+ failure = FailureType::INITIALIZATION;
+ }
+
+ if (failure != FailureType::NONE) {
+ return {static_cast<std::underlying_type_t<FailureType>>(failure)};
+ }
+
+ return {};
+}
+
+QString ProxyPython::shortDescription(unsigned int key) const
+{
+ switch (static_cast<FailureType>(key)) {
+ case FailureType::SEMICOLON:
+ return tr("ModOrganizer path contains a semicolon");
+ case FailureType::DLL_NOT_FOUND:
+ return tr("Python DLL not found");
+ case FailureType::INVALID_DLL:
+ return tr("Invalid Python DLL");
+ case FailureType::INITIALIZATION:
+ return tr("Initializing Python failed");
+ default:
+ return tr("invalid problem key %1").arg(key);
+ }
+}
+
+QString ProxyPython::fullDescription(unsigned int key) const
+{
+ switch (static_cast<FailureType>(key)) {
+ case FailureType::SEMICOLON:
+ return tr("The path to Mod Organizer (%1) contains a semicolon.<br>"
+ "While this is legal on NTFS drives, many applications 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 can offer is to remove the semicolon or move MO to a "
+ "path without a semicolon.")
+ .arg(QCoreApplication::applicationDirPath());
+ case FailureType::DLL_NOT_FOUND:
+ return tr("The Python plugin DLL was not found, maybe your antivirus deleted "
+ "it. Re-installing MO2 might fix the problem.");
+ case FailureType::INVALID_DLL:
+ return tr(
+ "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.");
+ case FailureType::INITIALIZATION:
+ return tr("The initialization of the Python plugin DLL failed, unfortunately "
+ "without any details.");
+ default:
+ return tr("invalid problem key %1").arg(key);
+ }
+}
+
+bool ProxyPython::hasGuidedFix(unsigned int) const
+{
+ return false;
+}
+
+void ProxyPython::startGuidedFix(unsigned int) const {}
diff --git a/libs/plugin_python/src/proxy/proxypython.h b/libs/plugin_python/src/proxy/proxypython.h
new file mode 100644
index 0000000..e824e2a
--- /dev/null
+++ b/libs/plugin_python/src/proxy/proxypython.h
@@ -0,0 +1,80 @@
+/*
+Copyright (C) 2022 Sebastian Herbord & MO2 Team. All rights reserved.
+
+This file is part of python proxy plugin for MO
+
+python proxy plugin is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+Python proxy plugin is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with python proxy plugin. If not, see <http://www.gnu.org/licenses/>.
+*/
+
+#ifndef PROXYPYTHON_H
+#define PROXYPYTHON_H
+
+#include <map>
+#include <memory>
+
+#include <uibase/iplugindiagnose.h>
+#include <uibase/ipluginproxy.h>
+
+#include <pythonrunner.h>
+
+class ProxyPython : public QObject,
+ public MOBase::IPluginProxy,
+ public MOBase::IPluginDiagnose {
+ Q_OBJECT
+ Q_INTERFACES(MOBase::IPlugin MOBase::IPluginProxy MOBase::IPluginDiagnose)
+ Q_PLUGIN_METADATA(IID "org.mo2.ProxyPython")
+
+public:
+ ProxyPython();
+
+ virtual bool init(MOBase::IOrganizer* moInfo);
+ virtual QString name() const override;
+ virtual QString localizedName() const override;
+ virtual QString author() const override;
+ virtual QString description() const override;
+ virtual MOBase::VersionInfo version() const override;
+ virtual QList<MOBase::PluginSetting> settings() const override;
+
+ QStringList pluginList(const QDir& pluginPath) const override;
+ QList<QObject*> load(const QString& identifier) override;
+ void unload(const QString& identifier) override;
+
+public: // IPluginDiagnose
+ virtual std::vector<unsigned int> activeProblems() const override;
+ virtual QString shortDescription(unsigned int key) const override;
+ virtual QString fullDescription(unsigned int key) const override;
+ virtual bool hasGuidedFix(unsigned int key) const override;
+ virtual void startGuidedFix(unsigned int key) const override;
+
+private:
+ MOBase::IOrganizer* m_MOInfo;
+#ifdef _WIN32
+ HMODULE m_RunnerLib;
+#else
+ void* m_RunnerLib;
+#endif
+ std::unique_ptr<mo2::python::IPythonRunner> m_Runner;
+
+ enum class FailureType : unsigned int {
+ NONE = 0,
+ SEMICOLON = 1,
+ DLL_NOT_FOUND = 2,
+ INVALID_DLL = 3,
+ INITIALIZATION = 4
+ };
+
+ FailureType m_LoadFailure;
+};
+
+#endif // PROXYPYTHON_H
diff --git a/libs/plugin_python/src/pybind11-qt/CMakeLists.txt b/libs/plugin_python/src/pybind11-qt/CMakeLists.txt
new file mode 100644
index 0000000..cb742ad
--- /dev/null
+++ b/libs/plugin_python/src/pybind11-qt/CMakeLists.txt
@@ -0,0 +1,60 @@
+cmake_minimum_required(VERSION 3.16)
+
+find_package(Qt6 REQUIRED COMPONENTS Core Widgets)
+
+mo2_find_python_executable(PYTHON_EXE)
+
+add_library(pybind11-qt STATIC)
+mo2_configure_target(pybind11-qt
+ NO_SOURCES
+ WARNINGS 4
+ EXTERNAL_WARNINGS 4
+ AUTOMOC OFF
+ TRANSLATIONS OFF
+)
+mo2_default_source_group()
+target_sources(pybind11-qt
+ PRIVATE
+ ./include/pybind11_qt/pybind11_qt_basic.h
+ ./include/pybind11_qt/pybind11_qt_containers.h
+ ./include/pybind11_qt/pybind11_qt_enums.h
+ ./include/pybind11_qt/pybind11_qt_holder.h
+ ./include/pybind11_qt/pybind11_qt_objects.h
+ ./include/pybind11_qt/pybind11_qt_qflags.h
+ ./include/pybind11_qt/pybind11_qt.h
+
+ pybind11_qt_basic.cpp
+ pybind11_qt_sip.cpp
+ pybind11_qt_utils.cpp
+
+)
+mo2_target_sources(pybind11-qt
+ FOLDER src/details
+ PRIVATE
+ ./include/pybind11_qt/details/pybind11_qt_enum.h
+ ./include/pybind11_qt/details/pybind11_qt_qlist.h
+ ./include/pybind11_qt/details/pybind11_qt_qmap.h
+ ./include/pybind11_qt/details/pybind11_qt_sip.h
+ ./include/pybind11_qt/details/pybind11_qt_utils.h
+)
+target_link_libraries(pybind11-qt PUBLIC pybind11::pybind11 PRIVATE Qt6::Core Qt6::Widgets)
+target_include_directories(pybind11-qt PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include)
+
+# this is kind of broken but it only works with this...
+target_compile_definitions(pybind11-qt PUBLIC QT_NO_KEYWORDS)
+
+# we need sip.h for pybind11-qt
+add_custom_target(PyQt6-siph DEPENDS "${CMAKE_CURRENT_BINARY_DIR}/sip.h")
+set_target_properties(PyQt6-siph PROPERTIES FOLDER autogen)
+add_custom_command(
+ OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/sip.h"
+ COMMAND ${Python_EXECUTABLE} -m sipbuild.tools.module
+ --sip-h
+ --target-dir ${CMAKE_CURRENT_BINARY_DIR}
+ PyQt${MO2_QT_VERSION_MAJOR}.sip
+)
+add_dependencies(pybind11-qt PyQt6-siph)
+
+target_include_directories(pybind11-qt PRIVATE ${CMAKE_CURRENT_BINARY_DIR})
+
+add_library(pybind11::qt ALIAS pybind11-qt)
diff --git a/libs/plugin_python/src/pybind11-qt/README.md b/libs/plugin_python/src/pybind11-qt/README.md
new file mode 100644
index 0000000..784f9a8
--- /dev/null
+++ b/libs/plugin_python/src/pybind11-qt/README.md
@@ -0,0 +1,64 @@
+# pybind11-qt
+
+This library contains code to interface pybind11 with Qt and PyQt.
+
+## Type casters
+
+The main part of this library is a set of (templated) type casters for Qt types that
+can be used by simply importing `pybind11_qt/pybind11_qt.h`.
+This provides type casters for:
+
+- Standard Qt types, such as `QString` or `QVariant`.
+ - `QString` is equivalent to Python `str` (unicode or not).
+ - `QVariant` is not exposed but the object is directly converted, similarly to
+ `std::variant` default type caster.
+- Qt containers (`QList`, `QSet`, `QMap`, `QStringList`).
+ - The `QList` type-caster is more flexible than the standard container type-casters
+ from pybind11 as it accepts any iterable.
+- `QFlags` - Delegates the cast to the underlying type, basically.
+- Qt enumerations: a lot of enumerations are provided in
+ [`pybind11_qt_enums.h`](include/pybind11_qt/pybind11_qt_enums.h) and new ones can be
+ easily added using the `PYQT_ENUM` macro (inside the header file).
+- Qt objects: very few are provided in
+ [`pybind11_qt_objects.h`](include/pybind11_qt/pybind11_qt_objects.h) and new ones can
+ be added using the `PYQT_OBJECT` macro (inside the header file).
+ - Copy-constructible Qt objects are copied when passing from C++ to Python or
+ vice-versa.
+ - Non copy-constructible Qt objects, e.g., `QObject` or `QWidget` should always be
+ exposed as pointer, and their ownership is transferred to C++ when coming from
+ Python.
+
+## Qt holder
+
+The library also provides a `pybind11::qt::qobject_holder` holder for pybind11 that
+transfer ownerships of the Python object to the underlying `QObject`.
+
+This holder is useful when exposing classes inheriting `QObject` (or a child class of
+`QObject`) that can be extended to Python.
+
+The library also provides two `set_qt_owner` functions that can be used to transfer
+ownership manually.
+
+## Qt delegates
+
+The library provides a `add_qt_delegate` function that can be used to delegate Python
+call to Qt functions to C++:
+
+```cpp
+py::class_<
+ // the C++ class extending QObject to expose
+ ISaveGameInfoWidget,
+
+ // the trampoline class
+ PySaveGameInfoWidget,
+
+ // the Qt holder to keep the Python object alive alongside the C++ one
+ py::qt::qobject_holder<ISaveGameInfoWidget>
+
+> iSaveGameInfoWidget(m, "ISaveGameInfoWidget");
+
+// allow to access most of the class attributes through Python via an overload of
+// __getattr__ and add a _widget() method to access the widget itself if needed
+//
+py::qt::add_qt_delegate<QWidget>(iSaveGameInfoWidget, "_widget");
+```
diff --git a/libs/plugin_python/src/pybind11-qt/include/pybind11_qt/details/pybind11_qt_enum.h b/libs/plugin_python/src/pybind11-qt/include/pybind11_qt/details/pybind11_qt_enum.h
new file mode 100644
index 0000000..45afd0c
--- /dev/null
+++ b/libs/plugin_python/src/pybind11-qt/include/pybind11_qt/details/pybind11_qt_enum.h
@@ -0,0 +1,59 @@
+#ifndef PYTHON_PYBIND11_QT_DETAILS_ENUM_HPP
+#define PYTHON_PYBIND11_QT_DETAILS_ENUM_HPP
+
+#include <type_traits>
+
+#include <pybind11/pybind11.h>
+
+#include "pybind11_qt_utils.h"
+
+namespace pybind11::detail::qt {
+
+ // EnumData, with static members (const char[])
+ // - package: name of the Python package containing the enum (e.g.,
+ // PyQt6.QtCore)
+ // - name: full path to the enum, e.g. Qt.QGlobalColor
+ //
+ template <typename T>
+ struct EnumData;
+
+ // template class for most Qt types that have Python equivalent (QWidget,
+ // etc.)
+ //
+ template <class Enum>
+ struct qt_enum_caster {
+
+ public:
+ PYBIND11_TYPE_CASTER(Enum, EnumData<Enum>::package + const_name(".") +
+ EnumData<Enum>::name);
+
+ bool load(pybind11::handle src, bool)
+ {
+ if (PyLong_Check(src.ptr())) {
+ value = static_cast<Enum>(PyLong_AsLong(src.ptr()));
+ return true;
+ }
+
+ auto pyenum =
+ get_attr_rec(EnumData<Enum>::package.text, EnumData<Enum>::name.text);
+
+ if (isinstance(src, pyenum)) {
+ value = static_cast<Enum>(src.attr("value").cast<int>());
+ return true;
+ }
+
+ return false;
+ }
+
+ static pybind11::handle cast(Enum src,
+ pybind11::return_value_policy /* policy */,
+ pybind11::handle /* parent */)
+ {
+ auto pyenum =
+ get_attr_rec(EnumData<Enum>::package.text, EnumData<Enum>::name.text);
+ return pyenum(static_cast<int>(src));
+ }
+ };
+} // namespace pybind11::detail::qt
+
+#endif
diff --git a/libs/plugin_python/src/pybind11-qt/include/pybind11_qt/details/pybind11_qt_qlist.h b/libs/plugin_python/src/pybind11-qt/include/pybind11_qt/details/pybind11_qt_qlist.h
new file mode 100644
index 0000000..e640725
--- /dev/null
+++ b/libs/plugin_python/src/pybind11-qt/include/pybind11_qt/details/pybind11_qt_qlist.h
@@ -0,0 +1,52 @@
+#ifndef PYTHON_PYBIND11_QT_DETAILS_QLIST_HPP
+#define PYTHON_PYBIND11_QT_DETAILS_QLIST_HPP
+
+#include <pybind11/pybind11.h>
+#include <pybind11/stl.h>
+
+namespace pybind11::detail::qt {
+
+ // helper class for QList to construct from any proper iterable
+ //
+ template <typename Type, typename Value>
+ struct qlist_caster {
+ using value_conv = make_caster<Value>;
+
+ bool load(handle src, bool convert)
+ {
+ if (!isinstance<iterable>(src) || isinstance<bytes>(src) ||
+ isinstance<str>(src)) {
+ return false;
+ }
+ auto s = reinterpret_borrow<iterable>(src);
+ value.clear();
+
+ if (isinstance<sequence>(src)) {
+ value.reserve(s.cast<sequence>().size());
+ }
+ for (auto it : s) {
+ value_conv conv;
+ if (!conv.load(it, convert)) {
+ return false;
+ }
+ value.push_back(cast_op<Value&&>(std::move(conv)));
+ }
+ return true;
+ }
+
+ template <typename T>
+ static handle cast(T&& src, return_value_policy policy, handle parent)
+ {
+ return list_caster<QList<Value>, Value>{}.cast(std::forward<T>(src), policy,
+ parent);
+ }
+
+ // we type these as "Sequence" even if these can be constructed from Iterable,
+ // otherwise the return type will be typed as "Iterable" which is problematic
+ PYBIND11_TYPE_CASTER(Type, const_name("Sequence[") + value_conv::name +
+ const_name("]"));
+ };
+
+} // namespace pybind11::detail::qt
+
+#endif
diff --git a/libs/plugin_python/src/pybind11-qt/include/pybind11_qt/details/pybind11_qt_qmap.h b/libs/plugin_python/src/pybind11-qt/include/pybind11_qt/details/pybind11_qt_qmap.h
new file mode 100644
index 0000000..ea743b7
--- /dev/null
+++ b/libs/plugin_python/src/pybind11-qt/include/pybind11_qt/details/pybind11_qt_qmap.h
@@ -0,0 +1,70 @@
+#ifndef PYTHON_PYBIND11_QT_DETAILS_QMAP_HPP
+#define PYTHON_PYBIND11_QT_DETAILS_QMAP_HPP
+
+#include <pybind11/pybind11.h>
+
+namespace pybind11::detail::qt {
+
+ // helper class for QMap because QMap do not follow the standard std:: maps
+ // interface, for other containers, the pybind11 built-in xxx_caster works
+ //
+ // this code is basically a copy/paste from the pybind11 stl stuff with
+ // minor modifications
+ //
+ template <typename Type, typename Key, typename Value>
+ struct qmap_caster {
+ using key_conv = make_caster<Key>;
+ using value_conv = make_caster<Value>;
+
+ bool load(handle src, bool convert)
+ {
+ if (!isinstance<dict>(src)) {
+ return false;
+ }
+ auto d = reinterpret_borrow<dict>(src);
+ value.clear();
+ for (auto it : d) {
+ key_conv kconv;
+ value_conv vconv;
+ if (!kconv.load(it.first.ptr(), convert) ||
+ !vconv.load(it.second.ptr(), convert)) {
+ return false;
+ }
+ value[cast_op<Key&&>(std::move(kconv))] =
+ cast_op<Value&&>(std::move(vconv));
+ }
+ return true;
+ }
+
+ template <typename T>
+ static handle cast(T&& src, return_value_policy policy, handle parent)
+ {
+ dict d;
+ return_value_policy policy_key = policy;
+ return_value_policy policy_value = policy;
+ if (!std::is_lvalue_reference<T>::value) {
+ policy_key = return_value_policy_override<Key>::policy(policy_key);
+ policy_value =
+ return_value_policy_override<Value>::policy(policy_value);
+ }
+ for (auto it = src.begin(); it != src.end(); ++it) {
+ auto key = reinterpret_steal<object>(
+ key_conv::cast(forward_like<T>(it.key()), policy_key, parent));
+ auto value = reinterpret_steal<object>(value_conv::cast(
+ forward_like<T>(it.value()), policy_value, parent));
+ if (!key || !value) {
+ return handle();
+ }
+ d[key] = value;
+ }
+ return d.release();
+ }
+
+ PYBIND11_TYPE_CASTER(Type, const_name("Dict[") + key_conv::name +
+ const_name(", ") + value_conv::name +
+ const_name("]"));
+ };
+
+} // namespace pybind11::detail::qt
+
+#endif
diff --git a/libs/plugin_python/src/pybind11-qt/include/pybind11_qt/details/pybind11_qt_sip.h b/libs/plugin_python/src/pybind11-qt/include/pybind11_qt/details/pybind11_qt_sip.h
new file mode 100644
index 0000000..556a980
--- /dev/null
+++ b/libs/plugin_python/src/pybind11-qt/include/pybind11_qt/details/pybind11_qt_sip.h
@@ -0,0 +1,221 @@
+#ifndef PYTHON_PYBIND11_QT_DETAILS_SIP_HPP
+#define PYTHON_PYBIND11_QT_DETAILS_SIP_HPP
+
+#include <type_traits>
+
+#include <pybind11/pybind11.h>
+
+#include <iostream>
+#include <pybind11/iostream.h>
+
+#include "../pybind11_qt_holder.h"
+
+struct _sipTypeDef;
+typedef struct _sipTypeDef sipTypeDef;
+
+struct _sipSimpleWrapper;
+typedef struct _sipSimpleWrapper sipSimpleWrapper;
+
+struct _sipWrapper;
+typedef struct _sipWrapper sipWrapper;
+
+namespace pybind11::detail::qt {
+
+ // helper functions to avoid bringing <sip.h> in this header
+ namespace sip {
+
+ // extract the underlying data if present from the equivalent PyQt object
+ void* extract_data(PyObject*);
+
+ const sipTypeDef* api_find_type(const char* type);
+ int api_can_convert_to_type(PyObject* pyObj, const sipTypeDef* td, int flags);
+
+ void api_transfer_to(PyObject* self, PyObject* owner);
+ void api_transfer_back(PyObject* self);
+ PyObject* api_convert_from_type(void* cpp, const sipTypeDef* td,
+ PyObject* transferObj);
+ } // namespace sip
+
+ template <typename T, class = void>
+ struct MetaData;
+
+ template <typename T>
+ struct MetaData<T, std::enable_if_t<std::is_pointer_v<T>>>
+ : MetaData<std::remove_pointer_t<T>> {};
+
+ // template class for most Qt types that have Python equivalent (QWidget,
+ // etc.)
+ //
+ template <class QClass>
+ struct qt_type_caster {
+
+ static constexpr bool is_pointer = std::is_pointer_v<QClass>;
+ using pointer = std::conditional_t<is_pointer, QClass, QClass*>;
+
+ QClass value;
+
+ public:
+ static constexpr auto name = MetaData<QClass>::python_name;
+
+ operator pointer()
+ {
+ if constexpr (is_pointer) {
+ return value;
+ }
+ else {
+ return &value;
+ }
+ }
+
+ // pybind11 requires operator T&() & and operator T&&() && but here we want to
+ // use SFINAE with is_pointer so we need to template the operator
+ //
+ // having a template <class U> operator U&&() does not work since it will not
+ // deduce the proper return type for QClass&& or QClass& so we have two separate
+ // overloads, and in each one, U is actually a reference type (lvalue or rvalue)
+ //
+
+ template <class U,
+ std::enable_if_t<std::is_same_v<std::remove_reference_t<U>, QClass> &&
+ std::is_lvalue_reference_v<U> && !is_pointer,
+ int> = 0>
+ operator U()
+ {
+ return value;
+ }
+
+ template <class U,
+ std::enable_if_t<std::is_same_v<std::remove_reference_t<U>, QClass> &&
+ std::is_rvalue_reference_v<U> && !is_pointer,
+ int> = 0>
+ operator U() &&
+ {
+ return std::move(value);
+ }
+
+ template <typename T>
+ using cast_op_type =
+ std::conditional_t<is_pointer, QClass, movable_cast_op_type<T>>;
+
+ bool load(pybind11::handle src, bool)
+ {
+ // special check for none for pointer classes
+ if constexpr (is_pointer) {
+ if (src.is_none()) {
+ value = nullptr;
+ return true;
+ }
+ }
+
+ const auto* type = sip::api_find_type(MetaData<QClass>::class_name);
+ if (type == nullptr) {
+ return false;
+ }
+ if (!sip::api_can_convert_to_type(src.ptr(), type, 0)) {
+ return false;
+ }
+
+ // this would transfer responsibility for deconstructing the
+ // object to C++, but pybind11 assumes l-value converters (such
+ // as this) don't do that instead, this should be called within
+ // the wrappers for functions which return deletable pointers.
+ //
+ // sipAPI()->api_transfer_to(objPtr, Py_None);
+ //
+ void* const data = sip::extract_data(src.ptr());
+
+ if (data) {
+ if constexpr (is_pointer) {
+ value = reinterpret_cast<QClass>(data);
+
+ // transfer ownership
+ sip::api_transfer_to(src.ptr(), Py_None);
+
+ // tie the py::object to the C++ one
+ new pybind11::detail::qt::qobject_holder_impl(value);
+ }
+ else {
+ value = *reinterpret_cast<QClass*>(data);
+ }
+ return true;
+ }
+ else {
+ return false;
+ }
+ }
+
+ template <
+ typename T,
+ std::enable_if_t<std::is_same<QClass, std::remove_cv_t<T>>::value, int> = 0>
+ static handle cast(T* src, return_value_policy policy, handle parent)
+ {
+ // note: when QClass is a pointer type, e.g. a QWidget*, T is a
+ // pointer to pointer, so we can defer to the standard cast()
+
+ if (!src) {
+ return none().release();
+ }
+
+ if (!is_pointer && policy == return_value_policy::take_ownership) {
+ auto h = cast(std::move(*src), policy, parent);
+ delete src;
+ return h;
+ }
+ return cast(*src, policy, parent);
+ }
+
+ static pybind11::handle cast(QClass src, pybind11::return_value_policy policy,
+ pybind11::handle /* parent */)
+ {
+ if constexpr (is_pointer) {
+ if (!src) {
+ return none().release();
+ }
+ }
+
+ const sipTypeDef* type = sip::api_find_type(MetaData<QClass>::class_name);
+ if (type == nullptr) {
+ return Py_None;
+ }
+
+ PyObject* sipObj;
+ void* sipData;
+
+ if constexpr (is_pointer) {
+ sipData = src;
+ }
+ else if (std::is_copy_assignable_v<QClass>) {
+ // we send to SIP a newly allocated object, and transfer the
+ // owernship to it
+ sipData =
+ new QClass(policy == ::pybind11::return_value_policy::take_ownership
+ ? std::move(src)
+ : src);
+ }
+ else {
+ sipData = &src;
+ }
+
+ sipObj = sip::api_convert_from_type(sipData, type, 0);
+
+ if (sipObj == nullptr) {
+ return Py_None;
+ }
+
+ // ensure Python deletes the C++ component
+ if constexpr (!is_pointer) {
+ sip::api_transfer_back(sipObj);
+ }
+ else {
+ if (policy == return_value_policy::take_ownership) {
+ sip::api_transfer_back(sipObj);
+ }
+ }
+
+ return sipObj;
+ }
+ };
+
+} // namespace pybind11::detail::qt
+
+#endif
diff --git a/libs/plugin_python/src/pybind11-qt/include/pybind11_qt/details/pybind11_qt_utils.h b/libs/plugin_python/src/pybind11-qt/include/pybind11_qt/details/pybind11_qt_utils.h
new file mode 100644
index 0000000..c877c15
--- /dev/null
+++ b/libs/plugin_python/src/pybind11-qt/include/pybind11_qt/details/pybind11_qt_utils.h
@@ -0,0 +1,47 @@
+#ifndef PYTHON_PYBIND11_QT_DETAILS_UTILS_HPP
+#define PYTHON_PYBIND11_QT_DETAILS_UTILS_HPP
+
+#include <string>
+#include <string_view>
+
+#include <pybind11/pybind11.h>
+
+namespace pybind11::detail::qt {
+
+ /**
+ * @brief Convert a XXX::YYY compile time string to a XXX.YYY compile time
+ * string. Only one :: is allowed.
+ *
+ */
+ template <size_t N>
+ constexpr descr<N - 2> qt_name_cpp2py(const char (&name)[N])
+ {
+ descr<N - 2> res;
+ for (std::size_t i = 0, j = 0; i < N - 2; ++i) {
+
+ res.text[i] = name[j];
+
+ if (res.text[i] == ':') {
+ res.text[i] = '.';
+ j += 2;
+ }
+ else {
+ ++j;
+ }
+ }
+ return res;
+ }
+
+ /**
+ * @brief Retrieve the class from the given package at the given path
+ *
+ * @param package Name of the module.
+ * @param path Path to the class/object in the module.
+ *
+ * @return the object at the given path in the given module
+ */
+ pybind11::object get_attr_rec(std::string_view package, std::string_view path);
+
+} // namespace pybind11::detail::qt
+
+#endif
diff --git a/libs/plugin_python/src/pybind11-qt/include/pybind11_qt/pybind11_qt.h b/libs/plugin_python/src/pybind11-qt/include/pybind11_qt/pybind11_qt.h
new file mode 100644
index 0000000..e803e9f
--- /dev/null
+++ b/libs/plugin_python/src/pybind11-qt/include/pybind11_qt/pybind11_qt.h
@@ -0,0 +1,88 @@
+#ifndef PYTHON_PYBIND11_QT_HPP
+#define PYTHON_PYBIND11_QT_HPP
+
+// this header defines many type casters for Qt types, including:
+// - basic Qt types such as QString and QVariant - those do not have PyQt6 equivalent
+// - QFlags<> class templates
+// - containers such as QList<>, QSet<>, etc., the QList<> casters is more flexible than
+// the std::vector<> or std::list<> ones as it accepts any iterable
+// - many Qt enumeration types (see pybind11_qt_enums)
+// - many Qt classes with PyQt6 equivalent
+// - copyable type are copied between Python and C++
+// - non-copyable type (QObject, QWidget, QMainWindow) are always owned by the C++
+// side, even when constructed on the Python side, and owned their corresponding
+// Python object, e.g., an instance of a class inheriting QWidget created on the
+// Python side can be safely used in C++ since the Python object will be owned by
+// the C++ QWidget object
+//
+
+#include "pybind11_qt_basic.h"
+#include "pybind11_qt_containers.h"
+#include "pybind11_qt_enums.h"
+#include "pybind11_qt_holder.h"
+#include "pybind11_qt_objects.h"
+#include "pybind11_qt_qflags.h"
+
+namespace pybind11::qt {
+
+ /**
+ * @brief Tie the lifetime of the Python object to the lifetime of the given
+ * QObject.
+ *
+ * @param owner QObject that will own the python object.
+ * @param child Python object that the QObject will own.
+ */
+ inline void set_qt_owner(QObject* owner, object child)
+ {
+ new detail::qt::qobject_holder_impl{owner, child};
+ }
+
+ /**
+ * @brief Tie the lifetime of the given object to the lifetime of the corresponding
+ * Python object.
+ *
+ * This object must have been created from Python and must inherit QObject.
+ *
+ * @param object Object to tie.
+ */
+ template <typename Class>
+ void set_qt_owner(Class* object)
+ {
+ static_assert(std::is_base_of_v<QObject, Class>);
+ new detail::qt::qobject_holder_impl{object};
+ }
+
+ /**
+ * @brief Add Qt "delegate" to the given class.
+ *
+ * This function defines two methods: __getattr__ and name, where name will
+ * simply return the PyQtX object as a QClass* object, while __getattr__
+ * will delegate to the underlying QClass object when required.
+ *
+ * This allow access to Qt interface for object exposed using pybind11
+ * (e.g., signals, methods from QObject or QWidget, etc.).
+ *
+ * @param pyclass Python class to define the methods on.
+ * @param name Name of the method to retrieve the underlying object.
+ *
+ * @tparam QClass Name of the Qt class, cannot be deduced.
+ * @tparam Class Class being wrapped, deduced.
+ * @tparam Args... Arguments of the class template parameters, deduced.
+ */
+ template <class QClass, class Class, class... Args>
+ auto& add_qt_delegate(pybind11::class_<Class, Args...>& pyclass, const char* name)
+ {
+ return pyclass
+ .def(name,
+ [](Class* w) -> QClass* {
+ return w;
+ })
+ .def(
+ "__getattr__", +[](Class* w, pybind11::str str) -> pybind11::object {
+ return pybind11::cast((QClass*)w).attr(str);
+ });
+ }
+
+} // namespace pybind11::qt
+
+#endif
diff --git a/libs/plugin_python/src/pybind11-qt/include/pybind11_qt/pybind11_qt_basic.h b/libs/plugin_python/src/pybind11-qt/include/pybind11_qt/pybind11_qt_basic.h
new file mode 100644
index 0000000..911de34
--- /dev/null
+++ b/libs/plugin_python/src/pybind11-qt/include/pybind11_qt/pybind11_qt_basic.h
@@ -0,0 +1,36 @@
+#ifndef PYTHON_PYBIND11_QT_BASIC_HPP
+#define PYTHON_PYBIND11_QT_BASIC_HPP
+
+#include <QString>
+#include <QVariant>
+
+#include <pybind11/pybind11.h>
+
+namespace pybind11::detail {
+
+ // QString
+ //
+ template <>
+ struct type_caster<QString> {
+ PYBIND11_TYPE_CASTER(QString, const_name("str"));
+
+ bool load(handle src, bool);
+
+ static handle cast(QString src, return_value_policy policy, handle parent);
+ };
+
+ // QVariant - this needs to be defined BEFORE QVariantList
+ //
+ template <>
+ struct type_caster<QVariant> {
+ public:
+ PYBIND11_TYPE_CASTER(QVariant, const_name("MoVariant"));
+
+ bool load(handle src, bool);
+
+ static handle cast(QVariant var, return_value_policy policy, handle parent);
+ };
+
+} // namespace pybind11::detail
+
+#endif
diff --git a/libs/plugin_python/src/pybind11-qt/include/pybind11_qt/pybind11_qt_containers.h b/libs/plugin_python/src/pybind11-qt/include/pybind11_qt/pybind11_qt_containers.h
new file mode 100644
index 0000000..120b5e8
--- /dev/null
+++ b/libs/plugin_python/src/pybind11-qt/include/pybind11_qt/pybind11_qt_containers.h
@@ -0,0 +1,51 @@
+#ifndef PYTHON_PYBIND11_QT_CONTAINERS_HPP
+#define PYTHON_PYBIND11_QT_CONTAINERS_HPP
+
+#include <QList>
+#include <QMap>
+#include <QSet>
+
+#include <pybind11/pybind11.h>
+#include <pybind11/stl.h>
+
+// this needs to be included here to get proper QVariantList and QVariantMap
+#include "details/pybind11_qt_qlist.h"
+#include "details/pybind11_qt_qmap.h"
+#include "pybind11_qt_basic.h"
+
+namespace pybind11::detail {
+
+ // QList
+ //
+ template <class T>
+ struct type_caster<QList<T>> : qt::qlist_caster<QList<T>, T> {};
+
+ // QSet
+ //
+ template <class T>
+ struct type_caster<QSet<T>> : set_caster<QList<T>, T> {};
+
+ // QMap
+ //
+ template <class K, class V>
+ struct type_caster<QMap<K, V>> : qt::qmap_caster<QMap<K, V>, K, V> {};
+
+ // QStringList
+ //
+ template <>
+ struct type_caster<QStringList> : qt::qlist_caster<QStringList, QString> {};
+
+ // QVariantList
+ //
+ template <>
+ struct type_caster<QVariantList> : qt::qlist_caster<QVariantList, QVariant> {};
+
+ // QVariantMap
+ //
+ template <>
+ struct type_caster<QVariantMap> : qt::qmap_caster<QVariantMap, QString, QVariant> {
+ };
+
+} // namespace pybind11::detail
+
+#endif
diff --git a/libs/plugin_python/src/pybind11-qt/include/pybind11_qt/pybind11_qt_enums.h b/libs/plugin_python/src/pybind11-qt/include/pybind11_qt/pybind11_qt_enums.h
new file mode 100644
index 0000000..b58cc41
--- /dev/null
+++ b/libs/plugin_python/src/pybind11-qt/include/pybind11_qt/pybind11_qt_enums.h
@@ -0,0 +1,115 @@
+#ifndef PYTHON_PYBIND11_QT_ENUMS_HPP
+#define PYTHON_PYBIND11_QT_ENUMS_HPP
+
+#include <QColor>
+#include <QMessageBox>
+
+#include "details/pybind11_qt_enum.h"
+#include "details/pybind11_qt_utils.h"
+
+#define PYQT_ENUM(QPackage, QEnum) \
+ namespace pybind11::detail { \
+ namespace qt { \
+ template <> \
+ struct EnumData<QEnum> { \
+ constexpr static const auto package = \
+ const_name("PyQt6.") + const_name(#QPackage); \
+ constexpr static const auto name = qt_name_cpp2py(#QEnum); \
+ }; \
+ } \
+ template <> \
+ struct type_caster<QEnum> : qt::qt_enum_caster<QEnum> {}; \
+ }
+
+PYQT_ENUM(QtCore, Qt::AlignmentFlag);
+PYQT_ENUM(QtCore, Qt::AnchorPoint);
+PYQT_ENUM(QtCore, Qt::ApplicationAttribute);
+PYQT_ENUM(QtCore, Qt::ApplicationState);
+PYQT_ENUM(QtCore, Qt::ArrowType);
+PYQT_ENUM(QtCore, Qt::AspectRatioMode);
+PYQT_ENUM(QtCore, Qt::Axis);
+PYQT_ENUM(QtCore, Qt::BGMode);
+PYQT_ENUM(QtCore, Qt::BrushStyle);
+PYQT_ENUM(QtCore, Qt::CaseSensitivity);
+PYQT_ENUM(QtCore, Qt::CheckState);
+PYQT_ENUM(QtCore, Qt::ChecksumType);
+PYQT_ENUM(QtCore, Qt::ClipOperation);
+PYQT_ENUM(QtCore, Qt::ConnectionType);
+PYQT_ENUM(QtCore, Qt::ContextMenuPolicy);
+PYQT_ENUM(QtCore, Qt::CoordinateSystem);
+PYQT_ENUM(QtCore, Qt::Corner);
+PYQT_ENUM(QtCore, Qt::CursorMoveStyle);
+PYQT_ENUM(QtCore, Qt::CursorShape);
+PYQT_ENUM(QtCore, Qt::DateFormat);
+PYQT_ENUM(QtCore, Qt::DayOfWeek);
+PYQT_ENUM(QtCore, Qt::DockWidgetArea);
+PYQT_ENUM(QtCore, Qt::DropAction);
+PYQT_ENUM(QtCore, Qt::Edge);
+PYQT_ENUM(QtCore, Qt::EnterKeyType);
+PYQT_ENUM(QtCore, Qt::EventPriority);
+PYQT_ENUM(QtCore, Qt::FillRule);
+PYQT_ENUM(QtCore, Qt::FindChildOption);
+PYQT_ENUM(QtCore, Qt::FocusPolicy);
+PYQT_ENUM(QtCore, Qt::FocusReason);
+PYQT_ENUM(QtCore, Qt::GestureFlag);
+PYQT_ENUM(QtCore, Qt::GestureState);
+PYQT_ENUM(QtCore, Qt::GestureType);
+PYQT_ENUM(QtCore, Qt::GlobalColor);
+PYQT_ENUM(QtCore, Qt::HitTestAccuracy);
+PYQT_ENUM(QtCore, Qt::ImageConversionFlag);
+PYQT_ENUM(QtCore, Qt::InputMethodHint);
+PYQT_ENUM(QtCore, Qt::InputMethodQuery);
+PYQT_ENUM(QtCore, Qt::ItemDataRole);
+PYQT_ENUM(QtCore, Qt::ItemFlag);
+PYQT_ENUM(QtCore, Qt::ItemSelectionMode);
+PYQT_ENUM(QtCore, Qt::ItemSelectionOperation);
+PYQT_ENUM(QtCore, Qt::Key);
+PYQT_ENUM(QtCore, Qt::KeyboardModifier);
+PYQT_ENUM(QtCore, Qt::LayoutDirection);
+PYQT_ENUM(QtCore, Qt::MaskMode);
+PYQT_ENUM(QtCore, Qt::MatchFlag);
+PYQT_ENUM(QtCore, Qt::Modifier);
+PYQT_ENUM(QtCore, Qt::MouseButton);
+PYQT_ENUM(QtCore, Qt::MouseEventFlag);
+PYQT_ENUM(QtCore, Qt::MouseEventSource);
+PYQT_ENUM(QtCore, Qt::NativeGestureType);
+PYQT_ENUM(QtCore, Qt::NavigationMode);
+PYQT_ENUM(QtCore, Qt::Orientation);
+PYQT_ENUM(QtCore, Qt::PenCapStyle);
+PYQT_ENUM(QtCore, Qt::PenJoinStyle);
+PYQT_ENUM(QtCore, Qt::PenStyle);
+PYQT_ENUM(QtCore, Qt::ScreenOrientation);
+PYQT_ENUM(QtCore, Qt::ScrollBarPolicy);
+PYQT_ENUM(QtCore, Qt::ScrollPhase);
+PYQT_ENUM(QtCore, Qt::ShortcutContext);
+PYQT_ENUM(QtCore, Qt::SizeHint);
+PYQT_ENUM(QtCore, Qt::SizeMode);
+PYQT_ENUM(QtCore, Qt::SortOrder);
+PYQT_ENUM(QtCore, Qt::TabFocusBehavior);
+PYQT_ENUM(QtCore, Qt::TextElideMode);
+PYQT_ENUM(QtCore, Qt::TextFlag);
+PYQT_ENUM(QtCore, Qt::TextFormat);
+PYQT_ENUM(QtCore, Qt::TextInteractionFlag);
+PYQT_ENUM(QtCore, Qt::TileRule);
+PYQT_ENUM(QtCore, Qt::TimeSpec);
+PYQT_ENUM(QtCore, Qt::TimerType);
+PYQT_ENUM(QtCore, Qt::ToolBarArea);
+PYQT_ENUM(QtCore, Qt::ToolButtonStyle);
+PYQT_ENUM(QtCore, Qt::TransformationMode);
+PYQT_ENUM(QtCore, Qt::WhiteSpaceMode);
+PYQT_ENUM(QtCore, Qt::WidgetAttribute);
+PYQT_ENUM(QtCore, Qt::WindowFrameSection);
+PYQT_ENUM(QtCore, Qt::WindowModality);
+PYQT_ENUM(QtCore, Qt::WindowState);
+PYQT_ENUM(QtCore, Qt::WindowType);
+
+PYQT_ENUM(QtWidgets, QMessageBox::ButtonRole);
+PYQT_ENUM(QtWidgets, QMessageBox::DialogCode);
+PYQT_ENUM(QtWidgets, QMessageBox::Icon);
+PYQT_ENUM(QtWidgets, QMessageBox::PaintDeviceMetric);
+PYQT_ENUM(QtWidgets, QMessageBox::RenderFlag);
+PYQT_ENUM(QtWidgets, QMessageBox::StandardButton);
+
+#undef PYQT_ENUM
+
+#endif
diff --git a/libs/plugin_python/src/pybind11-qt/include/pybind11_qt/pybind11_qt_holder.h b/libs/plugin_python/src/pybind11-qt/include/pybind11_qt/pybind11_qt_holder.h
new file mode 100644
index 0000000..39200d5
--- /dev/null
+++ b/libs/plugin_python/src/pybind11-qt/include/pybind11_qt/pybind11_qt_holder.h
@@ -0,0 +1,59 @@
+#ifndef PYTHON_PYBIND11_QT_HOLDER_HPP
+#define PYTHON_PYBIND11_QT_HOLDER_HPP
+
+#include <QObject>
+
+#include <pybind11/pybind11.h>
+
+namespace pybind11::detail::qt {
+
+ class qobject_holder_impl : public QObject {
+ object p_;
+
+ public:
+ /**
+ * @brief Construct a new qobject holder linked to the given QObject and
+ * maintaining the given python object alive.
+ *
+ * @param p Parent of this holder.
+ * @param o Python object to keep alive.
+ */
+ qobject_holder_impl(QObject* p, object o) : p_{o} { setParent(p); }
+
+ template <class U>
+ qobject_holder_impl(U* p)
+ : qobject_holder_impl{p, reinterpret_borrow<object>(cast(p))}
+ {
+ }
+
+ ~qobject_holder_impl()
+ {
+ gil_scoped_acquire s;
+ p_ = object();
+ }
+ };
+
+} // namespace pybind11::detail::qt
+
+namespace pybind11::qt {
+
+ template <class Type>
+ class qobject_holder {
+ using type = Type;
+
+ type* qobj_;
+
+ public:
+ qobject_holder(type* qobj) : qobj_{qobj}
+ {
+ new detail::qt::qobject_holder_impl(qobj_);
+ }
+
+ type* get() { return qobj_; }
+ };
+
+} // namespace pybind11::qt
+
+PYBIND11_DECLARE_HOLDER_TYPE(T, ::pybind11::qt::qobject_holder<T>)
+
+#endif
diff --git a/libs/plugin_python/src/pybind11-qt/include/pybind11_qt/pybind11_qt_objects.h b/libs/plugin_python/src/pybind11-qt/include/pybind11_qt/pybind11_qt_objects.h
new file mode 100644
index 0000000..e16b5ea
--- /dev/null
+++ b/libs/plugin_python/src/pybind11-qt/include/pybind11_qt/pybind11_qt_objects.h
@@ -0,0 +1,62 @@
+#ifndef PYTHON_PYBIND11_QT_OBJECTS_HPP
+#define PYTHON_PYBIND11_QT_OBJECTS_HPP
+
+#include <pybind11/pybind11.h>
+
+#include <QByteArray>
+#include <QDateTime>
+#include <QDir>
+#include <QFileInfo>
+#include <QIcon>
+#include <QMainWindow>
+#include <QPixmap>
+#include <QSize>
+#include <QUrl>
+#include <QWidget>
+
+#include "details/pybind11_qt_sip.h"
+#include "details/pybind11_qt_utils.h"
+
+#define PYQT_CLASS(QModule, QClass) \
+ namespace pybind11::detail { \
+ namespace qt { \
+ template <> \
+ struct MetaData<QClass> { \
+ constexpr static const auto class_name = #QClass; \
+ constexpr static const auto python_name = \
+ const_name("PyQt6.") + const_name(#QModule) + const_name(".") + \
+ const_name(#QClass); \
+ }; \
+ } \
+ template <> \
+ struct type_caster<QClass*> \
+ : std::conditional_t<std::is_copy_constructible_v<QClass>, \
+ type_caster_generic, qt::qt_type_caster<QClass*>> {}; \
+ template <> \
+ struct type_caster<QClass> \
+ : std::conditional_t<std::is_copy_constructible_v<QClass>, \
+ qt::qt_type_caster<QClass>, type_caster<QClass*>> {}; \
+ }
+
+// add declarations below to create bindings - the first argument is simply
+// the name of the PyQt6 package containing the class, and is only used for
+// the python signature
+
+PYQT_CLASS(QtCore, QByteArray);
+PYQT_CLASS(QtCore, QDateTime);
+PYQT_CLASS(QtCore, QDir);
+PYQT_CLASS(QtCore, QFileInfo);
+PYQT_CLASS(QtCore, QObject);
+PYQT_CLASS(QtCore, QSize);
+PYQT_CLASS(QtCore, QUrl);
+
+PYQT_CLASS(QtGui, QColor);
+PYQT_CLASS(QtGui, QIcon);
+PYQT_CLASS(QtGui, QPixmap);
+
+PYQT_CLASS(QtWidgets, QMainWindow);
+PYQT_CLASS(QtWidgets, QWidget);
+
+#undef METADATA
+
+#endif
diff --git a/libs/plugin_python/src/pybind11-qt/include/pybind11_qt/pybind11_qt_qflags.h b/libs/plugin_python/src/pybind11-qt/include/pybind11_qt/pybind11_qt_qflags.h
new file mode 100644
index 0000000..f246ed3
--- /dev/null
+++ b/libs/plugin_python/src/pybind11-qt/include/pybind11_qt/pybind11_qt_qflags.h
@@ -0,0 +1,56 @@
+#ifndef PYTHON_PYBIND11_QT_QFLAGS_HPP
+#define PYTHON_PYBIND11_QT_QFLAGS_HPP
+
+#include <QFlags>
+
+#include <pybind11/pybind11.h>
+
+namespace pybind11::detail {
+
+ // QFlags
+ //
+ template <class T>
+ struct type_caster<QFlags<T>> {
+ PYBIND11_TYPE_CASTER(QFlags<T>, const_name("QFlags[") + make_caster<T>::name +
+ const_name("]"));
+
+ /**
+ * Conversion part 1 (Python->C++): convert a PyObject into a QString
+ * instance or return false upon failure. The second argument
+ * indicates whether implicit conversions should be applied.
+ */
+ bool load(handle src, bool)
+ {
+ PyObject* tmp = PyNumber_Long(src.ptr());
+
+ if (!tmp) {
+ return false;
+ }
+
+ // we do an intermediate extraction to T but this actually
+ // can contains multiple values
+ T flag_value = static_cast<T>(PyLong_AsLong(tmp));
+ Py_DECREF(tmp);
+
+ value = QFlags<T>(flag_value);
+
+ return !PyErr_Occurred();
+ }
+
+ /**
+ * Conversion part 2 (C++ -> Python): convert an QString instance into
+ * a Python object. The second and third arguments are used to
+ * indicate the return value policy and parent object (for
+ * ``return_value_policy::reference_internal``) and are generally
+ * ignored by implicit casters.
+ */
+ static handle cast(QFlags<T> const& src, return_value_policy /* policy */,
+ handle /* parent */)
+ {
+ return PyLong_FromLong(static_cast<int>(src));
+ }
+ };
+
+} // namespace pybind11::detail
+
+#endif
diff --git a/libs/plugin_python/src/pybind11-qt/pybind11_qt_basic.cpp b/libs/plugin_python/src/pybind11-qt/pybind11_qt_basic.cpp
new file mode 100644
index 0000000..d82ec2b
--- /dev/null
+++ b/libs/plugin_python/src/pybind11-qt/pybind11_qt_basic.cpp
@@ -0,0 +1,156 @@
+#include "pybind11_qt/pybind11_qt_basic.h"
+
+#include <type_traits>
+
+#include <pybind11/stl/filesystem.h>
+
+#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 <class CharT>
+ QString qstring_from_stdstring(std::basic_string<CharT> const& s)
+ {
+ if constexpr (std::is_same_v<CharT, char>) {
+ return QString::fromStdString(s);
+ }
+ else if constexpr (std::is_same_v<CharT, wchar_t>) {
+ return QString::fromStdWString(s);
+ }
+ else if constexpr (std::is_same_v<CharT, char16_t>) {
+ return QString::fromStdU16String(s);
+ }
+ else if constexpr (std::is_same_v<CharT, char32_t>) {
+ return QString::fromStdU32String(s);
+ }
+ }
+
+ /**
+ * Conversion part 1 (Python->C++): convert a PyObject into a QString
+ * instance or return false upon failure. The second argument
+ * indicates whether implicit conversions should be applied.
+ */
+ bool type_caster<QString>::load(handle src, bool)
+ {
+ PyObject* objPtr = src.ptr();
+
+ if (PyBytes_Check(objPtr)) {
+ value = QString::fromUtf8(PyBytes_AsString(objPtr));
+ return true;
+ }
+ else if (PyUnicode_Check(objPtr)) {
+ switch (PyUnicode_KIND(objPtr)) {
+ case PyUnicode_1BYTE_KIND:
+ value = QString::fromUtf8(PyUnicode_AsUTF8(objPtr));
+ break;
+ case PyUnicode_2BYTE_KIND:
+ value = QString::fromUtf16(
+ reinterpret_cast<char16_t*>(PyUnicode_2BYTE_DATA(objPtr)),
+ PyUnicode_GET_LENGTH(objPtr));
+ break;
+ case PyUnicode_4BYTE_KIND:
+ value = QString::fromUcs4(
+ reinterpret_cast<char32_t*>(PyUnicode_4BYTE_DATA(objPtr)),
+ PyUnicode_GET_LENGTH(objPtr));
+ break;
+ default:
+ return false;
+ }
+
+ return true;
+ }
+ else {
+ return false;
+ }
+ }
+
+ /**
+ * Conversion part 2 (C++ -> Python): convert an QString instance into
+ * a Python object. The second and third arguments are used to
+ * indicate the return value policy and parent object (for
+ * ``return_value_policy::reference_internal``) and are generally
+ * ignored by implicit casters.
+ */
+ handle type_caster<QString>::cast(QString src, return_value_policy /* policy */,
+ handle /* parent */)
+ {
+ return PyUnicode_DecodeUTF16(reinterpret_cast<const char*>(src.utf16()),
+ 2 * src.length(), nullptr, 0);
+ }
+
+ bool type_caster<QVariant>::load(handle src, bool)
+ {
+ // test for string first otherwise PyList_Check also works
+ if (PyBytes_Check(src.ptr()) || PyUnicode_Check(src.ptr())) {
+ value = src.cast<QString>();
+ 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<QVariantList>();
+ return true;
+ }
+ else if (PyMapping_Check(src.ptr())) {
+ value = src.cast<QVariantMap>();
+ return true;
+ }
+ else if (src.is(pybind11::none())) {
+ value = QVariant();
+ return true;
+ }
+ else if (PyDict_Check(src.ptr())) {
+ value = src.cast<QVariantMap>();
+ 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())) {
+ value = src.cast<bool>();
+ return true;
+ }
+ else if (PyLong_Check(src.ptr())) {
+ // QVariant doesn't have long. It has int or long long. Given that
+ // on m/s, long is 32 bits for 32- and 64- bit code...
+ value = src.cast<int>();
+ return true;
+ }
+ else {
+ return false;
+ }
+ }
+
+ handle type_caster<QVariant>::cast(QVariant var, return_value_policy policy,
+ handle parent)
+ {
+ switch (var.typeId()) {
+ case QMetaType::UnknownType:
+ return Py_None;
+ case QMetaType::Int:
+ return PyLong_FromLong(var.toInt());
+ case QMetaType::UInt:
+ return PyLong_FromUnsignedLong(var.toUInt());
+ case QMetaType::Bool:
+ return PyBool_FromLong(var.toBool());
+ case QMetaType::QString:
+ return type_caster<QString>::cast(var.toString(), policy, parent);
+ // We need to check for StringList here because these are not considered
+ // List since List is QList<QVariant> will StringList is QList<QString>:
+ case QMetaType::QStringList:
+ return type_caster<QStringList>::cast(var.toStringList(), policy, parent);
+ case QMetaType::QVariantList:
+ return type_caster<QVariantList>::cast(var.toList(), policy, parent);
+ case QMetaType::QVariantMap:
+ return type_caster<QVariantMap>::cast(var.toMap(), policy, parent);
+ default: {
+ PyErr_Format(PyExc_TypeError, "type unsupported: %d", var.userType());
+ throw pybind11::error_already_set();
+ }
+ }
+ }
+
+} // namespace pybind11::detail
diff --git a/libs/plugin_python/src/pybind11-qt/pybind11_qt_sip.cpp b/libs/plugin_python/src/pybind11-qt/pybind11_qt_sip.cpp
new file mode 100644
index 0000000..69ad4b8
--- /dev/null
+++ b/libs/plugin_python/src/pybind11-qt/pybind11_qt_sip.cpp
@@ -0,0 +1,105 @@
+#include "pybind11_qt/details/pybind11_qt_sip.h"
+
+#include <stdexcept>
+
+#include <QString>
+
+#include <sip.h>
+
+namespace py = pybind11;
+
+namespace pybind11::detail::qt {
+
+ const sipAPIDef* sipAPI()
+ {
+ std::string exception;
+ static const sipAPIDef* sipApi = nullptr;
+ if (sipApi == nullptr) {
+ PyImport_ImportModule("PyQt6.sip");
+
+ {
+ auto errorObj = PyErr_Occurred();
+ if (errorObj != NULL) {
+ PyObject *type, *value, *traceback;
+ PyErr_Fetch(&type, &value, &traceback);
+ PyErr_NormalizeException(&type, &value, &traceback);
+ if (traceback != NULL) {
+ py::handle h_type(type);
+ py::handle h_val(value);
+ py::handle h_tb(traceback);
+ py::object tb(py::module_::import("traceback"));
+ py::object fmt_exp(tb.attr("format_exception"));
+ py::object exp_list(fmt_exp(h_type, h_val, h_tb));
+ py::object exp_str(py::str("\n").attr("join")(exp_list));
+ exception = exp_str.cast<std::string>();
+ }
+ PyErr_Restore(type, value, traceback);
+ throw std::runtime_error{"Failed to load SIP API: " + exception};
+ }
+ }
+
+ sipApi = (const sipAPIDef*)PyCapsule_Import("PyQt6.sip._C_API", 0);
+ if (sipApi == NULL) {
+ auto errorObj = PyErr_Occurred();
+ if (errorObj != NULL) {
+ PyObject *type, *value, *traceback;
+ PyErr_Fetch(&type, &value, &traceback);
+ PyErr_NormalizeException(&type, &value, &traceback);
+ if (traceback != NULL) {
+ py::handle h_type(type);
+ py::handle h_val(value);
+ py::handle h_tb(traceback);
+ py::object tb(py::module_::import("traceback"));
+ py::object fmt_exp(tb.attr("format_exception"));
+ py::object exp_list(fmt_exp(h_type, h_val, h_tb));
+ py::object exp_str(py::str("\n").attr("join")(exp_list));
+ exception = exp_str.cast<std::string>();
+ }
+ PyErr_Restore(type, value, traceback);
+ }
+ throw std::runtime_error{"Failed to load SIP API: " + exception};
+ }
+ }
+ return sipApi;
+ }
+
+ namespace sip {
+ const sipTypeDef* api_find_type(const char* type)
+ {
+ return sipAPI()->api_find_type(type);
+ }
+
+ int api_can_convert_to_type(PyObject* pyObj, const sipTypeDef* td, int flags)
+ {
+ return sipAPI()->api_can_convert_to_type(pyObj, td, flags);
+ }
+
+ void api_transfer_to(PyObject* self, PyObject* owner)
+ {
+ sipAPI()->api_transfer_to(self, owner);
+ }
+
+ void api_transfer_back(PyObject* self)
+ {
+ sipAPI()->api_transfer_back(self);
+ }
+
+ PyObject* api_convert_from_type(void* cpp, const sipTypeDef* td, PyObject*)
+ {
+ return sipAPI()->api_convert_from_type(cpp, td, 0);
+ }
+
+ void* extract_data(PyObject* ptr)
+ {
+ if (PyObject_TypeCheck(ptr, sipAPI()->api_simplewrapper_type)) {
+ return reinterpret_cast<sipSimpleWrapper*>(ptr)->data;
+ }
+ else if (PyObject_TypeCheck(ptr, sipAPI()->api_wrapper_type)) {
+ return reinterpret_cast<sipWrapper*>(ptr)->super.data;
+ }
+ return nullptr;
+ }
+
+ } // namespace sip
+
+} // namespace pybind11::detail::qt
diff --git a/libs/plugin_python/src/pybind11-qt/pybind11_qt_utils.cpp b/libs/plugin_python/src/pybind11-qt/pybind11_qt_utils.cpp
new file mode 100644
index 0000000..249bed5
--- /dev/null
+++ b/libs/plugin_python/src/pybind11-qt/pybind11_qt_utils.cpp
@@ -0,0 +1,11 @@
+#include "pybind11_qt/details/pybind11_qt_utils.h"
+
+namespace pybind11::detail::qt {
+
+ pybind11::object get_attr_rec(std::string_view package, std::string_view path)
+ {
+
+ return module_::import("operator")
+ .attr("attrgetter")(path.data())(module_::import(package.data()));
+ }
+} // namespace pybind11::detail::qt
diff --git a/libs/plugin_python/src/pybind11-utils/CMakeLists.txt b/libs/plugin_python/src/pybind11-utils/CMakeLists.txt
new file mode 100644
index 0000000..77895b6
--- /dev/null
+++ b/libs/plugin_python/src/pybind11-utils/CMakeLists.txt
@@ -0,0 +1,23 @@
+cmake_minimum_required(VERSION 3.16)
+
+add_library(pybind11-utils STATIC
+ ./include/pybind11_utils/functional.h
+ ./include/pybind11_utils/generator.h
+ ./include/pybind11_utils/shared_cpp_owner.h
+ ./include/pybind11_utils/smart_variant_wrapper.h
+ ./include/pybind11_utils/smart_variant.h
+
+ functional.cpp
+)
+mo2_configure_target(pybind11-utils
+ NO_SOURCES
+ WARNINGS 4
+ EXTERNAL_WARNINGS 4
+ AUTOMOC OFF
+ TRANSLATIONS OFF
+)
+mo2_default_source_group()
+target_link_libraries(pybind11-utils PUBLIC pybind11::pybind11)
+target_include_directories(pybind11-utils PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include)
+
+add_library(pybind11::utils ALIAS pybind11-utils)
diff --git a/libs/plugin_python/src/pybind11-utils/README.md b/libs/plugin_python/src/pybind11-utils/README.md
new file mode 100644
index 0000000..46a9256
--- /dev/null
+++ b/libs/plugin_python/src/pybind11-utils/README.md
@@ -0,0 +1,50 @@
+# pybind11-utils
+
+This library contains some utility stuff for `pybind11`
+
+## smart_variant_wrapper.h
+
+Expose a function `mo2::python::wrap_arguments` and a template
+`mo2::python::smart_variant` that can be used to expose more interesting types Python
+than the C++ one, e.g., accept `os.PathLike` and `QFileInfo` when a simple `QString` is
+expected.
+
+A toy example can be found in the test folder at
+[`tests/python/test_argument_wrapper.cpp`](../../tests/python/test_argument_wrapper.cpp).
+
+More concrete examples can be found in
+[`mobase/pybind11_all.h`](../mobase/pybind11_all.h) for `FileWrapper` and
+`DirectoryWrapper`.
+
+## functional.h
+
+TODO: updated version of `<pybind11/functional.h>` that should check the signature
+a bit more when creating `std::function` (similar to previous implementation).
+
+## shared_cpp_owner.h
+
+Expose a macro `MO2_PYBIND11_SHARED_CPP_HOLDER` that can be used to declare that
+`std::shared_ptr<...>` must hold-on their associate Python object.
+
+```cpp
+// use the macro on the type to be exposed (with a trampoline class)
+MO2_PYBIND11_SHARED_CPP_HOLDER(ISaveGame)
+
+// use std::shared_ptr<> as the holder for the class
+py::class_<ISaveGame, PySaveGame, std::shared_ptr<ISaveGame>>(...);
+```
+
+Using the `MO2_PYBIND11_SHARED_CPP_HOLDER` (must be present in all files manipulating
+`ISaveGame` between C++ and Python) ensure that the Python instance remains alive
+alongside the C++ one.
+
+The `MO2_PYBIND11_SHARED_CPP_HOLDER` declares a specialization of `type_caster<>` that
+alters the `std::shared_ptr` by return a `std::shared_ptr<>` that owns the Python
+object (via a `pybind11::object`) but does not release the C++ one - The C++ object is
+owned by the Python one, so the relation is as follows:
+
+- The `std::shared_ptr<X>` manipulated in C++ maintains the `pybind11::object` alive
+ through a custom deleter but DOES NOT release the C++ object when the reference count
+ reaches 0.
+- The Python object holds a standard `std::shared_ptr<X>` that will release the object
+ when the reference count reaches 0.
diff --git a/libs/plugin_python/src/pybind11-utils/functional.cpp b/libs/plugin_python/src/pybind11-utils/functional.cpp
new file mode 100644
index 0000000..4cc7b6c
--- /dev/null
+++ b/libs/plugin_python/src/pybind11-utils/functional.cpp
@@ -0,0 +1,28 @@
+#include "pybind11_utils/functional.h"
+
+namespace py = pybind11;
+
+namespace mo2::python::detail {
+
+ bool has_compatible_arity(py::function fn, std::size_t arity)
+ {
+ auto inspect = py::module_::import("inspect");
+ auto arg_spec = inspect.attr("getfullargspec")(fn);
+ py::object args = arg_spec.attr("args"), varargs = arg_spec.attr("varargs"),
+ defaults = arg_spec.attr("defaults");
+
+ auto args_count = args.is(py::none()) ? 0 : py::len(args);
+ auto defaults_count = defaults.is(py::none()) ? 0 : py::len(defaults);
+
+ if (inspect.attr("ismethod")(fn).cast<bool>() && py::hasattr(fn, "__self__")) {
+ --args_count;
+ }
+
+ auto required_count = args_count - defaults_count;
+
+ return required_count <= arity // cannot require more parameters than given,
+ && (args_count >= arity ||
+ !varargs.is_none()); // must accept enough parameters.
+ }
+
+} // namespace mo2::python::detail
diff --git a/libs/plugin_python/src/pybind11-utils/include/pybind11_utils/functional.h b/libs/plugin_python/src/pybind11-utils/include/pybind11_utils/functional.h
new file mode 100644
index 0000000..8662d82
--- /dev/null
+++ b/libs/plugin_python/src/pybind11-utils/include/pybind11_utils/functional.h
@@ -0,0 +1,155 @@
+#ifndef PYTHON_PYBIND11_FUNCTIONAL_H
+#define PYTHON_PYBIND11_FUNCTIONAL_H
+
+#include <pybind11/pybind11.h>
+
+namespace mo2::python::detail {
+
+ // check if the given function is valid for a C++ function with the
+ // given arity
+ //
+ bool has_compatible_arity(pybind11::function handle, std::size_t arity);
+
+} // namespace mo2::python::detail
+
+namespace pybind11::detail {
+
+ // custom type_caster for std::function<>
+ //
+ // most of this is from pybind11 except that we also check arity of the function to
+ // allow overloaded function based on arity of argument
+ //
+ template <typename Return, typename... Args>
+ struct type_caster<std::function<Return(Args...)>> {
+ using type = std::function<Return(Args...)>;
+ using retval_type =
+ conditional_t<std::is_same<Return, void>::value, void_type, Return>;
+ using function_type = Return (*)(Args...);
+
+ public:
+ bool load(handle src, bool convert)
+ {
+ if (src.is_none()) {
+ // Defer accepting None to other overloads (if we aren't in convert
+ // mode):
+ if (!convert) {
+ return false;
+ }
+ return true;
+ }
+
+ if (!isinstance<function>(src)) {
+ return false;
+ }
+
+ auto func = reinterpret_borrow<function>(src);
+
+ /*
+ When passing a C++ function as an argument to another C++
+ function via Python, every function call would normally involve
+ a full C++ -> Python -> C++ roundtrip, which can be prohibitive.
+ Here, we try to at least detect the case where the function is
+ stateless (i.e. function pointer or lambda function without
+ captured variables), in which case the roundtrip can be avoided.
+ */
+ if (auto cfunc = func.cpp_function()) {
+ auto* cfunc_self = PyCFunction_GET_SELF(cfunc.ptr());
+ if (isinstance<capsule>(cfunc_self)) {
+ auto c = reinterpret_borrow<capsule>(cfunc_self);
+ auto* rec = (function_record*)c;
+
+ while (rec != nullptr) {
+ if (rec->is_stateless &&
+ same_type(typeid(function_type),
+ *reinterpret_cast<const std::type_info*>(
+ rec->data[1]))) {
+ struct capture {
+ function_type f;
+ };
+ value = ((capture*)&rec->data)->f;
+ return true;
+ }
+ rec = rec->next;
+ }
+ }
+ // PYPY segfaults here when passing builtin function like sum.
+ // Raising an fail exception here works to prevent the segfault, but
+ // only on gcc. See PR #1413 for full details
+ }
+
+ // !MO2! - check arity
+
+ if (!mo2::python::detail::has_compatible_arity(func, sizeof...(Args))) {
+ return false;
+ }
+
+ // !MO2! - everything below is copy/paste from pybind11
+
+ // ensure GIL is held during functor destruction
+ struct func_handle {
+ function f;
+#if !(defined(_MSC_VER) && _MSC_VER == 1916 && defined(PYBIND11_CPP17))
+ // This triggers a syntax error under very special conditions (very
+ // weird indeed).
+ explicit
+#endif
+ func_handle(function&& f_) noexcept
+ : f(std::move(f_))
+ {
+ }
+ func_handle(const func_handle& f_) { operator=(f_); }
+ func_handle& operator=(const func_handle& f_)
+ {
+ gil_scoped_acquire acq;
+ f = f_.f;
+ return *this;
+ }
+ ~func_handle()
+ {
+ gil_scoped_acquire acq;
+ function kill_f(std::move(f));
+ }
+ };
+
+ // to emulate 'move initialization capture' in C++11
+ struct func_wrapper {
+ func_handle hfunc;
+ explicit func_wrapper(func_handle&& hf) noexcept : hfunc(std::move(hf))
+ {
+ }
+ Return operator()(Args... args) const
+ {
+ gil_scoped_acquire acq;
+ object retval(hfunc.f(std::forward<Args>(args)...));
+ return retval.template cast<Return>();
+ }
+ };
+
+ value = func_wrapper(func_handle(std::move(func)));
+ return true;
+ }
+
+ template <typename Func>
+ static handle cast(Func&& f_, return_value_policy policy, handle /* parent */)
+ {
+ if (!f_) {
+ return none().inc_ref();
+ }
+
+ auto result = f_.template target<function_type>();
+ if (result) {
+ return cpp_function(*result, policy).release();
+ }
+ return cpp_function(std::forward<Func>(f_), policy).release();
+ }
+
+ PYBIND11_TYPE_CASTER(type, const_name("Callable[[") +
+ concat(make_caster<Args>::name...) +
+ const_name("], ") +
+ make_caster<retval_type>::name +
+ const_name("]"));
+ };
+
+} // namespace pybind11::detail
+
+#endif
diff --git a/libs/plugin_python/src/pybind11-utils/include/pybind11_utils/generator.h b/libs/plugin_python/src/pybind11-utils/include/pybind11_utils/generator.h
new file mode 100644
index 0000000..cbf9d18
--- /dev/null
+++ b/libs/plugin_python/src/pybind11-utils/include/pybind11_utils/generator.h
@@ -0,0 +1,57 @@
+#ifndef PYTHON_PYBIND11_GENERATOR_H
+#define PYTHON_PYBIND11_GENERATOR_H
+
+#include <generator>
+
+#include <pybind11/pybind11.h>
+
+namespace mo2::python {
+
+ // the code here is mostly taken from pybind11 itself, and relies on some pybind11
+ // internals so might be subject to change when upgrading pybind11 versions
+
+ namespace detail {
+ template <typename T>
+ struct generator_state {
+ std::generator<T> g;
+ decltype(g.begin()) it;
+
+ generator_state(std::generator<T> gen) : g(std::move(gen)), it(g.begin()) {}
+ };
+ } // namespace detail
+
+ // create a Python generator from a C++ generator
+ //
+ template <typename T, typename... Args>
+ auto make_generator(std::generator<T> g, Args&&... args)
+ {
+ using state = detail::generator_state<T>;
+
+ namespace py = pybind11;
+ if (!py::detail::get_type_info(typeid(state), false)) {
+ py::class_<state>(py::handle(), "iterator", pybind11::module_local())
+ .def("__iter__",
+ [](state& s) -> state& {
+ return s;
+ })
+ .def(
+ "__next__",
+ [](state& s) -> T {
+ if (s.it != s.g.end()) {
+ T v = *s.it;
+ s.it++;
+ return v;
+ }
+ else {
+ throw py::stop_iteration();
+ }
+ },
+ std::forward<Args>(args)...);
+ }
+
+ return py::cast(state{std::move(g)});
+ }
+
+} // namespace mo2::python
+
+#endif
diff --git a/libs/plugin_python/src/pybind11-utils/include/pybind11_utils/shared_cpp_owner.h b/libs/plugin_python/src/pybind11-utils/include/pybind11_utils/shared_cpp_owner.h
new file mode 100644
index 0000000..02c2111
--- /dev/null
+++ b/libs/plugin_python/src/pybind11-utils/include/pybind11_utils/shared_cpp_owner.h
@@ -0,0 +1,90 @@
+#ifndef PYTHON_PYBIND11_SHARED_CPP_OWNER_H
+#define PYTHON_PYBIND11_SHARED_CPP_OWNER_H
+
+#include <pybind11/pybind11.h>
+
+// pybind11 has some issues when a Python classes extend a C++ wrapper since the Python
+// object is not kept alive alongside the returned object
+//
+// there is a pybind11 branch called "smart_holder" that tries to solve this in a very
+// complicated way (with many other features)
+//
+// here, we simply use a custom type_caster<> for the classes we need - see the actual
+// definition in mo2::python::detail below
+//
+// IMPORTANT: this only works for classes that are managed by shared_ptr on the C++
+// side, not Qt object (see pybind11-qt holder for that)
+//
+
+namespace mo2::python::detail {
+
+ template <class Type, class SharedType>
+ struct shared_cpp_owner_caster
+ : pybind11::detail::copyable_holder_caster<Type, SharedType> {
+
+ // note that the actual holder type might be different in term of constness
+ using type = Type;
+ using holder_type = SharedType;
+
+ using base = pybind11::detail::copyable_holder_caster<Type, SharedType>;
+ using base::holder;
+ using base::value;
+
+ // in load, we use the default type_caster<> to extract the shared pointer, then
+ // we replace it by a custom one
+ //
+ // the custom shared_ptr<> holds the py::object BUT does not really manage the
+ // C++ object because it will ref-count but not delete it
+ //
+ // this should work because here it's how it works:
+ // - the Python object holds a standard shared_ptr<> for the C++ object -> the
+ // C++ object remains alive as long as the Python one remains alive
+ // - the C++ object holds a shared_ptr<> that manages the python object -> the
+ // Python object remains alive as-long as there is a shared_ptr<> on the C++
+ // side
+ //
+ bool load(pybind11::handle src, bool convert)
+ {
+ namespace py = pybind11;
+
+ if (!base::load(src, convert)) {
+ return false;
+ }
+
+ holder.reset(holder.get(), [pyobj = py::reinterpret_borrow<py::object>(
+ src)](auto*) mutable {
+ py::gil_scoped_acquire s;
+ pyobj = py::object();
+
+ // we do NOT delete the object here - if this was the last reference to
+ // the Python object, the Python object will delete it
+ });
+
+ return true;
+ }
+
+ // cast simply forward to the original type_caster<>
+ //
+ static pybind11::handle cast(const holder_type& src,
+ pybind11::return_value_policy policy,
+ pybind11::handle parent)
+ {
+ return base::cast(src, policy, parent);
+ }
+ };
+
+} // namespace mo2::python::detail
+
+#define MO2_PYBIND11_SHARED_CPP_HOLDER(Type) \
+ namespace pybind11::detail { \
+ template <> \
+ struct type_caster<std::shared_ptr<Type>> \
+ : mo2::python::detail::shared_cpp_owner_caster<Type, \
+ std::shared_ptr<Type>> {}; \
+ template <> \
+ struct type_caster<std::shared_ptr<const Type>> \
+ : mo2::python::detail::shared_cpp_owner_caster< \
+ Type, std::shared_ptr<const Type>> {}; \
+ }
+
+#endif
diff --git a/libs/plugin_python/src/pybind11-utils/include/pybind11_utils/smart_variant.h b/libs/plugin_python/src/pybind11-utils/include/pybind11_utils/smart_variant.h
new file mode 100644
index 0000000..bd7bd9b
--- /dev/null
+++ b/libs/plugin_python/src/pybind11-utils/include/pybind11_utils/smart_variant.h
@@ -0,0 +1,53 @@
+#ifndef PYTHON_PYBIND11_UTILS_SMART_VARIANT_H
+#define PYTHON_PYBIND11_UTILS_SMART_VARIANT_H
+
+#include <variant>
+
+namespace mo2::python {
+
+ namespace detail {
+
+ // simple template class that should be specialized to expose proper fromXXX
+ // methods
+ //
+ template <class T>
+ struct smart_variant_converter {
+ template <class U>
+ static T from(U&& u)
+ {
+ return T{std::forward<U>(u)};
+ }
+ };
+
+ } // namespace detail
+
+ // a smart_variant is a std::variant that can be automatically converted to any of
+ // its type via custom operator T()
+ //
+ // user should specialize detail::smart_variant_converter to provide proper
+ // conversions
+ //
+ template <class... Args>
+ struct smart_variant : std::variant<Args...> {
+ using std::variant<Args...>::variant;
+
+ template <class T, std::enable_if_t<
+ std::disjunction_v<std::is_same<T, Args>...>, int> = 0>
+ operator T() const
+ {
+ return std::visit(
+ [](auto const& t) -> T {
+ if constexpr (std::is_same_v<std::decay_t<decltype(t)>, T>) {
+ return t;
+ }
+ else {
+ return detail::smart_variant_converter<T>::from(t);
+ }
+ },
+ *this);
+ }
+ };
+
+} // namespace mo2::python
+
+#endif
diff --git a/libs/plugin_python/src/pybind11-utils/include/pybind11_utils/smart_variant_wrapper.h b/libs/plugin_python/src/pybind11-utils/include/pybind11_utils/smart_variant_wrapper.h
new file mode 100644
index 0000000..123d95c
--- /dev/null
+++ b/libs/plugin_python/src/pybind11-utils/include/pybind11_utils/smart_variant_wrapper.h
@@ -0,0 +1,180 @@
+#ifndef PYTHON_PYBIND11_UTILS_SMART_VARIANT_WRAPPER_H
+#define PYTHON_PYBIND11_UTILS_SMART_VARIANT_WRAPPER_H
+
+#include <functional>
+#include <type_traits>
+
+#include <pybind11/pybind11.h>
+#include <pybind11/stl.h>
+
+#include "smart_variant.h"
+
+namespace mo2::python {
+
+ namespace detail {
+
+ // simple helper class that expose a ::type attribute which is U is I is in Is,
+ // V otherwise
+ template <class U, class V, std::size_t I, class Is>
+ struct wrap_arg;
+
+ template <class U, class V, std::size_t I, std::size_t... Is>
+ struct wrap_arg<U, V, I, std::index_sequence<Is...>> {
+ using type =
+ std::conditional_t<std::disjunction_v<std::bool_constant<I == Is>...>,
+ U, V>;
+ };
+
+ // helper type for wrap_arg
+ template <class U, class A, std::size_t I, class Is>
+ using wrap_arg_t = typename wrap_arg<U, A, I, Is>::type;
+
+ template <class T, std::size_t... Is, std::size_t... AIs, class Fn, class R,
+ class... Args>
+ auto wrap_arguments_impl(std::index_sequence<Is...>, Fn&& fn, R (*)(Args...),
+ std::index_sequence<AIs...>)
+ {
+ return [fn = std::forward<Fn>(fn)](
+ wrap_arg_t<T, Args, AIs, std::index_sequence<Is...>>... args) {
+ return std::invoke(fn, std::forward<decltype(args)>(args)...);
+ };
+ }
+
+ template <class T, class... Args, std::size_t... Is>
+ auto make_convertible_index_sequence(std::index_sequence<Is...>)
+ {
+ return std::index_sequence<(std::is_convertible_v<T, Args> ? Is : -1)...>{};
+ }
+
+ template <class T, std::size_t... Is, class Fn, class R, class... Args>
+ auto wrap_arguments_impl(Fn&& fn, R (*sg)(Args...))
+ {
+ if constexpr (sizeof...(Is) == 0) {
+ return wrap_arguments_impl<T>(
+ make_convertible_index_sequence<T, Args...>(
+ std::make_index_sequence<sizeof...(Args)>{}),
+ std::forward<Fn>(fn), sg,
+ std::make_index_sequence<sizeof...(Args)>{});
+ }
+ else {
+ return wrap_arguments_impl<T>(
+ std::index_sequence<Is...>{}, std::forward<Fn>(fn), sg,
+ std::make_index_sequence<sizeof...(Args)>{});
+ }
+ }
+
+ template <class T, class Fn, class R, class... Args>
+ auto wrap_return_impl(Fn&& fn, R (*)(Args...))
+ {
+ return [fn = std::forward<Fn>(fn)](Args... args) {
+ return T{std::invoke(fn, std::forward<decltype(args)>(args)...)};
+ };
+ }
+
+ // make_python_function_signature: return a null-pointer with the proper type
+ // for the given function
+
+ template <class Fn>
+ struct function_signature {
+ using type =
+ pybind11::detail::function_signature_t<std::remove_reference_t<Fn>>;
+ };
+
+ template <class R, class... Args>
+ struct function_signature<R (*)(Args...)> {
+ using type = R(Args...);
+ };
+
+ template <class R, class C, class... Args>
+ struct function_signature<R (C::*)(Args...)> {
+ using type = R(C*, Args...);
+ };
+
+ template <class R, class C, class... Args>
+ struct function_signature<R (C::*)(Args...) &> {
+ using type = R(C*, Args...);
+ };
+
+ template <class R, class C, class... Args>
+ struct function_signature<R (C::*)(Args...) const> {
+ using type = R(const C*, Args...);
+ };
+
+ template <class R, class C, class... Args>
+ struct function_signature<R (C::*)(Args...) const&> {
+ using type = R(const C*, Args...);
+ };
+
+ template <class Fn>
+ using function_signature_t = typename function_signature<Fn>::type;
+
+ template <class Type, class... WrappedTypes>
+ class wrap_type_caster {
+ using variant_type = std::variant<WrappedTypes...>;
+ using variant_caster = pybind11::detail::make_caster<variant_type>;
+
+ public:
+ PYBIND11_TYPE_CASTER(Type, variant_caster::name);
+
+ bool load(pybind11::handle src, bool convert)
+ {
+ variant_caster caster;
+
+ if (!caster.load(src, convert)) {
+ return false;
+ }
+
+ value = std::visit(
+ [](auto const& fn) {
+ return Type(fn);
+ },
+ static_cast<variant_type>(caster));
+ return true;
+ }
+
+ static pybind11::handle cast(const Type& src,
+ pybind11::return_value_policy policy,
+ pybind11::handle parent)
+ {
+ return variant_caster::cast(variant_type(std::in_place_index<0>, src),
+ policy, parent);
+ }
+ };
+
+ } // namespace detail
+
+ // wrap the given function-like object to accept T instead of the specified
+ // arguments at the specified positions
+ //
+ // if the list of positions is empty, replace all arguments that can be converted to
+ // T
+ //
+ template <class T, std::size_t... Is, class Fn>
+ auto wrap_arguments(Fn&& fn)
+ {
+ return detail::wrap_arguments_impl<T, Is...>(
+ std::forward<Fn>(fn),
+ (mo2::python::detail::function_signature_t<Fn>*)nullptr);
+ }
+
+ // wrap the given function-like object to return T instead of the specified type
+ //
+ template <class T, class Fn>
+ auto wrap_return(Fn&& fn)
+ {
+ return detail::wrap_return_impl<T>(
+ std::forward<Fn>(fn),
+ (mo2::python::detail::function_signature_t<Fn>*)nullptr);
+ }
+
+} // namespace mo2::python
+
+namespace pybind11::detail {
+
+ template <class... Args>
+ struct type_caster<::mo2::python::smart_variant<Args...>>
+ : variant_caster<::mo2::python::smart_variant<Args...>> {};
+
+} // namespace pybind11::detail
+
+#endif
diff --git a/libs/plugin_python/src/runner/CMakeLists.txt b/libs/plugin_python/src/runner/CMakeLists.txt
new file mode 100644
index 0000000..d164460
--- /dev/null
+++ b/libs/plugin_python/src/runner/CMakeLists.txt
@@ -0,0 +1,34 @@
+cmake_minimum_required(VERSION 3.16)
+
+if(NOT TARGET mo2::uibase)
+ find_package(mo2-uibase CONFIG REQUIRED)
+endif()
+
+add_library(runner SHARED
+ error.h
+ pythonrunner.cpp
+ pythonrunner.h
+ pythonutils.h
+ pythonutils.cpp
+)
+mo2_configure_target(runner
+ NO_SOURCES
+ WARNINGS 4
+ EXTERNAL_WARNINGS 4
+ AUTOMOC ON
+ TRANSLATIONS OFF
+)
+mo2_default_source_group()
+target_link_libraries(runner PUBLIC mo2::uibase PRIVATE pybind11::embed pybind11::qt)
+target_include_directories(runner PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
+target_compile_definitions(runner PRIVATE RUNNER_BUILD)
+if(NOT WIN32 AND Python_SHARED_LIBRARY)
+ get_filename_component(_pylib_name "${Python_SHARED_LIBRARY}" NAME)
+ target_compile_definitions(runner PRIVATE
+ MO2_PYTHON_SHARED_LIBRARY="${_pylib_name}")
+endif()
+
+# proxy will install runner
+
+# force runner to build mobase
+add_dependencies(runner mobase)
diff --git a/libs/plugin_python/src/runner/error.h b/libs/plugin_python/src/runner/error.h
new file mode 100644
index 0000000..341c7ea
--- /dev/null
+++ b/libs/plugin_python/src/runner/error.h
@@ -0,0 +1,82 @@
+#ifndef ERROR_H
+#define ERROR_H
+
+#include <format>
+
+#include <QString>
+
+#include <pybind11/pybind11.h>
+
+#include <uibase/utility.h>
+
+namespace pyexcept {
+
+ /**
+ * @brief Exception to throw when a python implementation does not implement
+ * a pure virtual function.
+ */
+ class MissingImplementation : public MOBase::Exception {
+ public:
+ MissingImplementation(std::string const& className,
+ std::string const& methodName)
+ : Exception(QString::fromStdString(
+ std::format("Python class implementing \"{}\" has no "
+ "implementation of method \"{}\".",
+ className, methodName)))
+ {
+ }
+ };
+
+ /**
+ * @brief Exception to throw when a python error occurs.
+ */
+ class PythonError : public MOBase::Exception {
+ public:
+ /**
+ * @brief Create a new PythonError, fetching the error message from
+ * python. If the message cannot be retrieved, `defaultErrorMessage()`
+ * is used instead.
+ */
+ PythonError(pybind11::error_already_set const& ex) : Exception(ex.what()) {}
+
+ /**
+ * @brief Create a new PythonError with the given message.
+ *
+ * @param message Message for the exception.
+ */
+ PythonError(QString message) : Exception(message) {}
+ };
+
+ /**
+ * @brief Exception to throw when an unknown error occured. This is
+ * typically thrown from a catch(...) block.
+ */
+ class UnknownException : public MOBase::Exception {
+ public:
+ /**
+ * @brief Create a new UnknownException with the default message.
+ *
+ * @see defaultErrorMessage
+ */
+ UnknownException() : Exception(defaultErrorMessage()) {}
+
+ /**
+ * @brief Create a new UnknownException with the given message.
+ *
+ * @param message Message for the exception.
+ */
+ UnknownException(QString message) : Exception(message) {}
+
+ protected:
+ /**
+ *
+ */
+ static QString defaultErrorMessage()
+ {
+ return QObject::tr("An unknown exception was thrown in python code.");
+ }
+ };
+
+} // namespace pyexcept
+
+#endif // ERROR_H
diff --git a/libs/plugin_python/src/runner/pythonrunner.cpp b/libs/plugin_python/src/runner/pythonrunner.cpp
new file mode 100644
index 0000000..05a0f30
--- /dev/null
+++ b/libs/plugin_python/src/runner/pythonrunner.cpp
@@ -0,0 +1,415 @@
+#include "pythonrunner.h"
+
+#ifdef _WIN32
+#pragma warning(disable : 4100)
+#pragma warning(disable : 4996)
+
+#include <Windows.h>
+#else
+#include <dlfcn.h>
+#endif
+
+#include <algorithm>
+#include <cstdlib>
+#include <optional>
+
+#include <QCoreApplication>
+#include <QDir>
+#include <QFile>
+
+#include "pybind11_qt/pybind11_qt.h"
+#include <pybind11/embed.h>
+#include <pybind11/functional.h>
+#include <pybind11/stl/filesystem.h>
+
+#include <uibase/log.h>
+#include <uibase/utility.h>
+
+#include "error.h"
+#include "pythonutils.h"
+
+using namespace MOBase;
+namespace py = pybind11;
+
+namespace mo2::python {
+
+ /**
+ *
+ */
+ class PythonRunner : public IPythonRunner {
+
+ public:
+ PythonRunner() = default;
+ ~PythonRunner() = default;
+
+ QList<QObject*> load(const QString& identifier) override;
+ void unload(const QString& identifier) override;
+
+ bool initialize(std::vector<std::filesystem::path> const& pythonPaths) override;
+ void addDllSearchPath(std::filesystem::path const& dllPath) override;
+ bool isInitialized() const override;
+
+ private:
+ /**
+ * @brief Ensure that the given folder is in sys.path.
+ */
+ void ensureFolderInPath(QString folder);
+
+ private:
+ // for each "identifier" (python file or python module folder), contains the
+ // list of python objects - this does not keep the objects alive, it simply used
+ // to unload plugins
+ std::unordered_map<QString, std::vector<py::handle>> m_PythonObjects;
+ };
+
+ std::unique_ptr<IPythonRunner> createPythonRunner()
+ {
+ return std::make_unique<PythonRunner>();
+ }
+
+ bool PythonRunner::initialize(std::vector<std::filesystem::path> const& pythonPaths)
+ {
+ // 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, Python is not re-initialized
+ //
+ // in an ideal world, we would initialize Python here (or in the constructor)
+ // and then finalize it in the destructor
+ //
+ // unfortunately, many library, including PyQt6, do not handle properly
+ // re-initializing the Python interpreter, so we cannot do that and we keep the
+ // interpreter alive
+ //
+
+ if (Py_IsInitialized()) {
+ return true;
+ }
+
+ std::optional<QByteArray> oldPythonHome;
+ std::optional<QByteArray> oldPythonPath;
+ auto restorePythonEnv = [&]() {
+ if (oldPythonHome.has_value()) {
+ setenv("PYTHONHOME", oldPythonHome->constData(), 1);
+ } else {
+ unsetenv("PYTHONHOME");
+ }
+ if (oldPythonPath.has_value()) {
+ setenv("PYTHONPATH", oldPythonPath->constData(), 1);
+ } else {
+ unsetenv("PYTHONPATH");
+ }
+ };
+
+ try {
+ static const char* argv0 = "ModOrganizer.exe";
+
+#ifndef _WIN32
+#ifdef MO2_PYTHON_SHARED_LIBRARY
+ // Ensure libpython symbols are globally visible for extension modules
+ // loaded later (_struct, PyQt6, etc.).
+ void* pyHandle =
+ dlopen(MO2_PYTHON_SHARED_LIBRARY, RTLD_NOW | RTLD_GLOBAL | RTLD_NOLOAD);
+ if (pyHandle == nullptr) {
+ pyHandle = dlopen(MO2_PYTHON_SHARED_LIBRARY, RTLD_NOW | RTLD_GLOBAL);
+ }
+ if (pyHandle == nullptr) {
+ MOBase::log::warn("failed to dlopen python shared library '{}': {}",
+ MO2_PYTHON_SHARED_LIBRARY, dlerror());
+ }
+#endif
+#endif
+
+ // For portable/AppImage builds, set PYTHONHOME so the interpreter
+ // finds the bundled stdlib instead of looking at system paths.
+ // MO2_PYTHON_DIR (set by AppRun) points to the writable python/
+ // dir next to the AppImage; fall back to <exe_dir>/python.
+ QString pythonHome;
+ const char* envPy = std::getenv("MO2_PYTHON_DIR");
+ if (envPy && envPy[0] != '\0') {
+ pythonHome = QString::fromUtf8(envPy);
+ } else {
+ pythonHome = QCoreApplication::applicationDirPath() + "/python";
+ }
+ if (const char* v = std::getenv("PYTHONHOME"); v != nullptr) {
+ oldPythonHome = QByteArray(v);
+ }
+ if (const char* v = std::getenv("PYTHONPATH"); v != nullptr) {
+ oldPythonPath = QByteArray(v);
+ }
+
+ if (QDir(pythonHome).exists()) {
+ setenv("PYTHONHOME", pythonHome.toUtf8().constData(), 1);
+
+ const QDir libDir(pythonHome + "/lib");
+ const auto pyDirs =
+ libDir.entryList({"python3.*"}, QDir::Dirs | QDir::NoDotAndDotDot);
+ if (!pyDirs.isEmpty()) {
+ const QString pyver = pyDirs.first();
+ const QString pyPath = QString("%1/lib/%2:%1/lib/%2/site-packages:%1")
+ .arg(pythonHome, pyver);
+ setenv("PYTHONPATH", pyPath.toUtf8().constData(), 1);
+ }
+ }
+
+ // Paths we want to prepend/append for MO2 plugin loading.
+ auto paths = pythonPaths;
+
+ PyConfig config;
+ PyConfig_InitPythonConfig(&config);
+
+ // from PyBind11
+ config.parse_argv = 0;
+ config.install_signal_handlers = 0;
+
+ // from MO2
+ config.site_import = 1;
+ config.optimization_level = 2;
+
+ py::initialize_interpreter(&config, 1, &argv0, true);
+
+ // Restore process environment after interpreter startup so
+ // subprocesses (umu/NaK/tools) are not forced onto MO2's Python.
+ restorePythonEnv();
+
+ if (!Py_IsInitialized()) {
+ MOBase::log::error(
+ "failed to init python: failed to initialize interpreter.");
+
+ if (PyGILState_Check()) {
+ PyEval_SaveThread();
+ }
+
+ return false;
+ }
+
+ {
+ for (auto const& path : paths) {
+ ensureFolderInPath(QString::fromStdString(absolute(path).string()));
+ }
+
+ py::module_ mainModule = py::module_::import("__main__");
+ py::object mainNamespace = mainModule.attr("__dict__");
+ mainNamespace["sys"] = py::module_::import("sys");
+ mainNamespace["mobase"] = py::module_::import("mobase");
+
+ mo2::python::configure_python_stream();
+ mo2::python::configure_python_logging(mainNamespace["mobase"]);
+ }
+
+ // we need to release the GIL here - which is what this does
+ //
+ // when Python is initialized, the GIl is acquired, and if it is not
+ // release, trying to acquire it on a different thread will deadlock
+ PyEval_SaveThread();
+
+ return true;
+ }
+ catch (const py::error_already_set& ex) {
+ restorePythonEnv();
+ MOBase::log::error("failed to init python: {}", ex.what());
+ return false;
+ }
+ }
+
+ void PythonRunner::addDllSearchPath(std::filesystem::path const& dllPath)
+ {
+ py::gil_scoped_acquire lock;
+#ifdef _WIN32
+ py::module_::import("os").attr("add_dll_directory")(absolute(dllPath));
+#else
+ // On Linux, there is no add_dll_directory equivalent; prepend the folder to
+ // sys.path so Python extension modules can be found.
+ ensureFolderInPath(QString::fromStdString(absolute(dllPath).string()));
+#endif
+ }
+
+ void PythonRunner::ensureFolderInPath(QString folder)
+ {
+ py::module_ sys = py::module_::import("sys");
+ py::list sysPath = sys.attr("path");
+
+ // Converting to QStringList for Qt::CaseInsensitive and because .index()
+ // raise an exception:
+ const QStringList currentPath = sysPath.cast<QStringList>();
+ if (!currentPath.contains(folder, Qt::CaseInsensitive)) {
+ sysPath.insert(0, folder);
+ }
+ }
+
+ QList<QObject*> PythonRunner::load(const QString& identifier)
+ {
+ py::gil_scoped_acquire lock;
+
+ const QFileInfo idInfo(identifier);
+ const QString baseName = idInfo.fileName();
+ if (baseName == "winreg.py" || baseName == "lzokay.py") {
+ log::debug("Skipping Python compatibility shim '{}'.", identifier);
+ return {};
+ }
+
+ // `pluginName` can either be a python file (single-file plugin or a folder
+ // (whole module).
+ //
+ // For whole module, we simply add the parent folder to path, then we load
+ // the module with a simple py::import, and we retrieve the associated
+ // __dict__ from which we extract either createPlugin or createPlugins.
+ //
+ // For single file, we need to use py::eval_file, and we will use the
+ // context (global variables) from __main__ (already contains mobase, and
+ // other required module). Since the context is shared between called of
+ // `instantiate`, we need to make sure to remove createPlugin(s) from
+ // previous call.
+ try {
+
+ // dictionary that will contain createPlugin() or createPlugins().
+ py::dict moduleDict;
+
+ if (identifier.endsWith(".py")) {
+ py::object mainModule = py::module_::import("__main__");
+
+ // make a copy, otherwise we might end up calling the createPlugin() or
+ // createPlugins() function multiple time
+ py::dict moduleNamespace = mainModule.attr("__dict__").attr("copy")();
+
+ std::string temp = ToString(identifier);
+ py::eval_file(temp, moduleNamespace).is_none();
+ moduleDict = moduleNamespace;
+ }
+ else {
+ // Retrieve the module name:
+ QStringList parts = identifier.split("/");
+ std::string moduleName = ToString(parts.takeLast());
+ ensureFolderInPath(parts.join("/"));
+
+ // check if the module is already loaded
+ py::dict modules = py::module_::import("sys").attr("modules");
+ if (modules.contains(moduleName)) {
+ py::module_ prev = modules[py::str(moduleName)];
+ py::module_(prev).reload();
+ moduleDict = prev.attr("__dict__");
+ }
+ else {
+ moduleDict =
+ py::module_::import(moduleName.c_str()).attr("__dict__");
+ }
+ }
+
+ if (py::len(moduleDict) == 0) {
+ MOBase::log::error("No plugins found in {}.", identifier);
+ return {};
+ }
+
+ // Create the plugins:
+ std::vector<py::object> plugins;
+
+ if (moduleDict.contains("createPlugin")) {
+ plugins.push_back(moduleDict["createPlugin"]());
+ }
+ else if (moduleDict.contains("createPlugins")) {
+ py::object pyPlugins = moduleDict["createPlugins"]();
+ if (!py::isinstance<py::sequence>(pyPlugins)) {
+ MOBase::log::error(
+ "Plugin {}: createPlugins must return a sequence.", identifier);
+ }
+ else {
+ py::sequence pyList(pyPlugins);
+ size_t nPlugins = pyList.size();
+ for (size_t i = 0; i < nPlugins; ++i) {
+ plugins.push_back(pyList[i]);
+ }
+ }
+ }
+ else {
+ MOBase::log::error("Plugin {}: missing a createPlugin(s) function.",
+ identifier);
+ }
+
+ // If we have no plugins, there was an issue, and we already logged the
+ // problem:
+ if (plugins.empty()) {
+ return QList<QObject*>();
+ }
+
+ QList<QObject*> allInterfaceList;
+
+ for (py::object pluginObj : plugins) {
+
+ // save to be able to unload it
+ m_PythonObjects[identifier].push_back(pluginObj);
+
+ QList<QObject*> interfaceList = py::module_::import("mobase.private")
+ .attr("extract_plugins")(pluginObj)
+ .cast<QList<QObject*>>();
+
+ if (interfaceList.isEmpty()) {
+ MOBase::log::error("Plugin {}: no plugin interface implemented.",
+ identifier);
+ }
+
+ // Append the plugins to the main list:
+ allInterfaceList.append(interfaceList);
+ }
+
+ return allInterfaceList;
+ }
+ catch (const py::error_already_set& ex) {
+ MOBase::log::error("Failed to import plugin from {}.", identifier);
+ throw pyexcept::PythonError(ex);
+ }
+ }
+
+ void PythonRunner::unload(const QString& identifier)
+ {
+ auto it = m_PythonObjects.find(identifier);
+ if (it != m_PythonObjects.end()) {
+
+ py::gil_scoped_acquire lock;
+
+ if (!identifier.endsWith(".py")) {
+
+ // At this point, the identifier is the full path to the module.
+ QDir folder(identifier);
+
+ // We want to "unload" (remove from sys.modules) modules that come
+ // from this plugin (whose __path__ points under this module,
+ // including the module of the plugin itself).
+ py::object sys = py::module_::import("sys");
+ py::dict modules = sys.attr("modules");
+ py::list keys = modules.attr("keys")();
+ for (std::size_t i = 0; i < py::len(keys); ++i) {
+ py::object mod = modules[keys[i]];
+ if (PyObject_HasAttrString(mod.ptr(), "__path__")) {
+ QString mpath =
+ mod.attr("__path__")[py::int_(0)].cast<QString>();
+
+ if (!folder.relativeFilePath(mpath).startsWith("..")) {
+ // If the path is under identifier, we need to unload
+ // it.
+ log::debug("Unloading module {} from {} for {}.",
+ keys[i].cast<std::string>(), mpath, identifier);
+
+ PyDict_DelItem(modules.ptr(), keys[i].ptr());
+ }
+ }
+ }
+ }
+
+ // Boost.Python does not handle cyclic garbace collection, so we need to
+ // release everything hold by the objects before deleting the objects
+ // themselves (done when erasing from m_PythonObjects).
+ for (auto& obj : it->second) {
+ obj.attr("__dict__").attr("clear")();
+ }
+
+ log::debug("Deleting {} python objects for {}.", it->second.size(),
+ identifier);
+ m_PythonObjects.erase(it);
+ }
+ }
+
+ bool PythonRunner::isInitialized() const
+ {
+ return Py_IsInitialized() != 0;
+ }
+
+} // namespace mo2::python
diff --git a/libs/plugin_python/src/runner/pythonrunner.h b/libs/plugin_python/src/runner/pythonrunner.h
new file mode 100644
index 0000000..5f9751b
--- /dev/null
+++ b/libs/plugin_python/src/runner/pythonrunner.h
@@ -0,0 +1,53 @@
+#ifndef PYTHONRUNNER_H
+#define PYTHONRUNNER_H
+
+#include <filesystem>
+#include <memory>
+
+#include <QList>
+#include <QObject>
+#include <QString>
+#include <QStringList>
+
+#ifdef RUNNER_BUILD
+#define RUNNER_DLL_EXPORT Q_DECL_EXPORT
+#else
+#define RUNNER_DLL_EXPORT Q_DECL_IMPORT
+#endif
+
+namespace mo2::python {
+
+ // python runner interface
+ //
+ class IPythonRunner {
+ public:
+ virtual QList<QObject*> load(const QString& identifier) = 0;
+ virtual void unload(const QString& identifier) = 0;
+
+ // initialize Python
+ //
+ // pythonPaths 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::vector<std::filesystem::path> const& pythonPaths = {}) = 0;
+
+ // add a DLL search path
+ //
+ virtual void addDllSearchPath(std::filesystem::path const& dllPath) = 0;
+
+ // check if the runner has been initialized, i.e., initialize() has been
+ // called and succeeded
+ virtual bool isInitialized() const = 0;
+
+ virtual ~IPythonRunner() {}
+ };
+
+ // create the Python runner
+ //
+ RUNNER_DLL_EXPORT std::unique_ptr<IPythonRunner> createPythonRunner();
+
+} // namespace mo2::python
+
+#endif // PYTHONRUNNER_H
diff --git a/libs/plugin_python/src/runner/pythonutils.cpp b/libs/plugin_python/src/runner/pythonutils.cpp
new file mode 100644
index 0000000..c94a50b
--- /dev/null
+++ b/libs/plugin_python/src/runner/pythonutils.cpp
@@ -0,0 +1,155 @@
+#include "pythonutils.h"
+
+#include <filesystem>
+#include <set>
+#include <sstream>
+
+#include <pybind11/eval.h>
+#include <pybind11/pybind11.h>
+
+#include <uibase/log.h>
+
+namespace py = pybind11;
+
+namespace mo2::python {
+
+ class PrintWrapper {
+ MOBase::log::Levels level_;
+ std::stringstream buffer_;
+
+ public:
+ PrintWrapper(MOBase::log::Levels level) : level_{level} {}
+
+ void write(std::string_view message)
+ {
+ buffer_ << message;
+ if (buffer_.tellp() != 0 && buffer_.str().back() == '\n') {
+ const auto full_message = buffer_.str();
+ MOBase::log::log(level_, "{}",
+ full_message.substr(0, full_message.length() - 1));
+ buffer_ = std::stringstream{};
+ }
+ }
+ };
+
+ /**
+ * @brief Construct a dynamic Python type.
+ *
+ */
+ template <class... Args>
+ pybind11::object make_python_type(std::string_view name,
+ pybind11::tuple base_classes, Args&&... args)
+ {
+ // this is ugly but that's how it's done in C Python
+ auto type = py::reinterpret_borrow<py::object>((PyObject*)&PyType_Type);
+
+ // create the python class
+ return type(name, base_classes, py::dict(std::forward<Args>(args)...));
+ }
+
+ void configure_python_stream()
+ {
+ // create the "MO2Handler" python class
+ auto printWrapper = make_python_type(
+ "MO2PrintWrapper", py::make_tuple(),
+ py::arg("write") = py::cpp_function([](std::string_view message) {
+ static PrintWrapper wrapper(MOBase::log::Debug);
+ wrapper.write(message);
+ }),
+ py::arg("flush") = py::cpp_function([] {}));
+ auto errorWrapper = make_python_type(
+ "MO2ErrorWrapper", py::make_tuple(),
+ py::arg("write") = py::cpp_function([](std::string_view message) {
+ static PrintWrapper wrapper(MOBase::log::Error);
+ wrapper.write(message);
+ }),
+ py::arg("flush") = py::cpp_function([] {}));
+ py::module_ sys = py::module_::import("sys");
+ sys.attr("stdout") = printWrapper();
+ sys.attr("stderr") = errorWrapper();
+
+ // this is required to handle exception in Python code OUTSIDE of pybind11 call,
+ // typically on Qt classes with methods overridden on the Python side
+ //
+ // without this, the application will crash instead of properly handling the
+ // exception as it would do with a py::error_already_set{}
+ //
+ // IMPORTANT: sys.attr("excepthook") = sys.attr("__excepthook__") DOES NOT WORK,
+ // and I have no clue why since the attribute does not seem to get updated (at
+ // least a print does not show it)
+ //
+ sys.attr("excepthook") =
+ py::eval("lambda x, y, z: sys.__excepthook__(x, y, z)");
+ }
+
+ // Small structure to hold the levels - There are copy paste from
+ // my Python version and I assume these will not change soon:
+ struct PyLogLevel {
+ static constexpr int CRITICAL = 50;
+ static constexpr int ERROR = 40;
+ static constexpr int WARNING = 30;
+ static constexpr int INFO = 20;
+ static constexpr int DEBUG = 10;
+ };
+
+ // This is the function we are going to use as our Handler .emit
+ // method.
+ void emit_function(py::object record)
+ {
+
+ // There are other parameters that could be used, but this is minimal
+ // for now (filename, line number, etc.).
+ const int level = record.attr("levelno").cast<int>();
+ const std::wstring msg = py::str(record.attr("msg")).cast<std::wstring>();
+
+ switch (level) {
+ case PyLogLevel::CRITICAL:
+ case PyLogLevel::ERROR:
+ MOBase::log::error("{}", msg);
+ break;
+ case PyLogLevel::WARNING:
+ MOBase::log::warn("{}", msg);
+ break;
+ case PyLogLevel::INFO:
+ MOBase::log::info("{}", msg);
+ break;
+ case PyLogLevel::DEBUG:
+ default: // There is a "NOTSET" level in theory:
+ MOBase::log::debug("{}", msg);
+ break;
+ }
+ };
+
+ void configure_python_logging(py::module_ mobase)
+ {
+ // most of this is dealing with actual Python objects since it is not
+ // possible to derive from logging.Handler in C++ using pybind11,
+ // and since a lot of this would require extra register only for this.
+
+ // see also
+ // https://github.com/pybind/pybind11/issues/1193#issuecomment-429451094
+
+ // retrieve the logging module and the Handler class.
+ auto logging = py::module_::import("logging");
+ auto Handler = logging.attr("Handler");
+
+ // create the "MO2Handler" python class
+ auto MO2Handler =
+ make_python_type("LogHandler", py::make_tuple(Handler),
+ py::arg("emit") = py::cpp_function(emit_function));
+
+ // create the default logger
+ auto handler = MO2Handler();
+ handler.attr("setLevel")(PyLogLevel::DEBUG);
+ auto logger = logging.attr("getLogger")(py::object(mobase.attr("__name__")));
+ logger.attr("setLevel")(PyLogLevel::DEBUG);
+
+ // set mobase attributes
+ mobase.attr("LogHandler") = MO2Handler;
+ mobase.attr("logger") = logger;
+
+ logging.attr("root").attr("setLevel")(PyLogLevel::DEBUG);
+ logging.attr("root").attr("addHandler")(handler);
+ }
+
+} // namespace mo2::python
diff --git a/libs/plugin_python/src/runner/pythonutils.h b/libs/plugin_python/src/runner/pythonutils.h
new file mode 100644
index 0000000..019e782
--- /dev/null
+++ b/libs/plugin_python/src/runner/pythonutils.h
@@ -0,0 +1,25 @@
+#ifndef PYTHONRUNNER_UTILS_H
+#define PYTHONRUNNER_UTILS_H
+
+#include <string_view>
+
+#include <pybind11/pybind11.h>
+
+namespace mo2::python {
+
+ /**
+ * @brief Configure Python stdout and stderr to log to MO2.
+ *
+ */
+ void configure_python_stream();
+
+ /**
+ * @brief Configure logging for MO2 python plugin.
+ *
+ * @param mobase The mobase module.
+ */
+ void configure_python_logging(pybind11::module_ mobase);
+
+} // namespace mo2::python
+
+#endif
diff --git a/libs/plugin_python/tests/CMakeLists.txt b/libs/plugin_python/tests/CMakeLists.txt
new file mode 100644
index 0000000..0141184
--- /dev/null
+++ b/libs/plugin_python/tests/CMakeLists.txt
@@ -0,0 +1,4 @@
+cmake_minimum_required(VERSION 3.16)
+
+add_subdirectory(python)
+add_subdirectory(runner)
diff --git a/libs/plugin_python/tests/mocks/DummyFileTree.h b/libs/plugin_python/tests/mocks/DummyFileTree.h
new file mode 100644
index 0000000..cf81691
--- /dev/null
+++ b/libs/plugin_python/tests/mocks/DummyFileTree.h
@@ -0,0 +1,34 @@
+#ifndef DUMMY_TREE_H
+#define DUMMY_TREE_H
+
+#include <uibase/ifiletree.h>
+
+// filetree implementation for testing purpose
+//
+class DummyFileTree : public MOBase::IFileTree {
+public:
+ DummyFileTree(std::shared_ptr<const IFileTree> parent, QString name)
+ : FileTreeEntry(parent, name), IFileTree()
+ {
+ }
+
+protected:
+ std::shared_ptr<IFileTree> makeDirectory(std::shared_ptr<const IFileTree> parent,
+ QString name) const override
+ {
+ return std::make_shared<DummyFileTree>(parent, name);
+ }
+
+ bool doPopulate(std::shared_ptr<const IFileTree>,
+ std::vector<std::shared_ptr<FileTreeEntry>>&) const override
+ {
+ return true;
+ }
+
+ std::shared_ptr<IFileTree> doClone() const override
+ {
+ return std::make_shared<DummyFileTree>(nullptr, name());
+ }
+};
+
+#endif
diff --git a/libs/plugin_python/tests/mocks/MockOrganizer.h b/libs/plugin_python/tests/mocks/MockOrganizer.h
new file mode 100644
index 0000000..3ccfa3c
--- /dev/null
+++ b/libs/plugin_python/tests/mocks/MockOrganizer.h
@@ -0,0 +1,65 @@
+#include <gmock/gmock.h>
+#include <uibase/imoinfo.h>
+
+using namespace MOBase;
+
+class MockOrganizer : public IOrganizer {
+public:
+ // clang-format off
+ MOCK_METHOD(IModRepositoryBridge*, createNexusBridge, (), (const, override));
+ MOCK_METHOD(QString, instanceName, (), (const, override));
+ MOCK_METHOD(QString, profileName, (), (const, override));
+ MOCK_METHOD(QString, profilePath, (), (const, override));
+ MOCK_METHOD(QString, downloadsPath, (), (const, override));
+ MOCK_METHOD(QString, overwritePath, (), (const, override));
+ MOCK_METHOD(QString, basePath, (), (const, override));
+ MOCK_METHOD(QString, modsPath, (), (const, override));
+ MOCK_METHOD(VersionInfo, appVersion, (), (const, override));
+ MOCK_METHOD(Version, version, (), (const, override));
+ MOCK_METHOD(IModInterface*, createMod, (GuessedValue<QString> &name), (override));
+ MOCK_METHOD(IPluginGame*, getGame, (const QString &gameName), (const, override));
+ MOCK_METHOD(void, modDataChanged, (IModInterface *mod), (override));
+ MOCK_METHOD(QVariant, pluginSetting, (const QString &pluginName, const QString &key), (const, override));
+ MOCK_METHOD(void, setPluginSetting, (const QString &pluginName, const QString &key, const QVariant &value), (override));
+ MOCK_METHOD(bool, isPluginEnabled, (const QString& pluginName), (const, override));
+ MOCK_METHOD(bool, isPluginEnabled, (IPlugin *plugin), (const, override));
+ MOCK_METHOD(QVariant, persistent, (const QString &pluginName, const QString &key, const QVariant &def), (const, override));
+ MOCK_METHOD(void, setPersistent, (const QString &pluginName, const QString &key, const QVariant &value, bool sync), (override));
+ MOCK_METHOD(QString, pluginDataPath, (), (const, override));
+ MOCK_METHOD(IModInterface*, installMod, (const QString &fileName, const QString &nameSuggestion), (override));
+ MOCK_METHOD(QString, resolvePath, (const QString &fileName), (const, override));
+ MOCK_METHOD(QStringList, listDirectories, (const QString &directoryName), (const, override));
+ MOCK_METHOD(QStringList, findFiles, (const QString &path, const std::function<bool(const QString&)> &filter), (const, override));
+ MOCK_METHOD(QStringList, findFiles, (const QString &path, const QStringList &filter), (const, override));
+ MOCK_METHOD(QStringList, getFileOrigins, (const QString &fileName) ,(const, override));
+ MOCK_METHOD(QList<FileInfo>, findFileInfos, (const QString &path, const std::function<bool(const FileInfo&)> &filter), (const, override));
+ MOCK_METHOD(std::shared_ptr<const IFileTree>, virtualFileTree, (), (const, override));
+ MOCK_METHOD(MOBase::IInstanceManager*, instanceManager, (), (const, override));
+ MOCK_METHOD(MOBase::IDownloadManager*, downloadManager, (), (const, override));
+ MOCK_METHOD(MOBase::IPluginList*, pluginList, (), (const, override));
+ MOCK_METHOD(MOBase::IModList*, modList, (), (const, override));
+ MOCK_METHOD(MOBase::IExecutablesList*, executablesList, (), (const, override));
+ MOCK_METHOD(std::shared_ptr<MOBase::IProfile>, profile, (), (const, override));
+ MOCK_METHOD(QStringList, profileNames, (), (const, override));
+ MOCK_METHOD(std::shared_ptr<const MOBase::IProfile>, getProfile, (const QString& name), (const, override));
+ MOCK_METHOD(MOBase::IGameFeatures*, gameFeatures, (), (const, override));
+ MOCK_METHOD(HANDLE, startApplication, (const QString &executable, const QStringList &args, const QString &cwd, const QString &profile, const QString &forcedCustomOverwrite, bool ignoreCustomOverwrite), (override));
+ MOCK_METHOD(bool, waitForApplication, (HANDLE handle, bool refresh, LPDWORD exitCode), (const, override));
+ MOCK_METHOD(bool, onAboutToRun, (const std::function<bool(const QString&)> &func), (override));
+ MOCK_METHOD(bool, onAboutToRun, (const std::function<bool(const QString&, const QDir&, const QString&)> &func), (override));
+ MOCK_METHOD(bool, onFinishedRun, (const std::function<void(const QString&, unsigned int)> &func), (override));
+ MOCK_METHOD(void, refresh, (bool saveChanges), (override));
+ MOCK_METHOD(MOBase::IPluginGame const *, managedGame, (), (const, override));
+ MOCK_METHOD(bool, onUserInterfaceInitialized, (const std::function<void (QMainWindow *)> &), (override));
+ MOCK_METHOD(bool, onNextRefresh, (const std::function<void()>&, bool), (override));
+ MOCK_METHOD(bool, onProfileCreated, (const std::function<void(MOBase::IProfile*)>&), (override));
+ MOCK_METHOD(bool, onProfileRemoved, (const std::function<void(const QString&)>&), (override));
+ MOCK_METHOD(bool, onProfileRenamed, (const std::function<void(MOBase::IProfile*, QString const&, QString const&)>&), (override));
+ MOCK_METHOD(bool, onProfileChanged, (const std::function<void (MOBase::IProfile*, MOBase::IProfile*)> &), (override));
+ MOCK_METHOD(bool, onPluginSettingChanged, (const std::function<void (const QString &,const QString &,const QVariant &,const QVariant &)> &), (override));
+ MOCK_METHOD(bool, onPluginEnabled, (const std::function<void(const MOBase::IPlugin*)>&), (override));
+ MOCK_METHOD(bool, onPluginEnabled, (const QString&, const std::function<void()>&), (override));
+ MOCK_METHOD(bool, onPluginDisabled, (const std::function<void(const MOBase::IPlugin*)>&), (override));
+ MOCK_METHOD(bool, onPluginDisabled, (const QString&, const std::function<void()>&), (override));
+ // clang-format on
+};
diff --git a/libs/plugin_python/tests/python/CMakeLists.txt b/libs/plugin_python/tests/python/CMakeLists.txt
new file mode 100644
index 0000000..f473373
--- /dev/null
+++ b/libs/plugin_python/tests/python/CMakeLists.txt
@@ -0,0 +1,75 @@
+cmake_minimum_required(VERSION 3.16)
+
+# pytest
+cmake_policy(SET CMP0144 NEW)
+
+find_package(mo2-uibase CONFIG REQUIRED)
+find_package(GTest REQUIRED)
+
+set(PYLIB_DIR ${CMAKE_CURRENT_BINARY_DIR}/pylibs)
+
+set(UIBASE_PATH $<TARGET_FILE_DIR:mo2::uibase>)
+
+add_custom_target(python-tests)
+target_sources(python-tests
+ PRIVATE
+ conftest.py
+ test_argument_wrapper.py
+ test_filetree.py
+ test_functional.py
+ test_guessed_string.py
+ test_organizer.py
+ test_path_wrappers.py
+ test_qt_widgets.py
+ test_qt.py
+ test_shared_cpp_owner.py
+)
+
+add_test(NAME pytest
+ COMMAND ${CMAKE_CURRENT_BINARY_DIR}/pylibs/bin/pytest.exe ${CMAKE_CURRENT_SOURCE_DIR} -s
+)
+
+set_tests_properties(pytest
+ PROPERTIES
+ DEPENDS python-tests
+ ENVIRONMENT_MODIFICATION
+ "PYTHONPATH=set:${PYLIB_DIR}\\;$<TARGET_FILE_DIR:mobase>;\
+UIBASE_PATH=set:${UIBASE_PATH}"
+)
+
+mo2_python_pip_install(python-tests
+ DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/pylibs
+ PACKAGES pytest
+ PyQt${MO2_QT_VERSION_MAJOR}==${MO2_PYQT_VERSION}
+ PyQt${MO2_QT_VERSION_MAJOR}-Qt${MO2_QT_VERSION_MAJOR}==${MO2_QT_VERSION})
+add_dependencies(python-tests mobase)
+set_target_properties(python-tests PROPERTIES FOLDER tests/python)
+
+file(GLOB test_files CONFIGURE_DEPENDS "test_*.cpp")
+foreach (test_file ${test_files})
+ get_filename_component(target ${test_file} NAME_WLE)
+
+ string(REPLACE "test_" "" pymodule ${target})
+ pybind11_add_module(${target} EXCLUDE_FROM_ALL THIN_LTO ${test_file})
+ set_target_properties(${target}
+ PROPERTIES
+ CXX_STANDARD 23
+ OUTPUT_NAME ${pymodule}
+ FOLDER tests/python
+ LIBRARY_OUTPUT_DIRECTORY "${PYLIB_DIR}/mobase_tests")
+
+ if(DEFINED CMAKE_CONFIGURATION_TYPES)
+ foreach(config ${CMAKE_CONFIGURATION_TYPES})
+ string(TOUPPER ${config} config)
+ set_target_properties(${target} PROPERTIES
+ LIBRARY_OUTPUT_DIRECTORY_${config} "${PYLIB_DIR}/mobase_tests")
+ endforeach()
+ endif()
+
+ target_link_libraries(${target} PRIVATE
+ mo2::uibase Qt6::Core Qt6::Widgets pybind11::qt pybind11::utils GTest::gmock)
+
+ target_include_directories(${target}
+ PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../mocks)
+ add_dependencies(python-tests ${target})
+endforeach()
diff --git a/libs/plugin_python/tests/python/conftest.py b/libs/plugin_python/tests/python/conftest.py
new file mode 100644
index 0000000..99e7800
--- /dev/null
+++ b/libs/plugin_python/tests/python/conftest.py
@@ -0,0 +1,12 @@
+import os
+import sys
+
+from PyQt6.QtWidgets import QApplication
+
+
+def pytest_configure():
+ global app
+
+ os.add_dll_directory(str(os.getenv("UIBASE_PATH")))
+
+ app = QApplication(sys.argv)
diff --git a/libs/plugin_python/tests/python/test_argument_wrapper.cpp b/libs/plugin_python/tests/python/test_argument_wrapper.cpp
new file mode 100644
index 0000000..e87f516
--- /dev/null
+++ b/libs/plugin_python/tests/python/test_argument_wrapper.cpp
@@ -0,0 +1,61 @@
+#include "pybind11_utils/smart_variant_wrapper.h"
+
+#include <pybind11/pybind11.h>
+#include <pybind11/stl.h>
+
+#include <string>
+
+namespace mo2::python::detail {
+
+ template <>
+ struct smart_variant_converter<std::string> {
+ static std::string from(int const& value) { return std::to_string(value); }
+ };
+
+ template <>
+ struct smart_variant_converter<int> {
+ static int from(std::string const& value) { return std::stoi(value); }
+ };
+
+} // namespace mo2::python::detail
+
+// wrapper that can be constructed from
+using Wrapper = mo2::python::smart_variant<int, std::string>;
+
+template <std::size_t... Is, class Fn>
+auto wrap(Fn&& fn)
+{
+ return mo2::python::wrap_arguments<Wrapper, Is...>(std::forward<Fn>(fn));
+}
+
+std::string fn1(std::string const& value)
+{
+ return value + "-" + value;
+}
+
+int fn2(int value)
+{
+ return value * 2;
+}
+
+std::string fn3(int value, std::vector<int> values, std::string const& name)
+{
+ return name + "-" + std::to_string(value + values.size());
+}
+
+PYBIND11_MODULE(argument_wrapper, m)
+{
+ m.def("fn1_raw", &fn1);
+ m.def("fn1_wrap", wrap(&fn1));
+ m.def("fn1_wrap_0", wrap<0>(&fn1));
+
+ m.def("fn2_raw", &fn2);
+ m.def("fn2_wrap", wrap(&fn2));
+ m.def("fn2_wrap_0", wrap<0>(&fn2));
+
+ m.def("fn3_raw", &fn3);
+ m.def("fn3_wrap", wrap(&fn3));
+ m.def("fn3_wrap_0", wrap<0>(&fn3));
+ m.def("fn3_wrap_2", wrap<2>(&fn3));
+ m.def("fn3_wrap_0_2", wrap<0, 2>(&fn3));
+}
diff --git a/libs/plugin_python/tests/python/test_argument_wrapper.py b/libs/plugin_python/tests/python/test_argument_wrapper.py
new file mode 100644
index 0000000..08b4a1b
--- /dev/null
+++ b/libs/plugin_python/tests/python/test_argument_wrapper.py
@@ -0,0 +1,62 @@
+import pytest
+
+import mobase_tests.argument_wrapper as m
+
+
+def test_argument_wrapper_fn1():
+ assert m.fn1_raw("hello") == "hello-hello"
+
+ with pytest.raises(TypeError):
+ m.fn1_raw(1) # pyright: ignore[reportArgumentType]
+
+ assert m.fn1_wrap("hello") == "hello-hello"
+ assert m.fn1_wrap(32) == "32-32"
+
+ assert m.fn1_wrap_0("world") == "world-world"
+ assert m.fn1_wrap_0(45) == "45-45"
+
+
+def test_argument_wrapper_fn2():
+ assert m.fn2_raw(33) == 66
+
+ with pytest.raises(TypeError):
+ m.fn2_raw("12") # pyright: ignore[reportArgumentType]
+
+ assert m.fn2_wrap("15") == 30
+ assert m.fn2_wrap(32) == 64
+
+ assert m.fn2_wrap_0("-15") == -30
+ assert m.fn2_wrap_0(45) == 90
+
+
+def test_argument_wrapper_fn3():
+ assert m.fn3_raw(33, [], "hello") == "hello-33"
+ assert m.fn3_raw(33, [1, 2], "hello") == "hello-35"
+
+ with pytest.raises(TypeError):
+ m.fn3_raw("12", [], "hello") # pyright: ignore[reportArgumentType]
+
+ with pytest.raises(TypeError):
+ m.fn3_raw(36, [], 136) # pyright: ignore[reportArgumentType]
+
+ assert m.fn3_wrap(14, [1, 2], "world") == "world-16"
+ assert m.fn3_wrap("15", [0], "woot") == "woot-16"
+ assert m.fn3_wrap(17, [], 33) == "33-17"
+ assert m.fn3_wrap("15", [], 44) == "44-15"
+
+ assert m.fn3_wrap_0_2(14, [1, 2], "world") == "world-16"
+ assert m.fn3_wrap_0_2("15", [0], "woot") == "woot-16"
+ assert m.fn3_wrap_0_2(17, [], 33) == "33-17"
+ assert m.fn3_wrap_0_2("15", [], 44) == "44-15"
+
+ assert m.fn3_wrap_0(14, [1, 2], "world") == "world-16"
+ assert m.fn3_wrap_0("15", [], "w00t") == "w00t-15"
+
+ with pytest.raises(TypeError):
+ m.fn3_wrap_0(14, [], 12) # pyright: ignore[reportArgumentType]
+
+ assert m.fn3_wrap_2(14, [1, 2], "world") == "world-16"
+ assert m.fn3_wrap_2(15, [], 18) == "18-15"
+
+ with pytest.raises(TypeError):
+ m.fn3_wrap_2("14", [], 12) # pyright: ignore[reportArgumentType]
diff --git a/libs/plugin_python/tests/python/test_filetree.cpp b/libs/plugin_python/tests/python/test_filetree.cpp
new file mode 100644
index 0000000..cce43cf
--- /dev/null
+++ b/libs/plugin_python/tests/python/test_filetree.cpp
@@ -0,0 +1,22 @@
+#include "pybind11_qt/pybind11_qt.h"
+
+#include <pybind11/functional.h>
+#include <pybind11/pybind11.h>
+
+#include <uibase/ifiletree.h>
+
+using namespace MOBase;
+
+PYBIND11_MODULE(filetree, m)
+{
+ // test the FileTypes stuff
+ m.def("is_file", [](IFileTree::FileTypes const& t) {
+ return t.testFlag(IFileTree::FILE);
+ });
+ m.def("is_directory", [](IFileTree::FileTypes const& t) {
+ return t.testFlag(IFileTree::DIRECTORY);
+ });
+ m.def("value", [](IFileTree::FileTypes const& t) {
+ return t.toInt();
+ });
+}
diff --git a/libs/plugin_python/tests/python/test_filetree.py b/libs/plugin_python/tests/python/test_filetree.py
new file mode 100644
index 0000000..0322faa
--- /dev/null
+++ b/libs/plugin_python/tests/python/test_filetree.py
@@ -0,0 +1,86 @@
+from typing import TypeAlias, cast
+
+import mobase
+
+import mobase_tests.filetree as m
+
+
+def test_filetype():
+ FT = mobase.FileTreeEntry.FileTypes
+
+ assert mobase.FileTreeEntry.FILE == FT.FILE
+ assert m.is_file(FT.FILE)
+ assert not m.is_directory(FT.FILE)
+ assert m.value(FT.FILE) == FT.FILE.value
+
+ assert mobase.FileTreeEntry.DIRECTORY == FT.DIRECTORY
+ assert not m.is_file(FT.DIRECTORY)
+ assert m.is_directory(FT.DIRECTORY)
+ assert m.value(FT.DIRECTORY) == FT.DIRECTORY.value
+
+ assert mobase.FileTreeEntry.FILE_OR_DIRECTORY == FT.FILE_OR_DIRECTORY
+ assert m.is_file(FT.FILE_OR_DIRECTORY)
+ assert m.is_directory(FT.FILE_OR_DIRECTORY)
+ assert m.value(FT.FILE_OR_DIRECTORY) == FT.FILE_OR_DIRECTORY.value
+
+ assert FT.FILE_OR_DIRECTORY == FT.FILE | FT.DIRECTORY
+ assert m.is_file(FT.FILE | FT.DIRECTORY)
+ assert m.is_directory(FT.FILE | FT.DIRECTORY)
+ assert m.value(FT.FILE | FT.DIRECTORY) == (FT.FILE.value | FT.DIRECTORY.value)
+
+ assert m.is_file(FT.FILE_OR_DIRECTORY & FT.FILE)
+ assert not m.is_directory(FT.FILE_OR_DIRECTORY & FT.FILE)
+
+ assert not m.is_file(FT.FILE_OR_DIRECTORY & FT.DIRECTORY)
+ assert m.is_directory(FT.FILE_OR_DIRECTORY & FT.DIRECTORY)
+
+ assert m.is_file(FT.FILE_OR_DIRECTORY & ~FT.DIRECTORY)
+ assert not m.is_directory(FT.FILE_OR_DIRECTORY & ~FT.DIRECTORY)
+
+
+_tree_values: TypeAlias = list["str | tuple[str, _tree_values]"]
+
+
+def make_tree(
+ values: _tree_values, root: mobase.IFileTree | None = None
+) -> mobase.IFileTree:
+ if root is None:
+ root = cast(mobase.IFileTree, mobase.private.makeTree()) # type: ignore
+
+ for value in values:
+ if isinstance(value, str):
+ root.addFile(value)
+ else:
+ sub_tree = root.addDirectory(value[0])
+ make_tree(value[1], sub_tree)
+
+ return root
+
+
+def test_walk():
+ tree = make_tree(
+ [("a", []), ("b", ["u", "v"]), "c.x", "d.y", ("e", [("q", ["c.t", ("p", [])])])]
+ )
+
+ assert {"a", "b", "b/u", "b/v", "c.x", "d.y", "e", "e/q", "e/q/c.t", "e/q/p"} == {
+ e.path("/") for e in tree.walk()
+ }
+
+ entries: list[str] = []
+ for e in tree.walk():
+ if e.name() == "e":
+ break
+ entries.append(e.path("/"))
+ assert {"a", "b", "b/u", "b/v"} == set(entries)
+
+
+def test_glob():
+ tree = make_tree(
+ [("a", []), ("b", ["u", "v"]), "c.x", "d.y", ("e", [("q", ["c.t", ("p", [])])])]
+ )
+
+ assert {"a", "b", "b/u", "b/v", "c.x", "d.y", "e", "e/q", "e/q/c.t", "e/q/p"} == {
+ e.path("/") for e in tree.glob("**/*")
+ }
+
+ assert {"d.y"} == {e.path("/") for e in tree.glob("**/*.y")}
diff --git a/libs/plugin_python/tests/python/test_functional.cpp b/libs/plugin_python/tests/python/test_functional.cpp
new file mode 100644
index 0000000..4858516
--- /dev/null
+++ b/libs/plugin_python/tests/python/test_functional.cpp
@@ -0,0 +1,38 @@
+#include "pybind11_utils/functional.h"
+
+#include <pybind11/pybind11.h>
+
+PYBIND11_MODULE(functional, m)
+{
+ m.def("fn_0_arg", [](std::function<int()> const& fn) {
+ return fn();
+ });
+
+ m.def("fn_1_arg", [](std::function<int(int)> const& fn, int a) {
+ return fn(a);
+ });
+
+ m.def("fn_2_arg", [](std::function<int(int, int)> const& fn, int a, int b) {
+ return fn(a, b);
+ });
+
+ m.def("fn_0_or_1_arg", [](std::function<int()> const& fn) {
+ return fn();
+ });
+
+ m.def("fn_0_or_1_arg", [](std::function<int(int)> const& fn) {
+ return fn(1);
+ });
+
+ m.def("fn_1_or_2_or_3_arg", [](std::function<int(int)> const& fn) {
+ return fn(1);
+ });
+
+ m.def("fn_1_or_2_or_3_arg", [](std::function<int(int, int)> const& fn) {
+ return fn(1, 2);
+ });
+
+ m.def("fn_1_or_2_or_3_arg", [](std::function<int(int, int, int)> const& fn) {
+ return fn(1, 2, 3);
+ });
+}
diff --git a/libs/plugin_python/tests/python/test_functional.py b/libs/plugin_python/tests/python/test_functional.py
new file mode 100644
index 0000000..f8da087
--- /dev/null
+++ b/libs/plugin_python/tests/python/test_functional.py
@@ -0,0 +1,55 @@
+from typing import Any
+
+import pytest
+
+import mobase_tests.functional as m
+
+
+def test_guessed_string():
+ # available functions:
+ # - fn_0_arg, fn_1_arg, fn_2_arg
+ # - fn_0_or_1_arg, fn_1_or_2_or_3_arg
+
+ def no_args() -> int:
+ return 0
+
+ def len_of_args(*args: int) -> int:
+ return len(args)
+
+ def len_of_args_tweaked(x: int, *args: int, **kwargs: Any) -> int:
+ return x + len(args)
+
+ def sum_of_args(*args: int) -> int:
+ return sum(args)
+
+ assert m.fn_0_arg(lambda: 0) == 0
+ assert m.fn_0_arg(lambda: 5) == 5
+ assert m.fn_0_arg(lambda x=2: x) == 2
+ assert m.fn_0_arg(len_of_args) == 0
+
+ assert m.fn_1_arg(lambda x: x, 4) == 4
+ assert m.fn_1_arg(sum_of_args, 8) == 8
+ assert m.fn_1_arg(lambda x=2, y=4: x + y, 3) == 7
+ assert m.fn_1_arg(len_of_args_tweaked, 5) == 5
+
+ assert m.fn_2_arg(lambda x, y: x * y, 4, 5) == 20
+ assert m.fn_2_arg(lambda x, y=3: x * y, 4, 2) == 8
+ assert m.fn_2_arg(sum_of_args, 8, 9) == 17
+ assert m.fn_2_arg(lambda x=2, y=4: x + y, 3, 3) == 6
+ assert m.fn_2_arg(len_of_args_tweaked, 5, 2) == 6
+
+ assert m.fn_0_or_1_arg(lambda: 3) == 3
+ assert m.fn_0_or_1_arg(lambda x: x) == 1
+
+ # the 0 arg is bound first, both are possible, the 0 arg is chosen
+ assert m.fn_0_or_1_arg(lambda x=2: x) == 2
+
+ with pytest.raises(TypeError):
+ m.fn_1_or_2_or_3_arg(no_args) # pyright: ignore[reportArgumentType, reportCallIssue]
+
+ assert m.fn_1_or_2_or_3_arg(lambda x=4: x) == 1
+ assert m.fn_1_or_2_or_3_arg(lambda x, y: x + y) == 3 # 1 + 2
+ assert m.fn_1_or_2_or_3_arg(lambda x, y, z: x * y * z) == 6 # 1 * 2 * 3
+
+ # the 1 arg is bound first
+ assert m.fn_1_or_2_or_3_arg(sum_of_args) == 1
diff --git a/libs/plugin_python/tests/python/test_guessed_string.cpp b/libs/plugin_python/tests/python/test_guessed_string.cpp
new file mode 100644
index 0000000..5e76773
--- /dev/null
+++ b/libs/plugin_python/tests/python/test_guessed_string.cpp
@@ -0,0 +1,34 @@
+#include "pybind11_qt/pybind11_qt.h"
+
+#include <pybind11/functional.h>
+#include <pybind11/pybind11.h>
+
+#include <uibase/guessedvalue.h>
+
+using namespace MOBase;
+
+PYBIND11_MODULE(guessed_string, m)
+{
+ m.def("get_value", [](GuessedValue<QString> const& value) {
+ return value.operator const QString&();
+ });
+ m.def("get_variants", [](GuessedValue<QString> const& value) {
+ return value.variants();
+ });
+
+ m.def("set_from_callback",
+ [](GuessedValue<QString>& value,
+ std::function<void(GuessedValue<QString>&)> const& fn) {
+ fn(value);
+ });
+
+ // note: the function needs to take the guessed string by pointer if constructed
+ // from C++, this is to be taken into account when calling Python function (cf.
+ // installers)
+ m.def("get_from_callback",
+ [](std::function<void(GuessedValue<QString>*)> const& fn) {
+ GuessedValue<QString> value;
+ fn(&value);
+ return (QString)value;
+ });
+}
diff --git a/libs/plugin_python/tests/python/test_guessed_string.py b/libs/plugin_python/tests/python/test_guessed_string.py
new file mode 100644
index 0000000..204e1f1
--- /dev/null
+++ b/libs/plugin_python/tests/python/test_guessed_string.py
@@ -0,0 +1,36 @@
+import mobase
+
+import mobase_tests.guessed_string as m
+
+
+def test_guessed_string():
+ # empty string
+ gs = mobase.GuessedString()
+
+ assert len(gs.variants()) == 0
+ assert not gs.variants()
+ assert str(gs) == ""
+
+ # automatic conversion from string
+ assert m.get_value("test") == "test"
+ assert m.get_variants("test") == {"test"}
+
+ # update
+ gs = mobase.GuessedString("fallback", mobase.GuessQuality.FALLBACK)
+ assert str(gs) == "fallback"
+
+ gs.update("good", mobase.GuessQuality.GOOD)
+ assert str(gs) == "good"
+ assert gs.variants() == {"fallback", "good"}
+
+ # back-and-forth
+ gs = mobase.GuessedString()
+ assert str(gs) == ""
+
+ def _update(gs: mobase.GuessedString):
+ gs.update("test")
+
+ m.set_from_callback(gs, _update)
+ assert str(gs) == "test"
+
+ assert m.get_from_callback(_update) == "test"
diff --git a/libs/plugin_python/tests/python/test_organizer.cpp b/libs/plugin_python/tests/python/test_organizer.cpp
new file mode 100644
index 0000000..ea54c0d
--- /dev/null
+++ b/libs/plugin_python/tests/python/test_organizer.cpp
@@ -0,0 +1,46 @@
+#include "pybind11_qt/pybind11_qt.h"
+
+#include <pybind11/pybind11.h>
+
+#include <QMap>
+#include <QWidget>
+
+#include "MockOrganizer.h"
+
+namespace py = pybind11;
+using namespace pybind11::literals;
+using ::testing::NiceMock;
+
+PYBIND11_MODULE(organizer, m)
+{
+ using ::testing::_;
+ using ::testing::Eq;
+ using ::testing::Return;
+
+ m.def("organizer", []() -> IOrganizer* {
+ MockOrganizer* mock = new NiceMock<MockOrganizer>();
+ ON_CALL(*mock, profileName).WillByDefault([&mock]() {
+ return "profile";
+ });
+
+ const auto handle = (HANDLE)std::uintptr_t{4654};
+ ON_CALL(*mock, startApplication)
+ .WillByDefault([handle](const auto& name, auto&&... args) {
+ return name == "valid.exe" ? handle : INVALID_HANDLE_VALUE;
+ });
+ ON_CALL(*mock, waitForApplication)
+ .WillByDefault([&mock, original_handle = handle](HANDLE handle, bool,
+ LPDWORD exitCode) {
+ if (handle == original_handle) {
+ *exitCode = 0;
+ return true;
+ }
+ else {
+ *exitCode = static_cast<DWORD>(-1);
+ return false;
+ }
+ });
+
+ return mock;
+ });
+}
diff --git a/libs/plugin_python/tests/python/test_organizer.py b/libs/plugin_python/tests/python/test_organizer.py
new file mode 100644
index 0000000..3ac42d2
--- /dev/null
+++ b/libs/plugin_python/tests/python/test_organizer.py
@@ -0,0 +1,14 @@
+import pytest
+
+import mobase
+
+m = pytest.importorskip("mobase_tests.organizer")
+
+
+def test_getters():
+ o: mobase.IOrganizer = m.organizer()
+ assert o.profileName() == "profile"
+ assert o.startApplication("valid.exe") == 4654
+ assert o.startApplication("invalid.exe") == mobase.INVALID_HANDLE_VALUE
+ assert o.waitForApplication(42) == (False, -1)
+ assert o.waitForApplication(4654) == (True, 0)
diff --git a/libs/plugin_python/tests/python/test_path_wrappers.py b/libs/plugin_python/tests/python/test_path_wrappers.py
new file mode 100644
index 0000000..5a0d701
--- /dev/null
+++ b/libs/plugin_python/tests/python/test_path_wrappers.py
@@ -0,0 +1,45 @@
+import sys
+from pathlib import Path
+
+import pytest
+from PyQt6.QtCore import QDir, QFileInfo
+
+import mobase
+
+
+def test_filepath_wrappers():
+ # TBC that this works everywhere
+ version = ".".join(map(str, sys.version_info[:3]))
+
+ # from string, ok
+ assert mobase.getProductVersion(sys.executable) == version
+
+ # from path, ok
+ assert mobase.getProductVersion(Path(sys.executable)) == version
+
+ # from QDir, ko
+ with pytest.raises(TypeError):
+ mobase.getProductVersion(QDir(sys.executable)) # pyright: ignore[reportArgumentType]
+
+
+def test_executableinfo():
+ info = mobase.ExecutableInfo("exe", QFileInfo(sys.executable))
+ assert info.binary() == QFileInfo(sys.executable)
+
+ info = mobase.ExecutableInfo("exe", sys.executable)
+ assert info.binary() == QFileInfo(sys.executable)
+
+ info = mobase.ExecutableInfo("exe", Path(sys.executable))
+ assert info.binary() == QFileInfo(sys.executable)
+
+ info.withWorkingDirectory(Path(__file__).parent)
+ assert info.workingDirectory() == QFileInfo(__file__).dir()
+
+ info.withWorkingDirectory(".")
+ assert info.workingDirectory() == QDir(".")
+
+ info.withWorkingDirectory(Path("."))
+ assert info.workingDirectory() == QDir(".")
+
+ info.withWorkingDirectory(".")
+ assert info.workingDirectory() == QDir(".")
diff --git a/libs/plugin_python/tests/python/test_qt.cpp b/libs/plugin_python/tests/python/test_qt.cpp
new file mode 100644
index 0000000..5ef1f00
--- /dev/null
+++ b/libs/plugin_python/tests/python/test_qt.cpp
@@ -0,0 +1,155 @@
+#include "pybind11_qt/pybind11_qt.h"
+
+#include <pybind11/pybind11.h>
+
+#include <tuple>
+
+namespace py = pybind11;
+using namespace pybind11::literals;
+
+PYBIND11_MODULE(qt, m)
+{
+ // QString
+
+ m.def("create_qstring_with_emoji", []() {
+ return QString::fromUtf16(u"\U0001F600");
+ });
+
+ m.def("consume_qstring_with_emoji", [](QString const& qstring) {
+ return qstring.length();
+ });
+
+ m.def("qstring_to_stdstring", [](QString const& qstring) {
+ return qstring.toStdString();
+ });
+
+ m.def("stdstring_to_qstring", [](std::string const& string) {
+ return QString::fromStdString(string);
+ });
+ m.def("qstring_to_int", [](QString const& qstring) {
+ return qstring.toInt();
+ });
+ m.def("int_to_qstring", [](int value) {
+ return QString::number(value);
+ });
+
+ // QStringList
+
+ m.def("qstringlist_join", [](QStringList const& values, QString const& sep) {
+ return values.join(sep);
+ });
+
+ m.def("qstringlist_at", [](QStringList const& values, int index) {
+ return values.at(index);
+ });
+
+ // QMap
+
+ m.def("qmap_to_length", [](QMap<QString, QString> const& map) {
+ QMap<QString, int> res;
+ for (auto it = map.begin(); it != map.end(); ++it) {
+ res[it.key()] = it.value().size();
+ }
+ return res;
+ });
+
+ // QDateTime
+
+ m.def(
+ "datetime_from_string",
+ [](QString const& date, Qt::DateFormat format) {
+ return QDateTime::fromString(date, format);
+ },
+ "string"_a, "format"_a = Qt::DateFormat::ISODate);
+
+ m.def(
+ "datetime_to_string",
+ [](QDateTime const& datetime, Qt::DateFormat format) {
+ 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() == QMetaType::UnknownType,
+ variant.isValid());
+ });
+ m.def("qvariant_from_int", [](QVariant const& variant) {
+ return std::make_tuple(variant.userType() == QMetaType::Int, variant.toInt());
+ });
+ m.def("qvariant_from_bool", [](QVariant const& variant) {
+ return std::make_tuple(variant.userType() == QMetaType::Bool, variant.toBool());
+ });
+ m.def("qvariant_from_str", [](QVariant const& variant) {
+ return std::make_tuple(variant.userType() == QMetaType::QString,
+ variant.toString());
+ });
+ m.def("qvariant_from_list", [](QVariant const& variant) {
+ return std::make_tuple(variant.userType() == QMetaType::QVariantList,
+ variant.toList());
+ });
+ m.def("qvariant_from_map", [](QVariant const& variant) {
+ return std::make_tuple(variant.userType() == QMetaType::QVariantMap,
+ 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;
+ });
+
+ // QFlags
+
+ enum SimpleEnum { Value0 = 0x1, Value1 = 0x2, Value2 = 0x4 };
+ Q_DECLARE_FLAGS(SimpleEnumFlags, SimpleEnum);
+
+ py::enum_<SimpleEnum>(m, "SimpleEnum", py::arithmetic())
+ .value("Value0", Value0)
+ .value("Value1", Value1)
+ .value("Value2", Value2);
+
+ m.def("qflags_explode", [](SimpleEnumFlags const& flags) {
+ return std::make_tuple(flags.toInt(), flags.testFlag(Value0),
+ flags.testFlag(Value1), flags.testFlag(Value2));
+ });
+ m.def("qflags_create", [](bool v0, bool v1, bool v2) {
+ SimpleEnumFlags r;
+ if (v0)
+ r.setFlag(Value0);
+ if (v1)
+ r.setFlag(Value1);
+ if (v2)
+ r.setFlag(Value2);
+ return r;
+ });
+}
diff --git a/libs/plugin_python/tests/python/test_qt.py b/libs/plugin_python/tests/python/test_qt.py
new file mode 100644
index 0000000..991e33c
--- /dev/null
+++ b/libs/plugin_python/tests/python/test_qt.py
@@ -0,0 +1,119 @@
+from PyQt6.QtCore import QDateTime, Qt
+
+import mobase_tests.qt as m
+
+
+def test_qstring():
+ assert m.qstring_to_stdstring("test") == "test"
+ assert m.stdstring_to_qstring("test") == "test"
+
+ assert m.qstring_to_stdstring("éàüö") == "éàüö"
+ assert m.stdstring_to_qstring("éàüö") == "éàüö"
+ assert m.qstring_to_stdstring("خالد") == "خالد"
+ assert m.qstring_to_stdstring("🌎") == "🌎"
+
+ assert m.qstring_to_int("2") == 2
+ assert m.int_to_qstring(2) == "2"
+
+ emoji = m.create_qstring_with_emoji()
+
+ assert emoji.encode("utf-16be", "surrogatepass") == b"\xd8\x3d\xde\x00"
+ assert m.consume_qstring_with_emoji(emoji) == 2
+
+ assert m.consume_qstring_with_emoji("🌎") == 2
+
+
+def test_qstringlist():
+ assert m.qstringlist_join([""], "--") == ""
+ assert m.qstringlist_join(["a", "b"], "") == "ab"
+ assert m.qstringlist_join(["x", "y"], ";") == "x;y"
+
+ assert m.qstringlist_at(["x", "y"], 0) == "x"
+ assert m.qstringlist_at(["x", "y"], 1) == "y"
+
+
+def test_qmap():
+ assert m.qmap_to_length({"t1": "abc", "t2": "o", "t3": ""}) == {
+ "t1": 3,
+ "t2": 1,
+ "t3": 0,
+ }
+
+
+def test_qdatetime():
+ assert m.datetime_from_string("2022-03-01") == QDateTime(2022, 3, 1, 0, 0)
+
+ date = QDateTime(1995, 5, 20, 0, 0)
+ assert (
+ m.datetime_from_string(
+ date.toString(Qt.DateFormat.TextDate), Qt.DateFormat.TextDate
+ )
+ == date
+ )
+
+ assert m.datetime_to_string(date) == "1995-05-20T00:00:00"
+ 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,
+ ]
+
+
+def test_qflags():
+ v0, v1, v2 = m.SimpleEnum.Value0, m.SimpleEnum.Value1, m.SimpleEnum.Value2
+
+ assert m.qflags_explode(v0 | v1) == (0x3, True, True, False)
+ assert m.qflags_explode(v0 | v2) == (0x5, True, False, True)
+ assert m.qflags_explode(0) == (0, False, False, False)
+
+ assert not (m.qflags_create(False, False, False) & v0)
+ assert not (m.qflags_create(False, False, False) & v1)
+ assert not (m.qflags_create(False, False, False) & v2)
+
+ assert m.qflags_create(True, False, False) & v0
+ assert m.qflags_create(True, True, False) & v0
+ assert m.qflags_create(True, True, False) & v1
+ assert not (m.qflags_create(True, True, False) & v2)
+
+ assert m.qflags_create(True, False, False) | v0 == v0
+ assert m.qflags_create(True, False, False) | v0 | v2 == v0 | v2
diff --git a/libs/plugin_python/tests/python/test_qt_widgets.cpp b/libs/plugin_python/tests/python/test_qt_widgets.cpp
new file mode 100644
index 0000000..6d8a901
--- /dev/null
+++ b/libs/plugin_python/tests/python/test_qt_widgets.cpp
@@ -0,0 +1,85 @@
+#include "pybind11_qt/pybind11_qt.h"
+
+#include <pybind11/pybind11.h>
+
+#include <QMap>
+#include <QWidget>
+
+namespace py = pybind11;
+using namespace pybind11::literals;
+
+QMap<QString, QWidget*> s_Widgets;
+QWidget* s_Parent;
+
+class CustomWidget : public QWidget {
+public:
+ CustomWidget(QString const& name, QWidget* parent = nullptr) : QWidget(parent)
+ {
+ s_Widgets[name] = this;
+ setProperty("name", name);
+ }
+
+ ~CustomWidget() { s_Widgets.remove(property("name").toString()); }
+};
+
+class PyCustomWidget : public CustomWidget {
+public:
+ using CustomWidget::CustomWidget;
+ int heightForWidth(int value) const
+ {
+ PYBIND11_OVERRIDE(int, CustomWidget, heightForWidth, value);
+ }
+};
+
+PYBIND11_MODULE(qt_widgets, m)
+{
+ s_Parent = new QWidget();
+
+ py::class_<CustomWidget, PyCustomWidget, py::qt::qobject_holder<CustomWidget>>
+ pyCustomWidget(m, "CustomWidget");
+ pyCustomWidget
+ .def(py::init<QString, QWidget*>(), "name"_a, "parent"_a = (QWidget*)nullptr)
+ .def("set_parent_cpp", [](CustomWidget* w) {
+ w->setParent(s_Parent);
+ });
+ py::qt::add_qt_delegate<QWidget>(pyCustomWidget, "_widget");
+
+ m.def("is_alive", [](QString const& name) {
+ return s_Widgets.contains(name);
+ });
+
+ m.def("get", [](QString const& name) {
+ return s_Widgets.contains(name) ? s_Widgets[name] : nullptr;
+ });
+
+ m.def("set_parent", [](QWidget* widget) {
+ widget->setParent(s_Parent);
+ });
+
+ m.def("is_owned_cpp", [](QString const& name) {
+ return s_Widgets.contains(name) && s_Widgets[name]->parent() == s_Parent;
+ });
+
+ m.def("make_widget_own_cpp", [](QString const& name) -> QWidget* {
+ return new CustomWidget(name, s_Parent);
+ });
+
+ m.def(
+ "make_widget_own_py",
+ [](QString const& name) -> QWidget* {
+ return new CustomWidget(name);
+ },
+ py::return_value_policy::take_ownership);
+
+ // simply passing the widget gives the ownership of the Python object to C++
+ m.def("send_to_cpp", [](QString const& name, QWidget* widget) {
+ widget->setProperty("name", name);
+ widget->setParent(s_Parent);
+ s_Widgets[name] = widget;
+ });
+
+ m.def("heightForWidth", [](QString const& name, int value) {
+ return s_Widgets.contains(name) ? s_Widgets[name]->heightForWidth(value)
+ : -1024;
+ });
+}
diff --git a/libs/plugin_python/tests/python/test_qt_widgets.py b/libs/plugin_python/tests/python/test_qt_widgets.py
new file mode 100644
index 0000000..057f34e
--- /dev/null
+++ b/libs/plugin_python/tests/python/test_qt_widgets.py
@@ -0,0 +1,62 @@
+from PyQt6.QtWidgets import QWidget
+
+import mobase_tests.qt_widgets as m
+
+
+class PyWidget(QWidget):
+ def heightForWidth(self, a0: int) -> int:
+ return a0 * 3 + 4
+
+
+class PyCustomWidget(m.CustomWidget):
+ def __init__(self, name: str):
+ super().__init__(name)
+
+ def heightForWidth(self, value: int) -> int:
+ return value * 6 - 5
+
+
+def test_qt_widget():
+ # own cpp
+ w = m.make_widget_own_cpp("w1")
+ assert m.is_alive("w1")
+ assert m.is_owned_cpp("w1")
+
+ del w
+ assert m.is_alive("w1")
+
+ # own py
+ w = m.make_widget_own_py("w2")
+ assert m.is_alive("w2")
+ assert not m.is_owned_cpp("w2")
+
+ del w
+ assert not m.is_alive("w2")
+
+ # transfer to C++
+ w = PyWidget()
+ m.send_to_cpp("w3", w)
+
+ # delete the reference w - this should NOT delete the underlying object since it
+ # was transferred to C++
+ del w
+ assert m.is_alive("w3")
+ assert m.is_owned_cpp("w3")
+
+ # if the Python object is dead (BAD!), this will crash horrible
+ assert m.heightForWidth("w3", 4) == 4 * 3 + 4
+
+ # CustomWidget as a qholder, so the construction itself transfers the ownership
+ # to C++
+ w = PyCustomWidget("w4")
+ w.set_parent_cpp()
+ assert m.is_alive("w4")
+ assert m.heightForWidth("w4", 7) == 6 * 7 - 5
+ assert w.heightForWidth(7) == 6 * 7 - 5
+
+ # can call function not defined and not bound through the delegate
+ assert not w.hasHeightForWidth()
+
+ del w
+ assert m.is_alive("w4")
+ assert m.heightForWidth("w4", 7) == 6 * 7 - 5
diff --git a/libs/plugin_python/tests/python/test_shared_cpp_owner.cpp b/libs/plugin_python/tests/python/test_shared_cpp_owner.cpp
new file mode 100644
index 0000000..f24cafd
--- /dev/null
+++ b/libs/plugin_python/tests/python/test_shared_cpp_owner.cpp
@@ -0,0 +1,67 @@
+#include "pybind11_utils/shared_cpp_owner.h"
+
+#include <pybind11/pybind11.h>
+
+#include <tuple>
+#include <unordered_map>
+
+namespace py = pybind11;
+using namespace pybind11::literals;
+
+class Base;
+static std::unordered_map<std::string, Base*> bases;
+
+class Base {
+ std::string name_;
+
+public:
+ Base(std::string const& name) : name_{name} { bases[name] = this; }
+ virtual std::string fn() const = 0;
+ virtual ~Base() { bases.erase(name_); }
+};
+
+MO2_PYBIND11_SHARED_CPP_HOLDER(Base);
+
+class CppBase : public Base {
+public:
+ using Base::Base;
+ std::string fn() const override { return "CppBase::fn()"; }
+};
+
+class PyBase : public Base {
+public:
+ using Base::Base;
+ std::string fn() const override { PYBIND11_OVERRIDE_PURE(std::string, Base, fn, ); }
+};
+
+PYBIND11_MODULE(shared_cpp_owner, m)
+{
+ static std::shared_ptr<Base> base_ptr;
+
+ py::class_<Base, PyBase, std::shared_ptr<Base>>(m, "Base")
+ .def(py::init<std::string>())
+ .def("fn", &Base::fn);
+
+ m.def("is_alive", [](std::string const& name) {
+ return bases.find(name) != bases.end();
+ });
+
+ m.def("create", [](std::string const& name) -> std::shared_ptr<Base> {
+ return std::make_shared<CppBase>(name);
+ });
+ m.def("create_and_store", [](std::string const& name) {
+ base_ptr = std::make_shared<CppBase>(name);
+ return base_ptr;
+ });
+ m.def("store", [](std::shared_ptr<Base> ptr) {
+ base_ptr = ptr;
+ });
+ m.def("clear", []() {
+ base_ptr.reset();
+ });
+
+ m.def("call_fn", [](std::string const& name) {
+ auto it = bases.find(name);
+ return it != bases.end() ? it->second->fn() : "";
+ });
+}
diff --git a/libs/plugin_python/tests/python/test_shared_cpp_owner.py b/libs/plugin_python/tests/python/test_shared_cpp_owner.py
new file mode 100644
index 0000000..0bcecf7
--- /dev/null
+++ b/libs/plugin_python/tests/python/test_shared_cpp_owner.py
@@ -0,0 +1,72 @@
+import mobase_tests.shared_cpp_owner as m
+
+
+class PyBase(m.Base):
+ def __init__(self, name: str, value: int):
+ super().__init__(name)
+ self.value = value
+
+ def fn(self):
+ return f"PyBase.fn({self.value})"
+
+
+def test_shared_cpp_owner_1():
+ # create from C++, owned by Python
+
+ # create from C++
+ p = m.create("tmp")
+ assert m.is_alive("tmp")
+
+ # should delete since it's not owner by C++
+ del p
+ assert not m.is_alive("tmp")
+
+
+def test_shared_cpp_owner_2():
+ # create from C++, owned by C++ (and Python)
+
+ # create from C++
+ p = m.create_and_store("tmp")
+ assert m.is_alive("tmp")
+
+ # should not delete since it's owned by both C++ and Python
+ del p
+ assert m.is_alive("tmp")
+
+ # clear from C++ should free it
+ m.clear()
+ assert not m.is_alive("tmp")
+
+
+def test_shared_cpp_owner_3():
+ # create from Python, owned by Python
+
+ p = PyBase("foo", 1)
+ assert m.is_alive("foo")
+ assert m.call_fn("foo") == "PyBase.fn(1)"
+
+ del p
+ assert not m.is_alive("foo")
+
+
+def test_shared_cpp_owner_4():
+ # create from Python, owned by C++
+
+ p = PyBase("foo", 2)
+ assert m.is_alive("foo")
+
+ # send to C++
+ m.store(p)
+ assert m.is_alive("foo")
+ assert m.call_fn("foo") == "PyBase.fn(2)"
+
+ # delete in Python, should still be alived
+ del p
+ assert m.is_alive("foo")
+
+ # should still be able to call fn()
+ assert m.call_fn("foo") == "PyBase.fn(2)"
+
+ # clear in C++, should kill Python
+ m.clear()
+ assert not m.is_alive("foo")
diff --git a/libs/plugin_python/tests/runner/CMakeLists.txt b/libs/plugin_python/tests/runner/CMakeLists.txt
new file mode 100644
index 0000000..ea17438
--- /dev/null
+++ b/libs/plugin_python/tests/runner/CMakeLists.txt
@@ -0,0 +1,65 @@
+cmake_minimum_required(VERSION 3.22)
+
+# 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(runner-tests EXCLUDE_FROM_ALL)
+mo2_default_source_group()
+mo2_target_sources(runner-tests
+ FOLDER src
+ PRIVATE
+ test_diagnose.cpp
+ test_filemapper.cpp
+ test_game.cpp
+ test_installer.cpp
+ test_iplugin.cpp
+ test_lifetime.cpp
+)
+mo2_target_sources(runner-tests
+ FOLDER src/mocks
+ PRIVATE
+ ../mocks/DummyFileTree.h
+ ../mocks/MockOrganizer.h
+)
+mo2_target_sources(runner-tests
+ FOLDER src/plugins
+ PRIVATE
+ plugins/dummy-diagnose.py
+ plugins/dummy-filemapper.py
+ plugins/dummy-game.py
+ plugins/dummy-installer.py
+ plugins/dummy-iplugin.py
+)
+mo2_configure_tests(runner-tests NO_SOURCES WARNINGS 4)
+
+set_target_properties(runner-tests PROPERTIES FOLDER tests/runner)
+
+# link to runner
+target_link_libraries(runner-tests PUBLIC runner)
+
+# linking to Python - this is not required to get proper linking but required so that
+# CMake generator variables will lookup appropriate DLLs for Python and update PATH
+# accordingly thanks to mo2_configure_tests
+target_link_libraries(runner-tests PUBLIC Python::Python)
+
+# add mocks
+target_include_directories(runner-tests
+ PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../mocks)
+
+set(PYLIB_DIR ${CMAKE_CURRENT_BINARY_DIR}/pylibs)
+mo2_python_pip_install(runner-tests
+ DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/pylibs
+ PACKAGES
+ pytest
+ PyQt${MO2_QT_VERSION_MAJOR}==${MO2_PYQT_VERSION}
+ PyQt${MO2_QT_VERSION_MAJOR}-Qt${MO2_QT_VERSION_MAJOR}==${MO2_QT_VERSION})
+
+add_dependencies(runner-tests mobase)
+
+set(PYTHONPATH "${PYLIB_DIR}\\;$<TARGET_FILE_DIR:mobase>\\;${Python_DLL_DIR}\\;${Python_LIB_DIR}")
+
+set_tests_properties(${runner-tests_gtests}
+ PROPERTIES
+ ENVIRONMENT "PLUGIN_DIR=${CMAKE_CURRENT_SOURCE_DIR}/plugins;PYTHONPATH=${PYTHONPATH}"
+)
diff --git a/libs/plugin_python/tests/runner/plugins/dummy-diagnose.py b/libs/plugin_python/tests/runner/plugins/dummy-diagnose.py
new file mode 100644
index 0000000..6f25ef0
--- /dev/null
+++ b/libs/plugin_python/tests/runner/plugins/dummy-diagnose.py
@@ -0,0 +1,37 @@
+import mobase
+
+
+class DummyDiagnose(mobase.IPluginDiagnose):
+ def activeProblems(self) -> list[int]:
+ return [1, 2]
+
+ def shortDescription(self, key: int) -> str:
+ return f"short-{key}"
+
+ def fullDescription(self, key: int) -> str:
+ return f"long-{key}"
+
+ def hasGuidedFix(self, key: int) -> bool:
+ return key == 1
+
+
+class DummyDiagnoseAndGame(mobase.IPluginDiagnose, mobase.IPluginGame):
+ def __init__(self):
+ mobase.IPluginDiagnose.__init__(self)
+ mobase.IPluginGame.__init__(self)
+
+ def activeProblems(self) -> list[int]:
+ return [5, 7]
+
+ def shortDescription(self, key: int) -> str:
+ return f"short-{key}"
+
+ def fullDescription(self, key: int) -> str:
+ return f"long-{key}"
+
+ def hasGuidedFix(self, key: int) -> bool:
+ return key == 7
+
+
+def createPlugins() -> list[mobase.IPlugin]:
+ return [DummyDiagnose(), DummyDiagnoseAndGame()] # type: ignore
diff --git a/libs/plugin_python/tests/runner/plugins/dummy-filemapper.py b/libs/plugin_python/tests/runner/plugins/dummy-filemapper.py
new file mode 100644
index 0000000..1993b55
--- /dev/null
+++ b/libs/plugin_python/tests/runner/plugins/dummy-filemapper.py
@@ -0,0 +1,32 @@
+import mobase
+
+
+class DummyFileMapper(mobase.IPluginFileMapper):
+ def mappings(self) -> list[mobase.Mapping]:
+ return [
+ mobase.Mapping(
+ source="the source", destination="the destination", is_directory=False
+ ),
+ mobase.Mapping(
+ source="the other source",
+ destination="the other destination",
+ is_directory=True,
+ ),
+ ]
+
+
+class DummyFileMapperAndGame(mobase.IPluginFileMapper, mobase.IPluginGame):
+ def __init__(self):
+ mobase.IPluginFileMapper.__init__(self)
+ mobase.IPluginGame.__init__(self)
+
+ def mappings(self) -> list[mobase.Mapping]:
+ return [
+ mobase.Mapping(
+ source="the source", destination="the destination", is_directory=False
+ ),
+ ]
+
+
+def createPlugins() -> list[mobase.IPlugin]:
+ return [DummyFileMapper(), DummyFileMapperAndGame()] # type: ignore
diff --git a/libs/plugin_python/tests/runner/plugins/dummy-game.py b/libs/plugin_python/tests/runner/plugins/dummy-game.py
new file mode 100644
index 0000000..e17c9b8
--- /dev/null
+++ b/libs/plugin_python/tests/runner/plugins/dummy-game.py
@@ -0,0 +1,45 @@
+from collections.abc import Sequence
+
+from PyQt6.QtWidgets import QWidget
+
+import mobase
+
+
+class DummyModDataChecker(mobase.ModDataChecker):
+ def dataLooksValid(
+ self, filetree: mobase.IFileTree
+ ) -> mobase.ModDataChecker.CheckReturn:
+ return mobase.ModDataChecker.VALID
+
+ def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree:
+ return filetree
+
+
+class DummySaveGameInfo(mobase.SaveGameInfo):
+ def getMissingAssets(self, save: mobase.ISaveGame) -> dict[str, Sequence[str]]:
+ return {}
+
+ def getSaveGameWidget(self, parent: QWidget) -> mobase.ISaveGameInfoWidget | None:
+ return None
+
+
+# we do not implement everything since this will do fine if we do not call anything
+# from the C++ side
+#
+class DummyGame(mobase.IPluginGame):
+ _features: dict[type, object]
+
+ def __init__(self):
+ super().__init__()
+
+ self._features = {
+ mobase.ModDataChecker: DummyModDataChecker(),
+ mobase.SaveGameInfo: DummySaveGameInfo(),
+ }
+
+ def _featureList(self) -> dict[type, object]:
+ return self._features
+
+
+def createPlugin() -> mobase.IPlugin:
+ return DummyGame() # type: ignore
diff --git a/libs/plugin_python/tests/runner/plugins/dummy-installer.py b/libs/plugin_python/tests/runner/plugins/dummy-installer.py
new file mode 100644
index 0000000..3c4e67e
--- /dev/null
+++ b/libs/plugin_python/tests/runner/plugins/dummy-installer.py
@@ -0,0 +1,46 @@
+# -*- encoding: utf-8 -*-
+
+from typing import Union, cast
+
+import mobase
+
+
+# we do not implement everything since this will do fine if we do not call anything
+# from the C++ side
+#
+class DummyInstaller(mobase.IPluginInstallerSimple):
+ def isManualInstaller(self) -> bool:
+ return False
+
+ def priority(self) -> int:
+ return 10
+
+ def isArchiveSupported(self, tree: mobase.IFileTree) -> bool:
+ return tree.find("needed-file.txt") is not None
+
+ def install(
+ self,
+ name: mobase.GuessedString,
+ tree: mobase.IFileTree,
+ version: str,
+ nexus_id: int,
+ ) -> Union[
+ mobase.InstallResult,
+ mobase.IFileTree,
+ tuple[mobase.InstallResult, mobase.IFileTree, str, int],
+ ]:
+ if tree.find("needed-file.txt") is None:
+ return mobase.InstallResult.FAILED
+
+ if tree.find("extra-file.txt"):
+ name.update("new name")
+ new_tree = tree.createOrphanTree()
+ new_tree.move(tree, "subtree")
+ cast(mobase.IFileTree, new_tree.find("subtree")).remove("extra-file.txt")
+ return new_tree
+
+ return (mobase.InstallResult.NOT_ATTEMPTED, tree, "2.4.5", 33)
+
+
+def createPlugin() -> mobase.IPlugin:
+ return DummyInstaller() # type: ignore
diff --git a/libs/plugin_python/tests/runner/plugins/dummy-iplugin.py b/libs/plugin_python/tests/runner/plugins/dummy-iplugin.py
new file mode 100644
index 0000000..eaaf6c0
--- /dev/null
+++ b/libs/plugin_python/tests/runner/plugins/dummy-iplugin.py
@@ -0,0 +1,29 @@
+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/libs/plugin_python/tests/runner/test_diagnose.cpp b/libs/plugin_python/tests/runner/test_diagnose.cpp
new file mode 100644
index 0000000..467c9cb
--- /dev/null
+++ b/libs/plugin_python/tests/runner/test_diagnose.cpp
@@ -0,0 +1,61 @@
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+
+#include "pythonrunner.h"
+
+#include <QCoreApplication>
+
+#include <uibase/iplugindiagnose.h>
+#include <uibase/iplugingame.h>
+
+#include "MockOrganizer.h"
+
+using namespace MOBase;
+
+using ::testing::ElementsAre;
+
+TEST(IPluginDiagnose, Simple)
+{
+ const auto plugins_folder = QString(std::getenv("PLUGIN_DIR"));
+
+ auto runner = mo2::python::createPythonRunner();
+ runner->initialize();
+
+ // load objects
+ const auto objects = runner->load(plugins_folder + "/dummy-diagnose.py");
+ EXPECT_EQ(objects.size(), 3);
+
+ // load the first IPluginDiagnose
+ {
+ IPluginDiagnose* plugin = qobject_cast<IPluginDiagnose*>(objects[0]);
+ EXPECT_NE(plugin, nullptr);
+
+ ASSERT_THAT(plugin->activeProblems(), ElementsAre(1, 2));
+ EXPECT_EQ(plugin->shortDescription(1), "short-1");
+ EXPECT_EQ(plugin->fullDescription(1), "long-1");
+ EXPECT_TRUE(plugin->hasGuidedFix(1));
+ EXPECT_EQ(plugin->shortDescription(2), "short-2");
+ EXPECT_EQ(plugin->fullDescription(2), "long-2");
+ EXPECT_FALSE(plugin->hasGuidedFix(2));
+ }
+
+ // load the second one (this is cast before IPluginGame so should be before)
+ {
+ IPluginDiagnose* plugin = qobject_cast<IPluginDiagnose*>(objects[1]);
+ EXPECT_NE(plugin, nullptr);
+
+ ASSERT_THAT(plugin->activeProblems(), ElementsAre(5, 7));
+ EXPECT_EQ(plugin->shortDescription(5), "short-5");
+ EXPECT_EQ(plugin->fullDescription(5), "long-5");
+ EXPECT_FALSE(plugin->hasGuidedFix(5));
+ EXPECT_EQ(plugin->shortDescription(7), "short-7");
+ EXPECT_EQ(plugin->fullDescription(7), "long-7");
+ EXPECT_TRUE(plugin->hasGuidedFix(7));
+ }
+
+ // load the game plugin
+ {
+ IPluginGame* plugin = qobject_cast<IPluginGame*>(objects[2]);
+ EXPECT_NE(plugin, nullptr);
+ }
+}
diff --git a/libs/plugin_python/tests/runner/test_filemapper.cpp b/libs/plugin_python/tests/runner/test_filemapper.cpp
new file mode 100644
index 0000000..5044283
--- /dev/null
+++ b/libs/plugin_python/tests/runner/test_filemapper.cpp
@@ -0,0 +1,64 @@
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+
+#include "pythonrunner.h"
+
+#include <QCoreApplication>
+
+#include <uibase/ipluginfilemapper.h>
+#include <uibase/iplugingame.h>
+
+#include "MockOrganizer.h"
+
+using namespace MOBase;
+
+TEST(IPluginFileMapper, Simple)
+{
+ const auto plugins_folder = QString(std::getenv("PLUGIN_DIR"));
+
+ auto runner = mo2::python::createPythonRunner();
+ runner->initialize();
+
+ // load objects
+ const auto objects = runner->load(plugins_folder + "/dummy-filemapper.py");
+ EXPECT_EQ(objects.size(), 3);
+
+ // load the first IPluginFileMapper
+ {
+ IPluginFileMapper* plugin = qobject_cast<IPluginFileMapper*>(objects[0]);
+ EXPECT_NE(plugin, nullptr);
+
+ const auto m = plugin->mappings();
+ EXPECT_EQ(m.size(), 2);
+
+ EXPECT_EQ(m[0].source, "the source");
+ EXPECT_EQ(m[0].destination, "the destination");
+ EXPECT_EQ(m[0].isDirectory, false);
+ EXPECT_EQ(m[0].createTarget, false);
+
+ EXPECT_EQ(m[1].source, "the other source");
+ EXPECT_EQ(m[1].destination, "the other destination");
+ EXPECT_EQ(m[1].isDirectory, true);
+ EXPECT_EQ(m[1].createTarget, false);
+ }
+
+ // load the second one (this is cast before IPluginGame so should be before)
+ {
+ IPluginFileMapper* plugin = qobject_cast<IPluginFileMapper*>(objects[1]);
+ EXPECT_NE(plugin, nullptr);
+
+ const auto m = plugin->mappings();
+ EXPECT_EQ(m.size(), 1);
+
+ EXPECT_EQ(m[0].source, "the source");
+ EXPECT_EQ(m[0].destination, "the destination");
+ EXPECT_EQ(m[0].isDirectory, false);
+ EXPECT_EQ(m[0].createTarget, false);
+ }
+
+ // load the game plugin
+ {
+ IPluginGame* plugin = qobject_cast<IPluginGame*>(objects[2]);
+ EXPECT_NE(plugin, nullptr);
+ }
+}
diff --git a/libs/plugin_python/tests/runner/test_game.cpp b/libs/plugin_python/tests/runner/test_game.cpp
new file mode 100644
index 0000000..0d1d718
--- /dev/null
+++ b/libs/plugin_python/tests/runner/test_game.cpp
@@ -0,0 +1,28 @@
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+
+#include "pythonrunner.h"
+
+#include <QCoreApplication>
+
+#include <uibase/iplugingame.h>
+
+#include "MockOrganizer.h"
+
+using namespace MOBase;
+
+TEST(IPluginGame, Simple)
+{
+ const auto plugins_folder = QString(std::getenv("PLUGIN_DIR"));
+
+ auto runner = mo2::python::createPythonRunner();
+ runner->initialize();
+
+ // load objects
+ const auto objects = runner->load(plugins_folder + "/dummy-game.py");
+ EXPECT_EQ(objects.size(), 1);
+
+ // load the IPlugin
+ IPluginGame* plugin = qobject_cast<IPluginGame*>(objects[0]);
+ EXPECT_NE(plugin, nullptr);
+}
diff --git a/libs/plugin_python/tests/runner/test_installer.cpp b/libs/plugin_python/tests/runner/test_installer.cpp
new file mode 100644
index 0000000..349c904
--- /dev/null
+++ b/libs/plugin_python/tests/runner/test_installer.cpp
@@ -0,0 +1,112 @@
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+
+#include <uibase/iplugininstallersimple.h>
+
+#include "MockOrganizer.h"
+#include "pythonrunner.h"
+
+#include <QCoreApplication>
+
+#include "DummyFileTree.h"
+
+using namespace MOBase;
+
+TEST(IPluginInstaller, Simple)
+{
+ const auto plugins_folder = QString(std::getenv("PLUGIN_DIR"));
+
+ auto runner = mo2::python::createPythonRunner();
+ runner->initialize();
+
+ // load objects
+ const auto objects = runner->load(plugins_folder + "/dummy-installer.py");
+ EXPECT_EQ(objects.size(), 1);
+
+ // load the IPlugin
+ IPluginInstallerSimple* plugin = qobject_cast<IPluginInstallerSimple*>(objects[0]);
+ EXPECT_NE(plugin, nullptr);
+
+ // basic tests
+ EXPECT_EQ(plugin->priority(), 10);
+ EXPECT_EQ(plugin->isManualInstaller(), false);
+
+ GuessedValue<QString> name{"default name"};
+ QString version = "1.0.0";
+ int nexus_id = 12;
+
+ // invalid tree
+ {
+ std::shared_ptr<IFileTree> invalid_tree =
+ std::make_shared<DummyFileTree>(nullptr, "root");
+ invalid_tree->addFile("some file");
+ invalid_tree->addDirectory("some directory")->addFile("some other file");
+ EXPECT_EQ(plugin->isArchiveSupported(invalid_tree), false);
+ EXPECT_EQ(plugin->install(name, invalid_tree, version, nexus_id),
+ IPluginInstaller::RESULT_FAILED);
+
+ // extra arguments stay the same
+ EXPECT_EQ(name, "default name");
+ EXPECT_EQ(version, "1.0.0");
+ EXPECT_EQ(nexus_id, 12);
+ }
+
+ // valid simple tree
+ {
+ std::shared_ptr<IFileTree> valid_tree =
+ std::make_shared<DummyFileTree>(nullptr, "root");
+ const auto initial_tree = valid_tree;
+ valid_tree->addFile("needed-file.txt");
+ valid_tree->addFile("some file");
+ valid_tree->addDirectory("some directory")->addFile("some other file");
+
+ // valid tree
+ EXPECT_EQ(plugin->isArchiveSupported(valid_tree), true);
+ EXPECT_EQ(plugin->install(name, valid_tree, version, nexus_id),
+ IPluginInstaller::RESULT_NOTATTEMPTED);
+
+ // name is not modified
+ EXPECT_EQ(name, "default name");
+
+ // tree is not modified
+ EXPECT_EQ(valid_tree, initial_tree);
+
+ // version and ID are modified
+ EXPECT_EQ(version, "2.4.5");
+ EXPECT_EQ(nexus_id, 33);
+ }
+
+ // "complex" tree
+ {
+ std::shared_ptr<IFileTree> complex_tree =
+ std::make_shared<DummyFileTree>(nullptr, "root");
+ const auto initial_tree = complex_tree;
+ const auto needed_file = complex_tree->addFile("needed-file.txt");
+ const auto extra_file = complex_tree->addFile("extra-file.txt");
+ const auto some_file = complex_tree->addFile("some file");
+ const auto some_directory = complex_tree->addDirectory("some directory");
+ some_directory->addFile("some other file");
+
+ // valid tree
+ EXPECT_EQ(plugin->isArchiveSupported(complex_tree), true);
+ EXPECT_EQ(plugin->install(name, complex_tree, version, nexus_id),
+ IPluginInstaller::RESULT_SUCCESS);
+
+ // name is modified
+ EXPECT_EQ(name, "new name");
+
+ // tree is modified
+ EXPECT_EQ(complex_tree->findDirectory("subtree")->find("needed-file.txt"),
+ needed_file);
+ EXPECT_EQ(
+ complex_tree->findDirectory("subtree")->findDirectory("extra-file.txt"),
+ nullptr);
+ EXPECT_EQ(
+ complex_tree->findDirectory("subtree")->findDirectory("some directory"),
+ some_directory);
+
+ // version and ID are not modified (from previous call)
+ EXPECT_EQ(version, "2.4.5");
+ EXPECT_EQ(nexus_id, 33);
+ }
+}
diff --git a/libs/plugin_python/tests/runner/test_iplugin.cpp b/libs/plugin_python/tests/runner/test_iplugin.cpp
new file mode 100644
index 0000000..27c4829
--- /dev/null
+++ b/libs/plugin_python/tests/runner/test_iplugin.cpp
@@ -0,0 +1,43 @@
+#include <gmock/gmock.h>
+#include <gtest/gtest.h>
+
+#include <uibase/iplugin.h>
+
+#include "MockOrganizer.h"
+#include "pythonrunner.h"
+
+#include <QCoreApplication>
+
+using namespace MOBase;
+
+TEST(IPlugin, Basic)
+{
+ const auto plugins_folder = QString(std::getenv("PLUGIN_DIR"));
+
+ auto runner = mo2::python::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<IPlugin*>(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(), QMetaType::Type::Int);
+ EXPECT_EQ(settings[0].defaultValue.toInt(), 12);
+
+ // no translation, no custom implementation -> name()
+ EXPECT_EQ(plugin->localizedName(), "The Name");
+}
diff --git a/libs/plugin_python/tests/runner/test_lifetime.cpp b/libs/plugin_python/tests/runner/test_lifetime.cpp
new file mode 100644
index 0000000..bf057d3
--- /dev/null
+++ b/libs/plugin_python/tests/runner/test_lifetime.cpp
@@ -0,0 +1,47 @@
+#include "gtest/gtest.h"
+
+#include "MockOrganizer.h"
+#include "pythonrunner.h"
+
+#include <QCoreApplication>
+
+TEST(Lifetime, Plugins)
+{
+ const auto plugins_folder = QString(std::getenv("PLUGIN_DIR"));
+
+ auto runner = mo2::python::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/libs/plugin_python/typings/generate.py b/libs/plugin_python/typings/generate.py
new file mode 100644
index 0000000..1983186
--- /dev/null
+++ b/libs/plugin_python/typings/generate.py
@@ -0,0 +1,38 @@
+import os
+import site
+import sys
+from pathlib import Path
+from typing import cast
+
+import pybind11_stubgen as py11stubs
+
+typings_dir = Path(__file__).parent
+mobase_tests_dir = Path(__file__).parent.parent.joinpath(
+ "vsbuild", "tests", "python", "pylibs", "mobase_tests"
+)
+
+site.addsitedir(str(mobase_tests_dir.parent))
+
+os.add_dll_directory(str(Path(cast(str, os.getenv("QT_ROOT"))).joinpath("bin")))
+os.add_dll_directory(str(os.getenv("UIBASE_PATH")))
+
+from PyQt6.QtWidgets import QApplication # noqa: E402
+
+app = QApplication(sys.argv)
+
+args = py11stubs.arg_parser().parse_args(["dummy"], namespace=py11stubs.CLIArgs())
+
+parser = py11stubs.stub_parser_from_args(args)
+printer = py11stubs.Printer(invalid_expr_as_ellipses=True) # type: ignore
+
+for path in mobase_tests_dir.glob("*.pyd"):
+ name = path.name.split(".")[0]
+ py11stubs.run(
+ parser,
+ printer,
+ f"mobase_tests.{name}",
+ typings_dir.joinpath("mobase_tests"),
+ sub_dir=None,
+ dry_run=False,
+ writer=py11stubs.Writer(stub_ext="pyi"), # type: ignore
+ )
diff --git a/libs/plugin_python/typings/mobase_tests/argument_wrapper.pyi b/libs/plugin_python/typings/mobase_tests/argument_wrapper.pyi
new file mode 100644
index 0000000..a96e49a
--- /dev/null
+++ b/libs/plugin_python/typings/mobase_tests/argument_wrapper.pyi
@@ -0,0 +1,27 @@
+from __future__ import annotations
+
+__all__ = [
+ "fn1_raw",
+ "fn1_wrap",
+ "fn1_wrap_0",
+ "fn2_raw",
+ "fn2_wrap",
+ "fn2_wrap_0",
+ "fn3_raw",
+ "fn3_wrap",
+ "fn3_wrap_0",
+ "fn3_wrap_0_2",
+ "fn3_wrap_2",
+]
+
+def fn1_raw(arg0: str) -> str: ...
+def fn1_wrap(arg0: int | str) -> str: ...
+def fn1_wrap_0(arg0: int | str) -> str: ...
+def fn2_raw(arg0: int) -> int: ...
+def fn2_wrap(arg0: int | str) -> int: ...
+def fn2_wrap_0(arg0: int | str) -> int: ...
+def fn3_raw(arg0: int, arg1: list[int], arg2: str) -> str: ...
+def fn3_wrap(arg0: int | str, arg1: list[int], arg2: int | str) -> str: ...
+def fn3_wrap_0(arg0: int | str, arg1: list[int], arg2: str) -> str: ...
+def fn3_wrap_0_2(arg0: int | str, arg1: list[int], arg2: int | str) -> str: ...
+def fn3_wrap_2(arg0: int, arg1: list[int], arg2: int | str) -> str: ...
diff --git a/libs/plugin_python/typings/mobase_tests/filetree.pyi b/libs/plugin_python/typings/mobase_tests/filetree.pyi
new file mode 100644
index 0000000..93eab63
--- /dev/null
+++ b/libs/plugin_python/typings/mobase_tests/filetree.pyi
@@ -0,0 +1,9 @@
+from __future__ import annotations
+
+import mobase
+
+__all__ = ["is_directory", "is_file", "value"]
+
+def is_directory(arg0: mobase.IFileTree.FileTypes) -> bool: ...
+def is_file(arg0: mobase.IFileTree.FileTypes) -> bool: ...
+def value(arg0: mobase.IFileTree.FileTypes) -> int: ...
diff --git a/libs/plugin_python/typings/mobase_tests/functional.pyi b/libs/plugin_python/typings/mobase_tests/functional.pyi
new file mode 100644
index 0000000..7e58f0e
--- /dev/null
+++ b/libs/plugin_python/typings/mobase_tests/functional.pyi
@@ -0,0 +1,19 @@
+from __future__ import annotations
+
+import typing
+
+__all__ = ["fn_0_arg", "fn_0_or_1_arg", "fn_1_arg", "fn_1_or_2_or_3_arg", "fn_2_arg"]
+
+def fn_0_arg(arg0: typing.Callable[[], int]) -> int: ...
+@typing.overload
+def fn_0_or_1_arg(arg0: typing.Callable[[], int]) -> int: ...
+@typing.overload
+def fn_0_or_1_arg(arg0: typing.Callable[[int], int]) -> int: ...
+def fn_1_arg(arg0: typing.Callable[[int], int], arg1: int) -> int: ...
+@typing.overload
+def fn_1_or_2_or_3_arg(arg0: typing.Callable[[int], int]) -> int: ...
+@typing.overload
+def fn_1_or_2_or_3_arg(arg0: typing.Callable[[int, int], int]) -> int: ...
+@typing.overload
+def fn_1_or_2_or_3_arg(arg0: typing.Callable[[int, int, int], int]) -> int: ...
+def fn_2_arg(arg0: typing.Callable[[int, int], int], arg1: int, arg2: int) -> int: ...
diff --git a/libs/plugin_python/typings/mobase_tests/guessed_string.pyi b/libs/plugin_python/typings/mobase_tests/guessed_string.pyi
new file mode 100644
index 0000000..a03d9b6
--- /dev/null
+++ b/libs/plugin_python/typings/mobase_tests/guessed_string.pyi
@@ -0,0 +1,16 @@
+from __future__ import annotations
+
+import typing
+
+import mobase
+
+__all__ = ["get_from_callback", "get_value", "get_variants", "set_from_callback"]
+
+def get_from_callback(
+ arg0: typing.Callable[[mobase.GuessedString], None],
+) -> str: ...
+def get_value(arg0: str | mobase.GuessedString) -> str: ...
+def get_variants(arg0: str | mobase.GuessedString) -> set[str]: ...
+def set_from_callback(
+ arg0: mobase.GuessedString, arg1: typing.Callable[[mobase.GuessedString], None]
+) -> None: ...
diff --git a/libs/plugin_python/typings/mobase_tests/organizer.pyi b/libs/plugin_python/typings/mobase_tests/organizer.pyi
new file mode 100644
index 0000000..dd5136a
--- /dev/null
+++ b/libs/plugin_python/typings/mobase_tests/organizer.pyi
@@ -0,0 +1,7 @@
+from __future__ import annotations
+
+import mobase
+
+__all__ = ["organizer"]
+
+def organizer() -> mobase.IOrganizer: ...
diff --git a/libs/plugin_python/typings/mobase_tests/qt.pyi b/libs/plugin_python/typings/mobase_tests/qt.pyi
new file mode 100644
index 0000000..088ef2a
--- /dev/null
+++ b/libs/plugin_python/typings/mobase_tests/qt.pyi
@@ -0,0 +1,109 @@
+from __future__ import annotations
+
+import typing
+
+import PyQt6.QtCore
+
+from mobase import MoVariant
+
+__all__ = [
+ "SimpleEnum",
+ "consume_qstring_with_emoji",
+ "create_qstring_with_emoji",
+ "datetime_from_string",
+ "datetime_to_string",
+ "int_to_qstring",
+ "qflags_create",
+ "qflags_explode",
+ "qmap_to_length",
+ "qstring_to_int",
+ "qstring_to_stdstring",
+ "qstringlist_at",
+ "qstringlist_join",
+ "qvariant_bool",
+ "qvariant_from_bool",
+ "qvariant_from_int",
+ "qvariant_from_list",
+ "qvariant_from_map",
+ "qvariant_from_none",
+ "qvariant_from_str",
+ "qvariant_int",
+ "qvariant_list",
+ "qvariant_map",
+ "qvariant_none",
+ "qvariant_str",
+ "stdstring_to_qstring",
+]
+
+class SimpleEnum:
+ """
+ Members:
+
+ Value0
+
+ Value1
+
+ Value2
+ """
+
+ Value0: typing.ClassVar[SimpleEnum] # value = <SimpleEnum.Value0: 1>
+ Value1: typing.ClassVar[SimpleEnum] # value = <SimpleEnum.Value1: 2>
+ Value2: typing.ClassVar[SimpleEnum] # value = <SimpleEnum.Value2: 4>
+ __members__: typing.ClassVar[
+ dict[str, SimpleEnum]
+ ] # value = {'Value0': <SimpleEnum.Value0: 1>, 'Value1': <SimpleEnum.Value1: 2>, 'Value2': <SimpleEnum.Value2: 4>}
+ def __and__(self, other: typing.Any) -> typing.Any: ...
+ def __eq__(self, other: typing.Any) -> bool: ...
+ def __ge__(self, other: typing.Any) -> bool: ...
+ def __getstate__(self) -> int: ...
+ def __gt__(self, other: typing.Any) -> bool: ...
+ def __hash__(self) -> int: ...
+ def __index__(self) -> int: ...
+ def __init__(self, value: int) -> None: ...
+ def __int__(self) -> int: ...
+ def __invert__(self) -> typing.Any: ...
+ def __le__(self, other: typing.Any) -> bool: ...
+ def __lt__(self, other: typing.Any) -> bool: ...
+ def __ne__(self, other: typing.Any) -> bool: ...
+ def __or__(self, other: typing.Any) -> typing.Any: ...
+ def __rand__(self, other: typing.Any) -> typing.Any: ...
+ def __repr__(self) -> str: ...
+ def __ror__(self, other: typing.Any) -> typing.Any: ...
+ def __rxor__(self, other: typing.Any) -> typing.Any: ...
+ def __setstate__(self, state: int) -> None: ...
+ def __str__(self) -> str: ...
+ def __xor__(self, other: typing.Any) -> typing.Any: ...
+ @property
+ def name(self) -> str: ...
+ @property
+ def value(self) -> int: ...
+
+def consume_qstring_with_emoji(arg0: str) -> int: ...
+def create_qstring_with_emoji() -> str: ...
+def datetime_from_string(
+ string: str, format: PyQt6.QtCore.Qt.DateFormat = ...
+) -> PyQt6.QtCore.QDateTime: ...
+def datetime_to_string(
+ datetime: PyQt6.QtCore.QDateTime, format: PyQt6.QtCore.Qt.DateFormat = ...
+) -> str: ...
+def int_to_qstring(arg0: int) -> str: ...
+def qflags_create(arg0: bool, arg1: bool, arg2: bool) -> int: ...
+def qflags_explode(arg0: int) -> tuple[int, bool, bool, bool]: ...
+def qmap_to_length(arg0: dict[str, str]) -> dict[str, int]: ...
+def qstring_to_int(arg0: str) -> int: ...
+def qstring_to_stdstring(arg0: str) -> str: ...
+def qstringlist_at(arg0: typing.Sequence[str], arg1: int) -> str: ...
+def qstringlist_join(arg0: typing.Sequence[str], arg1: str) -> str: ...
+def qvariant_bool() -> MoVariant: ...
+def qvariant_from_bool(arg0: MoVariant) -> tuple[bool, bool]: ...
+def qvariant_from_int(arg0: MoVariant) -> tuple[bool, int]: ...
+def qvariant_from_list(arg0: MoVariant) -> tuple[bool, typing.Sequence[MoVariant]]: ...
+def qvariant_from_map(arg0: MoVariant) -> tuple[bool, dict[str, MoVariant]]: ...
+def qvariant_from_none(arg0: MoVariant) -> tuple[bool, bool]: ...
+def qvariant_from_str(arg0: MoVariant) -> tuple[bool, str]: ...
+def qvariant_int() -> MoVariant: ...
+def qvariant_list() -> MoVariant: ...
+def qvariant_map() -> dict[str, MoVariant]: ...
+def qvariant_none() -> MoVariant: ...
+def qvariant_str() -> MoVariant: ...
+def stdstring_to_qstring(arg0: str) -> str: ...
diff --git a/libs/plugin_python/typings/mobase_tests/qt_widgets.pyi b/libs/plugin_python/typings/mobase_tests/qt_widgets.pyi
new file mode 100644
index 0000000..dfda4f9
--- /dev/null
+++ b/libs/plugin_python/typings/mobase_tests/qt_widgets.pyi
@@ -0,0 +1,34 @@
+from __future__ import annotations
+
+import typing
+
+import PyQt6.QtWidgets
+
+__all__ = [
+ "CustomWidget",
+ "get",
+ "heightForWidth",
+ "is_alive",
+ "is_owned_cpp",
+ "make_widget_own_cpp",
+ "make_widget_own_py",
+ "send_to_cpp",
+ "set_parent",
+]
+
+class CustomWidget:
+ def __getattr__(self, arg0: str) -> typing.Any: ...
+ def __init__(
+ self, name: str, parent: PyQt6.QtWidgets.QWidget | None = None
+ ) -> None: ...
+ def _widget(self) -> PyQt6.QtWidgets.QWidget: ...
+ def set_parent_cpp(self) -> None: ...
+
+def get(arg0: str) -> PyQt6.QtWidgets.QWidget: ...
+def heightForWidth(arg0: str, arg1: int) -> int: ...
+def is_alive(arg0: str) -> bool: ...
+def is_owned_cpp(arg0: str) -> bool: ...
+def make_widget_own_cpp(arg0: str) -> PyQt6.QtWidgets.QWidget: ...
+def make_widget_own_py(arg0: str) -> PyQt6.QtWidgets.QWidget: ...
+def send_to_cpp(arg0: str, arg1: PyQt6.QtWidgets.QWidget) -> None: ...
+def set_parent(arg0: PyQt6.QtWidgets.QWidget) -> None: ...
diff --git a/libs/plugin_python/typings/mobase_tests/shared_cpp_owner.pyi b/libs/plugin_python/typings/mobase_tests/shared_cpp_owner.pyi
new file mode 100644
index 0000000..789632e
--- /dev/null
+++ b/libs/plugin_python/typings/mobase_tests/shared_cpp_owner.pyi
@@ -0,0 +1,22 @@
+from __future__ import annotations
+
+__all__ = [
+ "Base",
+ "call_fn",
+ "clear",
+ "create",
+ "create_and_store",
+ "is_alive",
+ "store",
+]
+
+class Base:
+ def __init__(self, arg0: str) -> None: ...
+ def fn(self) -> str: ...
+
+def call_fn(arg0: str) -> str: ...
+def clear() -> None: ...
+def create(arg0: str) -> Base: ...
+def create_and_store(arg0: str) -> Base: ...
+def is_alive(arg0: str) -> bool: ...
+def store(arg0: Base) -> None: ...
diff --git a/libs/plugin_python/vcpkg.json b/libs/plugin_python/vcpkg.json
new file mode 100644
index 0000000..db9eb08
--- /dev/null
+++ b/libs/plugin_python/vcpkg.json
@@ -0,0 +1,34 @@
+{
+ "dependencies": ["pybind11"],
+ "features": {
+ "testing": {
+ "description": "Build Plugin Python tests.",
+ "dependencies": ["gtest"]
+ },
+ "standalone": {
+ "description": "Build Standalone.",
+ "dependencies": ["mo2-cmake", "mo2-uibase"]
+ }
+ },
+ "vcpkg-configuration": {
+ "default-registry": {
+ "kind": "git",
+ "repository": "https://github.com/Microsoft/vcpkg",
+ "baseline": "294f76666c3000630d828703e675814c05a4fd43"
+ },
+ "registries": [
+ {
+ "kind": "git",
+ "repository": "https://github.com/Microsoft/vcpkg",
+ "baseline": "294f76666c3000630d828703e675814c05a4fd43",
+ "packages": ["boost*", "boost-*"]
+ },
+ {
+ "kind": "git",
+ "repository": "https://github.com/ModOrganizer2/vcpkg-registry",
+ "baseline": "228cda39fe9d1eeed789c0ef64fd1235dab3b11e",
+ "packages": ["mo2-*", "pybind11", "spdlog"]
+ }
+ ]
+ }
+}