v2.0.0
Loading...
Searching...
No Matches
python_runner.cpp
Go to the documentation of this file.
1//=============================================================================================================
20
21//=============================================================================================================
22// INCLUDES
23//=============================================================================================================
24
25#include "python_runner.h"
26
27//=============================================================================================================
28// QT INCLUDES
29//=============================================================================================================
30
31#include <QProcess>
32#include <QDebug>
33#include <QRegularExpression>
34#include <QDir>
35#include <QFile>
36#include <QFileInfo>
37#include <QElapsedTimer>
38
39//=============================================================================================================
40// USED NAMESPACES
41//=============================================================================================================
42
43using namespace UTILSLIB;
44
45//=============================================================================================================
46// DEFINE MEMBER METHODS
47//=============================================================================================================
48
50 : QObject(pParent)
51{
52}
53
54//=============================================================================================================
55
57 : QObject(pParent)
58 , m_config(config)
59{
60}
61
62//=============================================================================================================
63
65{
66 m_config = config;
67}
68
69//=============================================================================================================
70
72{
73 return m_config;
74}
75
76//=============================================================================================================
77
79{
80 m_lineCb = std::move(cb);
81}
82
83//=============================================================================================================
84
86{
87 m_progressCb = std::move(cb);
88}
89
90//=============================================================================================================
91
92PythonRunnerResult PythonRunner::run(const QString& scriptPath,
93 const QStringList& args)
94{
95 QStringList fullArgs;
96 if (m_config.unbuffered) {
97 fullArgs << QStringLiteral("-u");
98 }
99 fullArgs << scriptPath << args;
100
101 return execute(fullArgs);
102}
103
104//=============================================================================================================
105
107 const QStringList& args)
108{
109 QStringList fullArgs;
110 if (m_config.unbuffered) {
111 fullArgs << QStringLiteral("-u");
112 }
113 fullArgs << QStringLiteral("-c") << code;
114 fullArgs << args;
115
116 return execute(fullArgs);
117}
118
119//=============================================================================================================
120
122{
123 QProcess proc;
124 proc.start(m_config.pythonExe, {QStringLiteral("--version")});
125 return proc.waitForFinished(5000) &&
126 proc.exitStatus() == QProcess::NormalExit &&
127 proc.exitCode() == 0;
128}
129
130//=============================================================================================================
131
133{
134 QProcess proc;
135 proc.start(m_config.pythonExe, {QStringLiteral("--version")});
136 if (!proc.waitForFinished(5000) || proc.exitCode() != 0) {
137 return {};
138 }
139 // "Python 3.11.5\n" → "3.11.5"
140 QString out = QString::fromUtf8(proc.readAllStandardOutput()).trimmed();
141 if (out.startsWith(QStringLiteral("Python "))) {
142 return out.mid(7);
143 }
144 return out;
145}
146
147//=============================================================================================================
148
149bool PythonRunner::isPackageAvailable(const QString& packageName) const
150{
151 // Sanitize: only allow valid Python identifiers to prevent injection
152 static const QRegularExpression validPkg(QStringLiteral("^[A-Za-z_][A-Za-z0-9_.]*$"));
153 if (!validPkg.match(packageName).hasMatch()) {
154 qWarning() << "[PythonRunner] Invalid package name:" << packageName;
155 return false;
156 }
157
158 QProcess proc;
159 proc.start(m_config.pythonExe,
160 {QStringLiteral("-c"),
161 QStringLiteral("import %1").arg(packageName)});
162 return proc.waitForFinished(10000) &&
163 proc.exitStatus() == QProcess::NormalExit &&
164 proc.exitCode() == 0;
165}
166
167//=============================================================================================================
168
170{
171 if (m_config.venvDir.isEmpty()) {
172 return {};
173 }
174#ifdef Q_OS_WIN
175 return QDir(m_config.venvDir).absoluteFilePath(QStringLiteral("Scripts/python.exe"));
176#else
177 return QDir(m_config.venvDir).absoluteFilePath(QStringLiteral("bin/python"));
178#endif
179}
180
181//=============================================================================================================
182
184{
185 PythonRunnerResult result;
186
187 if (m_config.venvDir.isEmpty()) {
188 result.success = true;
189 return result;
190 }
191
192 QString venvPython = venvPythonPath();
193 bool venvExists = QFileInfo::exists(venvPython);
194
195 // Step 1: Create venv if it doesn't exist
196 if (!venvExists) {
197 qDebug() << "[PythonRunner] Creating virtual environment at:" << m_config.venvDir;
198
199 QProcess venvProc;
200 venvProc.start(m_config.pythonExe,
201 {QStringLiteral("-m"), QStringLiteral("venv"), m_config.venvDir});
202
203 if (!venvProc.waitForStarted(10000)) {
204 result.stdErr = QStringLiteral("Failed to start venv creation: ") + venvProc.errorString();
205 qWarning() << "[PythonRunner]" << result.stdErr;
206 return result;
207 }
208
209 if (!venvProc.waitForFinished(120000)) { // 2 min timeout for venv creation
210 result.stdErr = QStringLiteral("Venv creation timed out.");
211 venvProc.kill();
212 venvProc.waitForFinished(5000);
213 qWarning() << "[PythonRunner]" << result.stdErr;
214 return result;
215 }
216
217 if (venvProc.exitCode() != 0) {
218 result.exitCode = venvProc.exitCode();
219 result.stdErr = QString::fromUtf8(venvProc.readAllStandardError());
220 qWarning() << "[PythonRunner] venv creation failed:" << result.stdErr;
221 return result;
222 }
223
224 qDebug() << "[PythonRunner] Virtual environment created.";
225 } else {
226 qDebug() << "[PythonRunner] Virtual environment already exists at:" << m_config.venvDir;
227 }
228
229 // Step 2: Install dependencies
230 // Prefer pyproject.toml (packageDir) over requirements.txt
231 bool needsInstall = false;
232 QStringList pipArgs;
233
234#ifdef Q_OS_WIN
235 QString pipExe = QDir(m_config.venvDir).absoluteFilePath(QStringLiteral("Scripts/pip.exe"));
236#else
237 QString pipExe = QDir(m_config.venvDir).absoluteFilePath(QStringLiteral("bin/pip"));
238#endif
239
240 if (!m_config.packageDir.isEmpty()) {
241 // Check for pyproject.toml
242 QString tomlPath = QDir(m_config.packageDir).absoluteFilePath(QStringLiteral("pyproject.toml"));
243 if (QFileInfo::exists(tomlPath)) {
244 pipArgs << QStringLiteral("install") << m_config.packageDir;
245 needsInstall = true;
246 qDebug() << "[PythonRunner] Installing from pyproject.toml in:" << m_config.packageDir;
247 } else {
248 qWarning() << "[PythonRunner] packageDir set but no pyproject.toml found at:" << tomlPath;
249 }
250 }
251
252 if (!needsInstall && !m_config.requirementsFile.isEmpty()) {
253 if (QFileInfo::exists(m_config.requirementsFile)) {
254 pipArgs << QStringLiteral("install")
255 << QStringLiteral("-r") << m_config.requirementsFile;
256 needsInstall = true;
257 qDebug() << "[PythonRunner] Installing from requirements.txt:" << m_config.requirementsFile;
258 } else {
259 qWarning() << "[PythonRunner] requirementsFile not found:" << m_config.requirementsFile;
260 }
261 }
262
263 if (needsInstall) {
264 QProcess pipProc;
265 pipProc.start(pipExe, pipArgs);
266
267 if (!pipProc.waitForStarted(10000)) {
268 result.stdErr = QStringLiteral("Failed to start pip: ") + pipProc.errorString();
269 qWarning() << "[PythonRunner]" << result.stdErr;
270 return result;
271 }
272
273 // pip install can take a while (torch alone is ~2 GB)
274 if (!pipProc.waitForFinished(1800000)) { // 30 min timeout
275 result.stdErr = QStringLiteral("pip install timed out.");
276 result.timedOut = true;
277 pipProc.kill();
278 pipProc.waitForFinished(5000);
279 qWarning() << "[PythonRunner]" << result.stdErr;
280 return result;
281 }
282
283 result.stdOut = QString::fromUtf8(pipProc.readAllStandardOutput());
284 result.stdErr = QString::fromUtf8(pipProc.readAllStandardError());
285 result.exitCode = pipProc.exitCode();
286
287 if (pipProc.exitCode() != 0) {
288 qWarning() << "[PythonRunner] pip install failed with code" << pipProc.exitCode();
289 qWarning() << "[PythonRunner] stderr:" << result.stdErr;
290 return result;
291 }
292
293 qDebug() << "[PythonRunner] Dependencies installed successfully.";
294 }
295
296 // Step 3: Switch interpreter to the venv Python
297 m_config.pythonExe = venvPython;
298 result.success = true;
299 result.exitCode = 0;
300
301 qDebug() << "[PythonRunner] Using venv Python:" << m_config.pythonExe;
302 return result;
303}
304
305//=============================================================================================================
306
308 const QStringList& args)
309{
310 // Ensure venv is set up
311 PythonRunnerResult setupResult = ensureVenv();
312 if (!setupResult.success) {
313 return setupResult;
314 }
315
316 // Now run the script with the venv Python
317 return run(scriptPath, args);
318}
319
320//=============================================================================================================
321
322PythonRunnerResult PythonRunner::execute(const QStringList& fullArgs)
323{
324 PythonRunnerResult result;
325
326 QProcess process;
327 process.setProcessChannelMode(QProcess::SeparateChannels);
328
329 // Working directory
330 if (!m_config.workingDir.isEmpty()) {
331 process.setWorkingDirectory(m_config.workingDir);
332 }
333
334 // Extra environment variables
335 if (!m_config.extraEnv.isEmpty()) {
336 QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
337 for (const QString& kv : m_config.extraEnv) {
338 int idx = kv.indexOf(QLatin1Char('='));
339 if (idx > 0) {
340 env.insert(kv.left(idx), kv.mid(idx + 1));
341 }
342 }
343 process.setProcessEnvironment(env);
344 }
345
346 qDebug() << "[PythonRunner] Launching:" << m_config.pythonExe << fullArgs.join(QLatin1Char(' '));
347
348 process.start(m_config.pythonExe, fullArgs);
349
350 if (!process.waitForStarted(10000)) {
351 result.stdErr = QStringLiteral("Failed to start Python process: ") + process.errorString();
352 qWarning() << "[PythonRunner]" << result.stdErr;
353 return result;
354 }
355
356 // Read output line-by-line until process finishes
357 QByteArray stdOutBuf, stdErrBuf;
358 QStringList stdOutLines, stdErrLines;
359
360 auto processLines = [&](QByteArray& buffer, int channel) {
361 while (buffer.contains('\n')) {
362 int idx = buffer.indexOf('\n');
363 QString line = QString::fromUtf8(buffer.left(idx));
364 buffer.remove(0, idx + 1);
365
366 // Accumulate full output
367 if (channel == 0) stdOutLines << line;
368 else stdErrLines << line;
369
370 // Dispatch to callback
371 if (m_lineCb) {
372 m_lineCb(channel, line);
373 }
374 emit lineReceived(channel, line);
375
376 // Check for progress protocol
377 float pct = 0.0f;
378 QString msg;
379 if (parseProgressLine(line, pct, msg)) {
380 result.progressPct = pct;
381 if (m_progressCb) {
382 m_progressCb(pct, msg);
383 }
384 emit progressUpdated(pct, msg);
385 }
386
387 // Log to Qt debug system
388 if (channel == 0) {
389 qDebug() << "[Python stdout]" << line;
390 } else {
391 qWarning() << "[Python stderr]" << line;
392 }
393 }
394 };
395
396 // Poll loop with proper elapsed-time tracking
397 QElapsedTimer elapsed;
398 elapsed.start();
399
400 while (!process.waitForFinished(100)) {
401 stdOutBuf.append(process.readAllStandardOutput());
402 stdErrBuf.append(process.readAllStandardError());
403 processLines(stdOutBuf, 0);
404 processLines(stdErrBuf, 1);
405
406 // Check timeout
407 if (m_config.timeoutMsec > 0 && elapsed.elapsed() >= m_config.timeoutMsec) {
408 // Flush remaining data
409 stdOutBuf.append(process.readAllStandardOutput());
410 stdErrBuf.append(process.readAllStandardError());
411 processLines(stdOutBuf, 0);
412 processLines(stdErrBuf, 1);
413
414 result.timedOut = true;
415 result.stdErr += QStringLiteral("\nProcess timed out after %1 ms.").arg(m_config.timeoutMsec);
416 process.kill();
417 process.waitForFinished(5000);
418 qWarning() << "[PythonRunner] Process timed out after" << m_config.timeoutMsec << "ms.";
419 break;
420 }
421 }
422
423 // Flush any remaining data
424 stdOutBuf.append(process.readAllStandardOutput());
425 stdErrBuf.append(process.readAllStandardError());
426 // Process remaining complete lines
427 processLines(stdOutBuf, 0);
428 processLines(stdErrBuf, 1);
429 // Handle trailing data without newline
430 if (!stdOutBuf.isEmpty()) {
431 QString line = QString::fromUtf8(stdOutBuf);
432 stdOutLines << line;
433 if (m_lineCb) m_lineCb(0, line);
434 emit lineReceived(0, line);
435 }
436 if (!stdErrBuf.isEmpty()) {
437 QString line = QString::fromUtf8(stdErrBuf);
438 stdErrLines << line;
439 if (m_lineCb) m_lineCb(1, line);
440 emit lineReceived(1, line);
441 }
442
443 // Assemble full output from accumulated lines
444 result.stdOut = stdOutLines.join(QLatin1Char('\n'));
445 result.stdErr += stdErrLines.join(QLatin1Char('\n'));
446
447 result.exitCode = process.exitCode();
448 result.success = (!result.timedOut &&
449 process.exitStatus() == QProcess::NormalExit &&
450 result.exitCode == 0);
451
452 if (result.success) {
453 qDebug() << "[PythonRunner] Script finished successfully.";
454 } else if (!result.timedOut) {
455 qWarning() << "[PythonRunner] Script exited with code" << result.exitCode;
456 }
457
458 emit finished(result);
459
460 return result;
461}
462
463//=============================================================================================================
464
465bool PythonRunner::parseProgressLine(const QString& line,
466 float& pct,
467 QString& msg) const
468{
469 // Match: [progress] 42.5% or [progress] 42.5% Training epoch 10/50
470 static const QRegularExpression re(
471 QStringLiteral(R"(^\‍[progress\‍]\s+(\d+(?:\.\d+)?)\s*%\s*(.*)?$)"),
472 QRegularExpression::CaseInsensitiveOption);
473
474 QRegularExpressionMatch match = re.match(line.trimmed());
475 if (!match.hasMatch()) {
476 return false;
477 }
478
479 bool ok = false;
480 pct = match.captured(1).toFloat(&ok);
481 if (!ok) {
482 return false;
483 }
484 msg = match.captured(2).trimmed();
485 return true;
486}
Embedding-free launcher that runs MNE-Python (or any user) scripts as a child process and streams the...
Shared utilities (I/O helpers, spectral analysis, layout management, warp algorithms).
std::function< void(float pct, const QString &msg)> PythonProgressCallback
std::function< void(int channel, const QString &line)> PythonLineCallback
Script execution result container.
Script execution configuration.
const PythonRunnerConfig & config() const
void setLineCallback(PythonLineCallback cb)
void setConfig(const PythonRunnerConfig &config)
QString pythonVersion() const
QString venvPythonPath() const
void progressUpdated(float pct, const QString &msg)
bool isPackageAvailable(const QString &packageName) const
PythonRunnerResult runCode(const QString &code, const QStringList &args={})
PythonRunnerResult ensureVenv()
PythonRunnerResult run(const QString &scriptPath, const QStringList &args={})
void setProgressCallback(PythonProgressCallback cb)
void finished(const UTILSLIB::PythonRunnerResult &result)
PythonRunnerResult runInVenv(const QString &scriptPath, const QStringList &args={})
PythonRunner(QObject *pParent=nullptr)
void lineReceived(int channel, const QString &line)