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 // find_spec locates the package without executing it; a full import of e.g.
159 // sklearn can exceed the timeout on a loaded machine and read as "not installed".
160 QProcess proc;
161 proc.start(m_config.pythonExe,
162 {QStringLiteral("-c"),
163 QStringLiteral("import importlib.util, sys; "
164 "sys.exit(0 if importlib.util.find_spec('%1') else 1)")
165 .arg(packageName)});
166 if (!proc.waitForFinished(10000)) {
167 proc.kill();
168 proc.waitForFinished(1000);
169 return false;
170 }
171 return proc.exitStatus() == QProcess::NormalExit &&
172 proc.exitCode() == 0;
173}
174
175//=============================================================================================================
176
178{
179 if (m_config.venvDir.isEmpty()) {
180 return {};
181 }
182#ifdef Q_OS_WIN
183 return QDir(m_config.venvDir).absoluteFilePath(QStringLiteral("Scripts/python.exe"));
184#else
185 return QDir(m_config.venvDir).absoluteFilePath(QStringLiteral("bin/python"));
186#endif
187}
188
189//=============================================================================================================
190
192{
193 PythonRunnerResult result;
194
195 if (m_config.venvDir.isEmpty()) {
196 result.success = true;
197 return result;
198 }
199
200 QString venvPython = venvPythonPath();
201 bool venvExists = QFileInfo::exists(venvPython);
202
203 // Step 1: Create venv if it doesn't exist
204 if (!venvExists) {
205 qDebug() << "[PythonRunner] Creating virtual environment at:" << m_config.venvDir;
206
207 QProcess venvProc;
208 venvProc.start(m_config.pythonExe,
209 {QStringLiteral("-m"), QStringLiteral("venv"), m_config.venvDir});
210
211 if (!venvProc.waitForStarted(10000)) {
212 result.stdErr = QStringLiteral("Failed to start venv creation: ") + venvProc.errorString();
213 qWarning() << "[PythonRunner]" << result.stdErr;
214 return result;
215 }
216
217 if (!venvProc.waitForFinished(120000)) { // 2 min timeout for venv creation
218 result.stdErr = QStringLiteral("Venv creation timed out.");
219 venvProc.kill();
220 venvProc.waitForFinished(5000);
221 qWarning() << "[PythonRunner]" << result.stdErr;
222 return result;
223 }
224
225 if (venvProc.exitCode() != 0) {
226 result.exitCode = venvProc.exitCode();
227 result.stdErr = QString::fromUtf8(venvProc.readAllStandardError());
228 qWarning() << "[PythonRunner] venv creation failed:" << result.stdErr;
229 return result;
230 }
231
232 qDebug() << "[PythonRunner] Virtual environment created.";
233 } else {
234 qDebug() << "[PythonRunner] Virtual environment already exists at:" << m_config.venvDir;
235 }
236
237 // Step 2: Install dependencies
238 // Prefer pyproject.toml (packageDir) over requirements.txt
239 bool needsInstall = false;
240 QStringList pipArgs;
241
242#ifdef Q_OS_WIN
243 QString pipExe = QDir(m_config.venvDir).absoluteFilePath(QStringLiteral("Scripts/pip.exe"));
244#else
245 QString pipExe = QDir(m_config.venvDir).absoluteFilePath(QStringLiteral("bin/pip"));
246#endif
247
248 if (!m_config.packageDir.isEmpty()) {
249 // Check for pyproject.toml
250 QString tomlPath = QDir(m_config.packageDir).absoluteFilePath(QStringLiteral("pyproject.toml"));
251 if (QFileInfo::exists(tomlPath)) {
252 pipArgs << QStringLiteral("install") << m_config.packageDir;
253 needsInstall = true;
254 qDebug() << "[PythonRunner] Installing from pyproject.toml in:" << m_config.packageDir;
255 } else {
256 qWarning() << "[PythonRunner] packageDir set but no pyproject.toml found at:" << tomlPath;
257 }
258 }
259
260 if (!needsInstall && !m_config.requirementsFile.isEmpty()) {
261 if (QFileInfo::exists(m_config.requirementsFile)) {
262 pipArgs << QStringLiteral("install")
263 << QStringLiteral("-r") << m_config.requirementsFile;
264 needsInstall = true;
265 qDebug() << "[PythonRunner] Installing from requirements.txt:" << m_config.requirementsFile;
266 } else {
267 qWarning() << "[PythonRunner] requirementsFile not found:" << m_config.requirementsFile;
268 }
269 }
270
271 if (needsInstall) {
272 QProcess pipProc;
273 pipProc.start(pipExe, pipArgs);
274
275 if (!pipProc.waitForStarted(10000)) {
276 result.stdErr = QStringLiteral("Failed to start pip: ") + pipProc.errorString();
277 qWarning() << "[PythonRunner]" << result.stdErr;
278 return result;
279 }
280
281 // pip install can take a while (torch alone is ~2 GB)
282 if (!pipProc.waitForFinished(1800000)) { // 30 min timeout
283 result.stdErr = QStringLiteral("pip install timed out.");
284 result.timedOut = true;
285 pipProc.kill();
286 pipProc.waitForFinished(5000);
287 qWarning() << "[PythonRunner]" << result.stdErr;
288 return result;
289 }
290
291 result.stdOut = QString::fromUtf8(pipProc.readAllStandardOutput());
292 result.stdErr = QString::fromUtf8(pipProc.readAllStandardError());
293 result.exitCode = pipProc.exitCode();
294
295 if (pipProc.exitCode() != 0) {
296 qWarning() << "[PythonRunner] pip install failed with code" << pipProc.exitCode();
297 qWarning() << "[PythonRunner] stderr:" << result.stdErr;
298 return result;
299 }
300
301 qDebug() << "[PythonRunner] Dependencies installed successfully.";
302 }
303
304 // Step 3: Switch interpreter to the venv Python
305 m_config.pythonExe = venvPython;
306 result.success = true;
307 result.exitCode = 0;
308
309 qDebug() << "[PythonRunner] Using venv Python:" << m_config.pythonExe;
310 return result;
311}
312
313//=============================================================================================================
314
316 const QStringList& args)
317{
318 // Ensure venv is set up
319 PythonRunnerResult setupResult = ensureVenv();
320 if (!setupResult.success) {
321 return setupResult;
322 }
323
324 // Now run the script with the venv Python
325 return run(scriptPath, args);
326}
327
328//=============================================================================================================
329
330PythonRunnerResult PythonRunner::execute(const QStringList& fullArgs)
331{
332 PythonRunnerResult result;
333
334 QProcess process;
335 process.setProcessChannelMode(QProcess::SeparateChannels);
336
337 // Working directory
338 if (!m_config.workingDir.isEmpty()) {
339 process.setWorkingDirectory(m_config.workingDir);
340 }
341
342 // Extra environment variables
343 if (!m_config.extraEnv.isEmpty()) {
344 QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
345 for (const QString& kv : m_config.extraEnv) {
346 int idx = kv.indexOf(QLatin1Char('='));
347 if (idx > 0) {
348 env.insert(kv.left(idx), kv.mid(idx + 1));
349 }
350 }
351 process.setProcessEnvironment(env);
352 }
353
354 qDebug() << "[PythonRunner] Launching:" << m_config.pythonExe << fullArgs.join(QLatin1Char(' '));
355
356 process.start(m_config.pythonExe, fullArgs);
357
358 if (!process.waitForStarted(10000)) {
359 result.stdErr = QStringLiteral("Failed to start Python process: ") + process.errorString();
360 qWarning() << "[PythonRunner]" << result.stdErr;
361 return result;
362 }
363
364 // Read output line-by-line until process finishes
365 QByteArray stdOutBuf, stdErrBuf;
366 QStringList stdOutLines, stdErrLines;
367
368 auto processLines = [&](QByteArray& buffer, int channel) {
369 while (buffer.contains('\n')) {
370 int idx = buffer.indexOf('\n');
371 QString line = QString::fromUtf8(buffer.left(idx));
372 buffer.remove(0, idx + 1);
373
374 // Accumulate full output
375 if (channel == 0)
376 stdOutLines << line;
377 else
378 stdErrLines << line;
379
380 // Dispatch to callback
381 if (m_lineCb) {
382 m_lineCb(channel, line);
383 }
384 emit lineReceived(channel, line);
385
386 // Check for progress protocol
387 float pct = 0.0f;
388 QString msg;
389 if (parseProgressLine(line, pct, msg)) {
390 result.progressPct = pct;
391 if (m_progressCb) {
392 m_progressCb(pct, msg);
393 }
394 emit progressUpdated(pct, msg);
395 }
396
397 // Log to Qt debug system
398 if (channel == 0) {
399 qDebug() << "[Python stdout]" << line;
400 } else {
401 qWarning() << "[Python stderr]" << line;
402 }
403 }
404 };
405
406 // Poll loop with proper elapsed-time tracking
407 QElapsedTimer elapsed;
408 elapsed.start();
409
410 while (!process.waitForFinished(100)) {
411 stdOutBuf.append(process.readAllStandardOutput());
412 stdErrBuf.append(process.readAllStandardError());
413 processLines(stdOutBuf, 0);
414 processLines(stdErrBuf, 1);
415
416 // Check timeout
417 if (m_config.timeoutMsec > 0 && elapsed.elapsed() >= m_config.timeoutMsec) {
418 // Flush remaining data
419 stdOutBuf.append(process.readAllStandardOutput());
420 stdErrBuf.append(process.readAllStandardError());
421 processLines(stdOutBuf, 0);
422 processLines(stdErrBuf, 1);
423
424 result.timedOut = true;
425 result.stdErr += QStringLiteral("\nProcess timed out after %1 ms.").arg(m_config.timeoutMsec);
426 process.kill();
427 process.waitForFinished(5000);
428 qWarning() << "[PythonRunner] Process timed out after" << m_config.timeoutMsec << "ms.";
429 break;
430 }
431 }
432
433 // Flush any remaining data
434 stdOutBuf.append(process.readAllStandardOutput());
435 stdErrBuf.append(process.readAllStandardError());
436 // Process remaining complete lines
437 processLines(stdOutBuf, 0);
438 processLines(stdErrBuf, 1);
439 // Handle trailing data without newline
440 if (!stdOutBuf.isEmpty()) {
441 QString line = QString::fromUtf8(stdOutBuf);
442 stdOutLines << line;
443 if (m_lineCb)
444 m_lineCb(0, line);
445 emit lineReceived(0, line);
446 }
447 if (!stdErrBuf.isEmpty()) {
448 QString line = QString::fromUtf8(stdErrBuf);
449 stdErrLines << line;
450 if (m_lineCb)
451 m_lineCb(1, line);
452 emit lineReceived(1, line);
453 }
454
455 // Assemble full output from accumulated lines
456 result.stdOut = stdOutLines.join(QLatin1Char('\n'));
457 result.stdErr += stdErrLines.join(QLatin1Char('\n'));
458
459 result.exitCode = process.exitCode();
460 result.success = (!result.timedOut &&
461 process.exitStatus() == QProcess::NormalExit &&
462 result.exitCode == 0);
463
464 if (result.success) {
465 qDebug() << "[PythonRunner] Script finished successfully.";
466 } else if (!result.timedOut) {
467 qWarning() << "[PythonRunner] Script exited with code" << result.exitCode;
468 }
469
470 emit finished(result);
471
472 return result;
473}
474
475//=============================================================================================================
476
477bool PythonRunner::parseProgressLine(const QString& line,
478 float& pct,
479 QString& msg) const
480{
481 // Match: [progress] 42.5% or [progress] 42.5% Training epoch 10/50
482 static const QRegularExpression re(
483 QStringLiteral(R"(^\‍[progress\‍]\s+(\d+(?:\.\d+)?)\s*%\s*(.*)?$)"),
484 QRegularExpression::CaseInsensitiveOption);
485
486 QRegularExpressionMatch match = re.match(line.trimmed());
487 if (!match.hasMatch()) {
488 return false;
489 }
490
491 bool ok = false;
492 pct = match.captured(1).toFloat(&ok);
493 if (!ok) {
494 return false;
495 }
496 msg = match.captured(2).trimmed();
497 return true;
498}
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)