aboutsummaryrefslogtreecommitdiff
path: root/libs/plugin_python/src/runner
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/src/runner
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/src/runner')
-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
6 files changed, 764 insertions, 0 deletions
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