From de4db79e5158017c002cc91c14fa9e58420e7774 Mon Sep 17 00:00:00 2001 From: SulfurNitride Date: Fri, 13 Mar 2026 06:26:18 -0500 Subject: Bundle PBS Python 3.12 + PyQt6, restore Python plugins as .py files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace portable Python approach with python-build-standalone 3.12.13 (20260310 release) bundled directly in the distribution. Python is now always available without user setup — removes the Python settings tab. - PyQt6 staged to plugins/libs/PyQt6/ with bundled Qt stripped out; patchelf'd to use our existing bundled Qt in lib/ instead of duplicating it. - Restored native-converted plugins back to .py files: Form43Checker, ScriptExtenderPluginChecker, DDSPreview, basic_games, rootbuilder, installer_omod. Removed all *_native CMake targets. - pythonrunner.cpp: removed venv/MO2_PYTHON_DIR lookup; always uses bundled Python at /python, system Python as last-resort fallback. - plugincontainer.cpp: removed fluorine/python_enabled gate (Python always loads), demoted proxy loading log messages from warn to debug, silenced [plugin-diag] stderr spam. - Dockerfile: switched from uv venv to PBS install_only tarball; PBS Python used for both build-time pybind11 compilation and runtime distribution. - build-inner.sh: aggressive Python staging (strip test/tkinter/ensurepip/ distutils/lib2to3/idlelib; wipe build-time site-packages, restore psutil+vdf). Co-Authored-By: Claude Sonnet 4.6 --- CMakeLists.txt | 6 - docker/Dockerfile | 25 +-- docker/build-inner.sh | 103 +++++++++++-- libs/plugin_python/src/runner/pythonrunner.cpp | 76 ++------- src/src/plugincontainer.cpp | 23 +-- src/src/settingsdialog.cpp | 3 - src/src/settingsdialog.ui | 161 ------------------- src/src/settingsdialogpython.cpp | 206 ------------------------- src/src/settingsdialogpython.h | 29 ---- 9 files changed, 122 insertions(+), 510 deletions(-) delete mode 100644 src/src/settingsdialogpython.cpp delete mode 100644 src/src/settingsdialogpython.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 21e13f9..3b569ac 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -217,7 +217,6 @@ add_subdirectory(libs/game_features) add_subdirectory(libs/game_bethesda) add_subdirectory(libs/installer_fomod) add_subdirectory(libs/installer_fomod_plus) -add_subdirectory(libs/installer_omod_native) add_subdirectory(libs/installer_bain) add_subdirectory(libs/installer_bundle) add_subdirectory(libs/installer_manual) @@ -227,14 +226,9 @@ add_subdirectory(libs/preview_bsa) add_subdirectory(libs/bsa_extractor) add_subdirectory(libs/bsapacker) add_subdirectory(libs/diagnose_basic) -add_subdirectory(libs/form43_checker_native) -add_subdirectory(libs/script_extender_checker_native) -add_subdirectory(libs/preview_dds_native) -add_subdirectory(libs/basic_games_native) add_subdirectory(libs/check_fnis) add_subdirectory(libs/tool_inibakery) add_subdirectory(libs/tool_inieditor) -add_subdirectory(libs/rootbuilder_native) if(BUILD_DOTNET_PLUGINS) if(WIN32 AND MSVC) add_subdirectory(libs/installer_fomod_csharp) diff --git a/docker/Dockerfile b/docker/Dockerfile index 5ab99dc..ec5653b 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -50,16 +50,23 @@ ENV CMAKE_PREFIX_PATH="/opt/qt6/6.10.2/gcc_64:${CMAKE_PREFIX_PATH}" ENV PATH="/opt/qt6/6.10.2/gcc_64/bin:${PATH}" ENV LD_LIBRARY_PATH="/opt/qt6/6.10.2/gcc_64/lib:${LD_LIBRARY_PATH}" -# ── uv (Python package manager) ── -RUN curl -LsSf https://astral.sh/uv/install.sh | sh -ENV PATH="/root/.local/bin:${PATH}" +# ── python-build-standalone 3.12 (build-time + bundled runtime) ── +# Single Python for both compiling pybind11 modules and shipping in the tarball. +# The install_only_stripped tarball is ~28MB; we strip it further in build-inner.sh. +ARG PBS_VERSION=3.12.13 +ARG PBS_DATE=20260310 +RUN curl -fL --retry 3 -o /tmp/pbs-python.tar.gz \ + "https://github.com/astral-sh/python-build-standalone/releases/download/${PBS_DATE}/cpython-${PBS_VERSION}%2B${PBS_DATE}-x86_64-unknown-linux-gnu-install_only.tar.gz" && \ + tar xzf /tmp/pbs-python.tar.gz -C /opt/ && \ + mv /opt/python /opt/python-bundled && \ + rm /tmp/pbs-python.tar.gz +ENV BUILD_PYTHON=/opt/python-bundled/bin/python3 +ENV PATH="/opt/python-bundled/bin:${PATH}" -# ── Build-time Python venv (pybind11, sip, etc.) ── -RUN uv venv /opt/build-python --python 3.13 --seed && \ - uv pip install --python /opt/build-python/bin/python \ - pybind11==2.13.6 sip psutil vdf -ENV BUILD_PYTHON=/opt/build-python/bin/python -ENV PATH="/opt/build-python/bin:${PATH}" +# ── Build-time Python packages ── +# pybind11/sip/etc. for compiling mobase.so; PyQt6 is staged into the distribution. +RUN /opt/python-bundled/bin/pip3 install --no-cache-dir \ + pybind11==2.13.6 sip psutil vdf PyQt6 # ── Rust toolchain ── RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \ diff --git a/docker/build-inner.sh b/docker/build-inner.sh index 0c0fbc7..3290069 100755 --- a/docker/build-inner.sh +++ b/docker/build-inner.sh @@ -20,9 +20,12 @@ if [ -n "${CMAKE_CXX_COMPILER_LAUNCHER:-}" ]; then CMAKE_EXTRA_ARGS+=("-DCMAKE_CXX_COMPILER_LAUNCHER=${CMAKE_CXX_COMPILER_LAUNCHER}") fi +PYTHON_ROOT="$(dirname "$(dirname "${BUILD_PY}")")" + cmake -S . -B build -G Ninja \ -DCMAKE_BUILD_TYPE=RelWithDebInfo \ -DPython_EXECUTABLE="${BUILD_PY}" \ + -DPython_ROOT_DIR="${PYTHON_ROOT}" \ ${PYBIND11_DIR:+-Dpybind11_DIR="${PYBIND11_DIR}"} \ -DBUILD_PLUGIN_PYTHON=ON \ "${CMAKE_EXTRA_ARGS[@]}" @@ -65,13 +68,7 @@ find build/libs -type f \( \ -name "libinibakery.so" -o \ -name "libbsa_extractor.so" -o \ -name "libbsa_packer.so" -o \ - -name "libproxy.so" -o \ - -name "libform43_checker_native.so" -o \ - -name "libscript_extender_checker_native.so" -o \ - -name "libpreview_dds_native.so" -o \ - -name "libbasic_games_native.so" -o \ - -name "librootbuilder_native.so" -o \ - -name "libinstaller_omod_native.so" \ + -name "libproxy.so" \ \) -exec cp -f {} "${OUT_DIR}/plugins/" \; # Python plugin loader (small — kept for optional Python support). @@ -81,10 +78,31 @@ if [ -d "build/src/src/plugins/libs" ]; then mkdir -p "${OUT_DIR}/plugins/libs" cp -f build/src/src/plugins/libs/mobase*.so "${OUT_DIR}/plugins/libs/" 2>/dev/null || true fi -# Python helper shims (needed by Python plugins when Python is enabled). +# Python helper shims for f in lzokay.py winreg.py pyCfg.py; do [ -f "build/src/src/plugins/${f}" ] && cp -f "build/src/src/plugins/${f}" "${OUT_DIR}/plugins/" done + +# Python plugins (simple single-file) +for pyfile in \ + "libs/form43_checker/src/Form43Checker.py" \ + "libs/script_extender_plugin_checker/src/ScriptExtenderPluginChecker.py" \ + "libs/preview_dds/src/DDSPreview.py" \ + "src/plugins/rootbuilder.py" \ + "src/plugins/installer_omod.py"; do + [ -f "${pyfile}" ] && cp -f "${pyfile}" "${OUT_DIR}/plugins/" +done + +# basic_games Python module (directory package) — copy the whole tree, .py files only +if [ -d "libs/basic_games" ]; then + cp -a "libs/basic_games" "${OUT_DIR}/plugins/basic_games" + # Remove non-Python clutter (metadata, lock files, vcpkg, etc.) + find "${OUT_DIR}/plugins/basic_games" \ + \( -name "*.toml" -o -name "*.lock" -o -name "*.json" \ + -o -name "*.txt" -o -name "*.md" -o -name "LICENSE" \ + -o -name "CMakeLists.txt" -o -name "CMakePresets.json" \) \ + -delete 2>/dev/null || true +fi # data/ dir (DDS headers etc., used by native plugins too). [ -d "build/src/src/plugins/data" ] && cp -a "build/src/src/plugins/data" "${OUT_DIR}/plugins/" @@ -204,9 +222,72 @@ if [ -f /usr/local/lib/libloot.so.0 ]; then ln -sf libloot.so.0 "${OUT_DIR}/lib/libloot.so" fi -# ── No portable Python runtime ── -# Python plugins are optional and use the system Python + venv when enabled. -# Native C++ plugins replace DDSPreview, Form43Checker, ScriptExtenderChecker, basic_games. +# ── Bundle PBS Python 3.12 runtime ── +# PYTHONHOME only needs lib/python3.12/ (the stdlib). We do NOT copy the +# binary, headers, static lib, or .py sources — only stripped .pyc + .so. +PBS_SRC="/opt/python-bundled" +PYTHON_OUT="${OUT_DIR}/python" +mkdir -p "${PYTHON_OUT}/lib" + +# Copy only the stdlib directory +cp -a "${PBS_SRC}/lib/python3.12" "${PYTHON_OUT}/lib/" + +# Remove only what is safe — test suites, GUI toolkits, dev tools. +# Do NOT strip network/stdlib modules; basic_games uses email, http, xml, urllib, etc. +find "${PYTHON_OUT}" -type d \( -name "test" -o -name "tests" \) \ + -exec rm -rf {} + 2>/dev/null || true +rm -rf "${PYTHON_OUT}/lib/python3.12/tkinter" +rm -rf "${PYTHON_OUT}/lib/python3.12/ensurepip" +rm -rf "${PYTHON_OUT}/lib/python3.12/distutils" +rm -rf "${PYTHON_OUT}/lib/python3.12/lib2to3" +rm -rf "${PYTHON_OUT}/lib/python3.12/idlelib" +rm -rf "${PYTHON_OUT}/lib/python3.12/turtledemo" +rm -f "${PYTHON_OUT}/lib/python3.12/turtle.py" +# Wipe site-packages entirely — build-time packages (pybind11, PyQt6, sip, etc.) +# are not needed at runtime. PyQt6 is staged separately to plugins/libs/PyQt6/. +rm -rf "${PYTHON_OUT}/lib/python3.12/site-packages" +mkdir -p "${PYTHON_OUT}/lib/python3.12/site-packages" +# Copy runtime-required packages back in +for pkg in psutil vdf; do + pkg_dir="$("${PBS_SRC}/bin/python3" -c "import importlib.util; s=importlib.util.find_spec('${pkg}'); print(s.submodule_search_locations[0] if s and s.submodule_search_locations else (s.origin if s else ''))" 2>/dev/null || true)" + if [ -d "${pkg_dir}" ]; then + cp -a "${pkg_dir}" "${PYTHON_OUT}/lib/python3.12/site-packages/" + elif [ -f "${pkg_dir}" ]; then + cp -f "${pkg_dir}" "${PYTHON_OUT}/lib/python3.12/site-packages/" + fi +done + +# Pre-compile .py → .pyc (PBS ships .py + .pyc; this ensures cache is fresh). +# We keep the .py source files — Python's SourceFileLoader requires them to +# find the corresponding __pycache__/*.pyc files. Deleting them breaks imports. +"${PBS_SRC}/bin/python3" -m compileall -q "${PYTHON_OUT}/lib/python3.12/" 2>/dev/null || true + +# Strip debug info from extension modules +find "${PYTHON_OUT}/lib/python3.12" -name "*.so" \ + -exec strip --strip-unneeded {} \; 2>/dev/null || true + +# libpython shared library goes in our lib/ (dlopen'd by librunner.so via $ORIGIN RPATH) +# Not placed inside python/ — PYTHONHOME doesn't need the shared lib alongside the stdlib. +cp -Lf "${PBS_SRC}/lib/libpython3.12.so.1.0" "${OUT_DIR}/lib/" +strip --strip-unneeded "${OUT_DIR}/lib/libpython3.12.so.1.0" 2>/dev/null || true +ln -sf libpython3.12.so.1.0 "${OUT_DIR}/lib/libpython3.12.so" +echo "Bundled PBS Python 3.12: $(du -sh "${PYTHON_OUT}" | cut -f1)" + +# ── Bundle PyQt6 (bindings only — reuse our bundled Qt, no duplicate Qt .so) ── +# PyQt6 pip wheel bundles Qt under PyQt6/Qt6/lib/ which we strip out. +# The binding .so files are patchelf'd to find our Qt in lib/. +PYQT6_SRC="$("${PBS_SRC}/bin/python3" -c 'import PyQt6, os; print(os.path.dirname(PyQt6.__file__))')" +PYQT6_OUT="${OUT_DIR}/plugins/libs/PyQt6" +mkdir -p "${PYQT6_OUT}" +cp -a "${PYQT6_SRC}/." "${PYQT6_OUT}/" +# Remove PyQt6's bundled Qt — we already have Qt in lib/ +rm -rf "${PYQT6_OUT}/Qt6" +# Patchelf all PyQt6 binding .so files to reach our lib/ via RPATH +# Path: plugins/libs/PyQt6/*.so → ../../.. = staging root → lib/ +find "${PYQT6_OUT}" -name "*.so" -exec \ + patchelf --force-rpath --set-rpath '$ORIGIN/../../../lib' {} \; 2>/dev/null || true +strip --strip-unneeded "${PYQT6_OUT}"/*.so 2>/dev/null || true +echo "Bundled PyQt6 (no Qt dupe): $(du -sh "${PYQT6_OUT}" | cut -f1)" # ── Strip all MO2 binaries ── echo "Stripping MO2 binaries..." diff --git a/libs/plugin_python/src/runner/pythonrunner.cpp b/libs/plugin_python/src/runner/pythonrunner.cpp index 0e6d596..c630530 100644 --- a/libs/plugin_python/src/runner/pythonrunner.cpp +++ b/libs/plugin_python/src/runner/pythonrunner.cpp @@ -148,64 +148,21 @@ namespace mo2::python { #endif // Determine Python home directory. - // Priority: 1) venv at ~/.local/share/fluorine/python-venv/ - // 2) MO2_PYTHON_DIR env var (legacy bundled Python) - // 3) /python (legacy bundled Python) - // 4) system Python (no PYTHONHOME needed) + // Priority: 1) /python (bundled PBS Python) + // 2) system Python (no PYTHONHOME — last resort fallback) QString pythonHome; - QString venvPath; - bool usingVenv = false; - // Check for user-created venv { - QString dataDir = QDir::homePath() + "/.local/share/fluorine"; - QString venvDir = dataDir + "/python-venv"; - if (QDir(venvDir).exists() && QFile::exists(venvDir + "/bin/python3")) { - venvPath = venvDir; - // Read pyvenv.cfg to find the base Python prefix - QFile cfg(venvDir + "/pyvenv.cfg"); - if (cfg.open(QIODevice::ReadOnly | QIODevice::Text)) { - while (!cfg.atEnd()) { - QString line = QString::fromUtf8(cfg.readLine()).trimmed(); - if (line.startsWith("home")) { - // "home = /usr/bin" → base prefix is parent of bin dir - int eq = line.indexOf('='); - if (eq >= 0) { - QString binDir = line.mid(eq + 1).trimmed(); - QDir parent(binDir); - parent.cdUp(); - pythonHome = parent.absolutePath(); - usingVenv = true; - MOBase::log::info("python: using venv at '{}', base prefix '{}'", - venvPath, pythonHome); - } - break; - } - } - } - } - } - - // Fallback: legacy bundled Python - if (pythonHome.isEmpty()) { - const char* envPy = std::getenv("MO2_PYTHON_DIR"); - if (envPy && envPy[0] != '\0') { - pythonHome = QString::fromUtf8(envPy); + QString bundled = QCoreApplication::applicationDirPath() + "/python"; + if (QDir(bundled).exists()) { + pythonHome = bundled; + MOBase::log::info("python: using bundled Python at '{}'", pythonHome); } else { - QString bundled = QCoreApplication::applicationDirPath() + "/python"; - if (QDir(bundled).exists()) { - pythonHome = bundled; - } + MOBase::log::warn("python: bundled Python not found at '{}', " + "falling back to system Python", bundled); } } - // If no bundled Python, use system Python (don't set PYTHONHOME) - if (!pythonHome.isEmpty() && !QDir(pythonHome).exists()) { - MOBase::log::warn("python: PYTHONHOME dir '{}' does not exist, " - "falling back to system Python", pythonHome); - pythonHome.clear(); - } - std::optional oldPythonHome; std::optional oldPythonPath; auto restorePythonEnv = [&]() { @@ -259,29 +216,14 @@ namespace mo2::python { setenv("PYTHONHOME", pythonHome.toUtf8().constData(), 1); } - // If using a venv, prepend its site-packages so venv packages - // (PyQt6, etc.) take priority over system site-packages. - if (usingVenv && !venvPath.isEmpty()) { - const QDir venvLibDir(venvPath + "/lib"); - const auto venvPyDirs = - venvLibDir.entryList({"python3.*"}, QDir::Dirs | QDir::NoDotAndDotDot); - if (!venvPyDirs.isEmpty()) { - QString venvSite = venvPath + "/lib/" + venvPyDirs.first() + "/site-packages"; - if (QDir(venvSite).exists()) { - corePaths.prepend(venvSite); - } - } - } - if (!corePaths.isEmpty()) { setenv("PYTHONPATH", corePaths.join(":").toUtf8().constData(), 1); } MOBase::log::debug( "python: calling Py_InitializeFromConfig, PYTHONHOME='{}', " - "venv='{}', Py_IsInitialized before={}", + "Py_IsInitialized before={}", pythonHome.isEmpty() ? "(system)" : pythonHome, - venvPath.isEmpty() ? "(none)" : venvPath, Py_IsInitialized()); // Use Py_InitializeFromConfig (Python 3.8+) for explicit error reporting. diff --git a/src/src/plugincontainer.cpp b/src/src/plugincontainer.cpp index a170c99..aa11f6c 100644 --- a/src/src/plugincontainer.cpp +++ b/src/src/plugincontainer.cpp @@ -63,10 +63,8 @@ static void ensureBundledPluginsLinked(const QString& bundledDir, } #endif -static void printPluginDiagToStderr(const QString& message) +static void printPluginDiagToStderr(const QString&) { - std::fprintf(stderr, "[plugin-diag] %s\n", qUtf8Printable(message)); - std::fflush(stderr); } // Welcome to the wonderful world of MO2 plugin management! @@ -654,7 +652,7 @@ IPlugin* PluginContainer::registerPlugin(QObject* plugin, const QString& filepat const QString pluginRoot = m_PluginPath.isEmpty() ? AppConfig::pluginsPath() : m_PluginPath; QStringList filepaths = proxy->pluginList(pluginRoot); - log::warn("proxy '{}' discovered {} proxied plugin candidate(s) in '{}'", + log::debug("proxy '{}' discovered {} proxied plugin candidate(s) in '{}'", proxy->name(), filepaths.size(), QDir::toNativeSeparators(pluginRoot)); printPluginDiagToStderr( @@ -859,7 +857,7 @@ std::vector PluginContainer::loadProxied(const QString& filepath, std::vector proxiedPlugins; try { - log::warn("loading proxied plugin candidate '{}' via proxy '{}'", + log::debug("loading proxied plugin candidate '{}' via proxy '{}'", QDir::toNativeSeparators(filepath), (proxy ? proxy->name() : QStringLiteral(""))); printPluginDiagToStderr( @@ -871,7 +869,7 @@ std::vector PluginContainer::loadProxied(const QString& filepath, // per file and do not have a good way of supporting multiple inheritance. QList matchingPlugins = proxy->load(filepath); if (matchingPlugins.isEmpty()) { - log::warn("no plugins were returned for proxied candidate '{}' via proxy '{}'", + log::debug("no plugins were returned for proxied candidate '{}' via proxy '{}'", QDir::toNativeSeparators(filepath), proxy->name()); printPluginDiagToStderr( QString("no plugins were returned for proxied candidate '%1' via proxy '%2'") @@ -930,7 +928,7 @@ std::vector PluginContainer::loadProxied(const QString& filepath, } } } - log::warn("finished proxied candidate '{}' via proxy '{}': {} plugin(s) loaded", + log::debug("finished proxied candidate '{}' via proxy '{}': {} plugin(s) loaded", QDir::toNativeSeparators(filepath), proxy->name(), proxiedPlugins.size()); printPluginDiagToStderr( @@ -1305,17 +1303,6 @@ void PluginContainer::loadPlugins() } } - // Skip the Python proxy plugin unless the user has enabled Python support - // in Settings > Python. Native C++ replacements handle all default plugins. - if (iter.fileName() == "libplugin_python.so" || - iter.fileName() == "plugin_python.dll") { - if (!QSettings().value("fluorine/python_enabled", false).toBool()) { - log::debug("plugin \"{}\" skipped (Python plugins disabled in settings)", - iter.fileName()); - continue; - } - } - if (loadCheck.isOpen()) { loadCheck.write(iter.fileName().toUtf8()); loadCheck.write("\n"); diff --git a/src/src/settingsdialog.cpp b/src/src/settingsdialog.cpp index 757c6f8..7a4c2b9 100644 --- a/src/src/settingsdialog.cpp +++ b/src/src/settingsdialog.cpp @@ -25,7 +25,6 @@ along with Mod Organizer. If not, see . #include "settingsdialogpaths.h" #include "settingsdialogplugins.h" #include "settingsdialogproton.h" -#include "settingsdialogpython.h" #include "settingsdialogtheme.h" #include "settingsdialogworkarounds.h" #include "ui_settingsdialog.h" @@ -52,8 +51,6 @@ SettingsDialog::SettingsDialog(PluginContainer* pluginContainer, Settings& setti new PluginsSettingsTab(settings, m_pluginContainer, *this))); m_tabs.push_back( std::unique_ptr(new ProtonSettingsTab(settings, *this))); - m_tabs.push_back( - std::unique_ptr(new PythonSettingsTab(settings, *this))); m_tabs.push_back( std::unique_ptr(new WorkaroundsSettingsTab(settings, *this))); } diff --git a/src/src/settingsdialog.ui b/src/src/settingsdialog.ui index 2089c06..646b9b6 100644 --- a/src/src/settingsdialog.ui +++ b/src/src/settingsdialog.ui @@ -1897,167 +1897,6 @@ If you disable this feature, MO will only display official DLCs this way. Please - - - Python - - - - - - Python Plugin Support - - - - - - Enable Python Plugins: - - - - - - - Load .py plugins (requires Python 3 and PyQt6) - - - - - - - Python Path: - - - - - - - true - - - Auto-detected - - - - - - - Detect - - - - - - - Version: - - - - - - - Not detected - - - - - - - - - - Virtual Environment - - - - - - Venv Location: - - - - - - - true - - - - - - - Status: - - - - - - - Not created - - - - - - - Create Virtual Environment - - - Creates a Python virtual environment and installs PyQt6. Required for loading .py plugins. - - - - - - - Delete Venv - - - - - - - Open Folder - - - - - - - true - - - false - - - 150 - - - - monospace - 9 - - - - - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - Workarounds diff --git a/src/src/settingsdialogpython.cpp b/src/src/settingsdialogpython.cpp deleted file mode 100644 index ebad6eb..0000000 --- a/src/src/settingsdialogpython.cpp +++ /dev/null @@ -1,206 +0,0 @@ -#include "settingsdialogpython.h" - -#include "fluorinepaths.h" -#include "ui_settingsdialog.h" - -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include - -PythonSettingsTab::PythonSettingsTab(Settings& s, SettingsDialog& d) - : QObject(&d), SettingsTab(s, d) -{ - m_venvPath = fluorineDataDir() + "/python-venv"; - ui->venvPathEdit->setText(m_venvPath); - - // Load saved state - bool pythonEnabled = QSettings().value("fluorine/python_enabled", false).toBool(); - ui->pythonEnableCheck->setChecked(pythonEnabled); - - detectPython(); - refreshVenvState(); - - QObject::connect(ui->pythonRefreshButton, &QPushButton::clicked, this, - &PythonSettingsTab::detectPython); - QObject::connect(ui->createVenvButton, &QPushButton::clicked, this, - &PythonSettingsTab::onCreateVenv); - QObject::connect(ui->deleteVenvButton, &QPushButton::clicked, this, - &PythonSettingsTab::onDeleteVenv); - QObject::connect(ui->openVenvFolderButton, &QPushButton::clicked, this, - &PythonSettingsTab::onOpenVenvFolder); -} - -void PythonSettingsTab::update() -{ - QSettings().setValue("fluorine/python_enabled", - ui->pythonEnableCheck->isChecked()); -} - -void PythonSettingsTab::detectPython() -{ - m_pythonPath = QStandardPaths::findExecutable("python3"); - if (m_pythonPath.isEmpty()) { - m_pythonPath = QStandardPaths::findExecutable("python"); - } - - if (m_pythonPath.isEmpty()) { - ui->pythonPathEdit->setText(""); - ui->pythonVersionValue->setText("Python 3 not found in PATH"); - return; - } - - ui->pythonPathEdit->setText(m_pythonPath); - - // Get version - QProcess proc; - proc.start(m_pythonPath, {"--version"}); - proc.waitForFinished(5000); - - if (proc.exitCode() == 0) { - QString version = QString::fromUtf8(proc.readAllStandardOutput()).trimmed(); - if (version.isEmpty()) { - version = QString::fromUtf8(proc.readAllStandardError()).trimmed(); - } - ui->pythonVersionValue->setText(version); - } else { - ui->pythonVersionValue->setText("Error detecting version"); - } -} - -void PythonSettingsTab::refreshVenvState() -{ - QDir venvDir(m_venvPath); - bool exists = venvDir.exists() && venvDir.exists("bin/python3"); - - if (exists) { - // Check if PyQt6 is installed - QProcess proc; - proc.start(m_venvPath + "/bin/python3", - {"-c", "import PyQt6; print(PyQt6.__file__)"}); - proc.waitForFinished(5000); - - if (proc.exitCode() == 0) { - ui->venvStatusValue->setText("Active (PyQt6 installed)"); - } else { - ui->venvStatusValue->setText("Active (PyQt6 NOT installed)"); - } - } else { - ui->venvStatusValue->setText("Not created"); - } - - ui->deleteVenvButton->setEnabled(exists); - ui->openVenvFolderButton->setEnabled(exists); -} - -void PythonSettingsTab::onCreateVenv() -{ - if (m_pythonPath.isEmpty()) { - QMessageBox::warning(parentWidget(), tr("No Python"), - tr("Python 3 was not found in PATH.\n\n" - "Install Python 3 using your package manager:\n" - " Arch: pacman -S python\n" - " Ubuntu: apt install python3 python3-venv\n" - " Fedora: dnf install python3")); - return; - } - - ui->pythonLogOutput->setVisible(true); - ui->pythonLogOutput->clear(); - ui->venvStatusValue->setText("Creating virtual environment..."); - - // Create venv - { - QProcess proc; - proc.start(m_pythonPath, {"-m", "venv", m_venvPath}); - proc.waitForFinished(30000); - - QString output = QString::fromUtf8(proc.readAllStandardOutput()); - QString errors = QString::fromUtf8(proc.readAllStandardError()); - if (!output.isEmpty()) - ui->pythonLogOutput->append(output); - if (!errors.isEmpty()) - ui->pythonLogOutput->append(errors); - - if (proc.exitCode() != 0) { - ui->venvStatusValue->setText("Failed to create venv"); - ui->pythonLogOutput->append("venv creation failed with exit code " + - QString::number(proc.exitCode())); - return; - } - } - - ui->pythonLogOutput->append("Virtual environment created. Installing PyQt6..."); - ui->venvStatusValue->setText("Installing PyQt6..."); - - // Install PyQt6 - { - QProcess proc; - proc.start(m_venvPath + "/bin/pip", - {"install", "--upgrade", "pip", "PyQt6"}); - proc.waitForFinished(120000); // PyQt6 is large, give it time - - QString output = QString::fromUtf8(proc.readAllStandardOutput()); - QString errors = QString::fromUtf8(proc.readAllStandardError()); - if (!output.isEmpty()) - ui->pythonLogOutput->append(output); - if (!errors.isEmpty()) - ui->pythonLogOutput->append(errors); - - if (proc.exitCode() != 0) { - ui->venvStatusValue->setText("PyQt6 installation failed"); - ui->pythonLogOutput->append("pip install failed with exit code " + - QString::number(proc.exitCode())); - refreshVenvState(); - return; - } - } - - ui->pythonLogOutput->append("PyQt6 installed successfully."); - MOBase::log::info("Python venv created at {}", m_venvPath); - - refreshVenvState(); -} - -void PythonSettingsTab::onDeleteVenv() -{ - QDir venvDir(m_venvPath); - if (!venvDir.exists()) { - refreshVenvState(); - return; - } - - auto result = QMessageBox::question( - parentWidget(), tr("Delete Virtual Environment"), - tr("Delete the Python virtual environment at:\n%1\n\n" - "This will disable .py plugin loading until recreated.") - .arg(m_venvPath)); - - if (result != QMessageBox::Yes) - return; - - if (venvDir.removeRecursively()) { - ui->pythonLogOutput->setVisible(false); - MOBase::log::info("Python venv deleted at {}", m_venvPath); - } else { - QMessageBox::warning(parentWidget(), tr("Error"), - tr("Failed to delete the virtual environment.")); - } - - refreshVenvState(); -} - -void PythonSettingsTab::onOpenVenvFolder() -{ - QDir venvDir(m_venvPath); - if (venvDir.exists()) { - MOBase::shell::Explore(venvDir); - } -} diff --git a/src/src/settingsdialogpython.h b/src/src/settingsdialogpython.h deleted file mode 100644 index 187d04b..0000000 --- a/src/src/settingsdialogpython.h +++ /dev/null @@ -1,29 +0,0 @@ -#ifndef SETTINGSDIALOGPYTHON_H -#define SETTINGSDIALOGPYTHON_H - -#include - -#include "settings.h" -#include "settingsdialog.h" - -class PythonSettingsTab : public QObject, public SettingsTab -{ - Q_OBJECT - -public: - PythonSettingsTab(Settings& settings, SettingsDialog& dialog); - - void update() override; - -private: - void detectPython(); - void refreshVenvState(); - void onCreateVenv(); - void onDeleteVenv(); - void onOpenVenvFolder(); - - QString m_pythonPath; - QString m_venvPath; -}; - -#endif // SETTINGSDIALOGPYTHON_H -- cgit v1.3.1