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;