From ddc645174f7b8f896aea7ce29a6112b6b20914dc Mon Sep 17 00:00:00 2001 From: ispyisail Date: Tue, 29 Sep 2026 15:17:16 +1300 Subject: [PATCH] Connect an AI assistant: say when Python is missing The configuration names "python" or "python3", or the Python the Windows installer can add. When none of those can run, the assistant fails with an error far from the cause, so the dialog now says so in bold, with what to do: tick "Python pour l'assistant IA" in the installer or install Python (Windows), or install Python 3 from the system's packages. Windows 10 and 11 put a python.exe in WindowsApps that only opens the Microsoft Store, so a PATH search alone reports Python where there is none. A result there is reported as possibly that shortcut. Backslashes are converted explicitly, not with QDir::fromNativeSeparators(), which leaves them alone off Windows and so could not be tested here. tst_aiassistantsetup: 17 cases (8 new: the bundled Python is taken without looking on PATH; none, python.org, the Store shortcut with either separator, and a Linux folder named WindowsApps). Each of the 4 new rules was removed in turn and the test failed. ctest 32/32. In the GUI, the same install layout shows the warning with no python3 on PATH and no warning with it. Co-Authored-By: Claude Opus 5.5 --- sources/ui/aiassistantdialog.cpp | 22 +++++++++++++ sources/ui/aiassistantsetup.cpp | 27 +++++++++++++-- sources/ui/aiassistantsetup.h | 18 +++++++++- tests/qttest/tst_aiassistantsetup.cpp | 47 +++++++++++++++++++++++++++ 4 files changed, 111 insertions(+), 3 deletions(-) diff --git a/sources/ui/aiassistantdialog.cpp b/sources/ui/aiassistantdialog.cpp index b9f7ac355..6d8542d7e 100644 --- a/sources/ui/aiassistantdialog.cpp +++ b/sources/ui/aiassistantdialog.cpp @@ -77,6 +77,28 @@ AiAssistantDialog::AiAssistantDialog(QWidget *parent) : layout->addWidget(missing); } + if (!m_paths.server.isEmpty() + && m_paths.python_status != AiAssistantSetup::PythonStatus::Found) { + QString text; + if (m_paths.python_status == AiAssistantSetup::PythonStatus::StoreShortcut) + text = tr("Python n'est peut-être pas installé. Seul le raccourci " + "« python » du Microsoft Store a été trouvé : sans Python, il " + "ouvre le Store au lieu de lancer le serveur."); + else + text = tr("Python est introuvable sur cet ordinateur (commande " + "« %1 »). Le serveur en a besoin.").arg(m_paths.python); + if (windows) + text += QLatin1Char(' ') + tr("Relancez l'installateur de QElectroTech et " + "cochez « Python pour l'assistant IA », ou " + "installez Python depuis python.org."); + else + text += QLatin1Char(' ') + tr("Installez Python 3 avec le gestionnaire " + "de paquets de votre système."); + auto *python = new QLabel(text, this); + python->setWordWrap(true); + layout->addWidget(python); + } + auto *form = new QFormLayout(); m_client = new QComboBox(this); m_client->addItem(QStringLiteral("Claude Desktop"), int(Client::ClaudeDesktop)); diff --git a/sources/ui/aiassistantsetup.cpp b/sources/ui/aiassistantsetup.cpp index d484fc6e0..6bb0923dc 100644 --- a/sources/ui/aiassistantsetup.cpp +++ b/sources/ui/aiassistantsetup.cpp @@ -22,6 +22,7 @@ #include #include #include +#include namespace { @@ -79,10 +80,13 @@ QList AiAssistantSetup::clients() @param application_dir : QCoreApplication::applicationDirPath() @param application_file : QCoreApplication::applicationFilePath() @param windows : true on Windows, where Python is "python", not "python3" + @param find_executable : looks a program up on PATH; empty means + QStandardPaths::findExecutable(). Given in tests. */ AiAssistantSetup::Paths AiAssistantSetup::detect(const QString &application_dir, const QString &application_file, - bool windows) + bool windows, + const ExecutableFinder &find_executable) { const QDir bin(application_dir); Paths paths; @@ -95,9 +99,28 @@ AiAssistantSetup::Paths AiAssistantSetup::detect(const QString &application_dir, if (windows) { const QString bundled = firstFile({ bin.filePath(QStringLiteral("../mcp/python/python.exe"))}); - if (!bundled.isEmpty()) + if (!bundled.isEmpty()) { paths.python = bundled; + paths.python_status = PythonStatus::Found; + return paths; + } } + + const ExecutableFinder find = find_executable + ? find_executable + : ExecutableFinder([](const QString &name) { + return QStandardPaths::findExecutable(name); }); + const QString found = find(paths.python); + if (found.isEmpty()) + paths.python_status = PythonStatus::Missing; + // Windows 10 and 11 put a python.exe in WindowsApps that only opens the + // Microsoft Store. A Python installed from the Store answers from there + // too, so this cannot tell the two apart: say which it may be. + else if (windows && QString(found).replace(QLatin1Char('\\'), QLatin1Char('/')) + .contains(QLatin1String("/WindowsApps/"), Qt::CaseInsensitive)) + paths.python_status = PythonStatus::StoreShortcut; + else + paths.python_status = PythonStatus::Found; return paths; } diff --git a/sources/ui/aiassistantsetup.h b/sources/ui/aiassistantsetup.h index 144a74020..799e91c63 100644 --- a/sources/ui/aiassistantsetup.h +++ b/sources/ui/aiassistantsetup.h @@ -21,6 +21,8 @@ #include #include +#include + /** @brief What an AI assistant needs to start QElectroTech's MCP server (misc/qet-mcp), and the configuration text each assistant expects. @@ -44,16 +46,30 @@ namespace AiAssistantSetup QList clients(); + /// Whether the Python the configuration names can actually run. + enum class PythonStatus { + Found, + Missing, + /// Only Windows' "python.exe" shortcut in WindowsApps, which opens + /// the Microsoft Store when Python is not installed. + StoreShortcut + }; + /// Where the pieces are. An empty server means none was found. struct Paths { QString server; ///< qet_mcp.py QString python; ///< the Python to run it with QString qet_binary; ///< this QElectroTech + PythonStatus python_status = PythonStatus::Missing; }; + /// Finds a program on PATH; QStandardPaths::findExecutable() by default. + using ExecutableFinder = std::function; + Paths detect(const QString &application_dir, const QString &application_file, - bool windows); + bool windows, + const ExecutableFinder &find_executable = ExecutableFinder()); QString configuration(Client client, const Paths &paths, diff --git a/tests/qttest/tst_aiassistantsetup.cpp b/tests/qttest/tst_aiassistantsetup.cpp index 09912077e..b7b566bcf 100644 --- a/tests/qttest/tst_aiassistantsetup.cpp +++ b/tests/qttest/tst_aiassistantsetup.cpp @@ -97,6 +97,53 @@ class TestAiAssistantSetup : public QObject QCOMPARE(p.python, QStringLiteral("python3")); } + void bundledPythonIsFoundWithoutLookingOnPath() + { + QTemporaryDir root; + touch(root.filePath(QStringLiteral("mcp/qet_mcp.py"))); + touch(root.filePath(QStringLiteral("mcp/python/python.exe"))); + QDir().mkpath(root.filePath(QStringLiteral("bin"))); + bool looked = false; + const auto p = AiAssistantSetup::detect(root.filePath(QStringLiteral("bin")), + root.filePath(QStringLiteral("bin/QElectroTech.exe")), true, + [&looked](const QString &) { looked = true; return QString(); }); + QCOMPARE(p.python_status, AiAssistantSetup::PythonStatus::Found); + QVERIFY(!looked); + } + + void pythonStatusFromPath_data() + { + QTest::addColumn("windows"); + QTest::addColumn("found"); + QTest::addColumn("status"); + using S = AiAssistantSetup::PythonStatus; + QTest::newRow("windows, none") << true << QString() << int(S::Missing); + QTest::newRow("windows, python.org") << true + << QStringLiteral("C:/Users/me/AppData/Local/Programs/Python/Python314/python.exe") << int(S::Found); + QTest::newRow("windows, Store shortcut") << true + << QStringLiteral("C:/Users/me/AppData/Local/Microsoft/WindowsApps/python.exe") << int(S::StoreShortcut); + QTest::newRow("windows, Store shortcut, backslashes") << true + << QStringLiteral("C:\\Users\\me\\AppData\\Local\\Microsoft\\WINDOWSAPPS\\python.exe") << int(S::StoreShortcut); + QTest::newRow("linux, none") << false << QString() << int(S::Missing); + QTest::newRow("linux, python3") << false << QStringLiteral("/usr/bin/python3") << int(S::Found); + QTest::newRow("linux, a folder named WindowsApps") << false + << QStringLiteral("/opt/WindowsApps/python3") << int(S::Found); + } + + void pythonStatusFromPath() + { + QFETCH(bool, windows); + QFETCH(QString, found); + QFETCH(int, status); + QTemporaryDir build; + QString asked; + const auto p = AiAssistantSetup::detect(build.path(), + build.filePath(QStringLiteral("qelectrotech")), windows, + [&](const QString &name) { asked = name; return found; }); + QCOMPARE(int(p.python_status), status); + QCOMPARE(asked, windows ? QStringLiteral("python") : QStringLiteral("python3")); + } + void noServerIsReportedAsEmpty() { QTemporaryDir build;