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;