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