2023-04-01 14:20:44 +02:00
|
|
|
"""
|
|
|
|
***************************************************************************
|
|
|
|
process_wrapper.py
|
|
|
|
---------------------
|
|
|
|
Date : February 2023
|
|
|
|
Copyright : (C) 2023 by Yoann Quenach de Quivillic
|
|
|
|
Email : yoann dot quenach at gmail dot com
|
|
|
|
***************************************************************************
|
|
|
|
* *
|
|
|
|
* This program is free software; you can redistribute it and/or modify *
|
|
|
|
* it under the terms of the GNU General Public License as published by *
|
|
|
|
* the Free Software Foundation; either version 2 of the License, or *
|
|
|
|
* (at your option) any later version. *
|
|
|
|
* *
|
|
|
|
***************************************************************************
|
|
|
|
"""
|
|
|
|
|
2023-04-01 14:20:44 +02:00
|
|
|
import locale
|
|
|
|
import os
|
|
|
|
import subprocess
|
|
|
|
import signal
|
|
|
|
import sys
|
|
|
|
import time
|
|
|
|
from queue import Queue, Empty
|
|
|
|
from threading import Thread
|
|
|
|
|
|
|
|
from qgis.PyQt.QtCore import QObject, pyqtSignal
|
|
|
|
|
|
|
|
|
|
|
|
class ProcessWrapper(QObject):
|
|
|
|
|
|
|
|
finished = pyqtSignal(int)
|
|
|
|
|
2023-04-01 14:20:44 +02:00
|
|
|
def __init__(self, command, interactive=True, parent=None):
|
2023-04-01 14:20:44 +02:00
|
|
|
super().__init__(parent)
|
|
|
|
|
|
|
|
self.stdout = ""
|
|
|
|
self.stderr = ""
|
|
|
|
self.returncode = None
|
|
|
|
|
|
|
|
options = {
|
|
|
|
"stdout": subprocess.PIPE,
|
|
|
|
"stdin": subprocess.PIPE,
|
|
|
|
"stderr": subprocess.PIPE,
|
|
|
|
"shell": True,
|
|
|
|
}
|
|
|
|
|
|
|
|
# On Unix, we can use os.setsid
|
|
|
|
# This will allow killing the process and its children when pressing Ctrl+C if psutil is not available
|
|
|
|
if hasattr(os, "setsid"):
|
|
|
|
options["preexec_fn"] = os.setsid
|
|
|
|
|
|
|
|
# Create and start subprocess
|
|
|
|
self.p = subprocess.Popen(command, **options)
|
|
|
|
|
2023-04-01 14:20:44 +02:00
|
|
|
# Start in non-interactive mode, wait for the process to finish
|
|
|
|
if not interactive:
|
|
|
|
out, err = self.p.communicate()
|
|
|
|
self.stdout = self.decode(out)
|
|
|
|
self.stderr = self.decode(err)
|
|
|
|
self.returncode = self.p.returncode
|
|
|
|
return
|
|
|
|
|
2023-04-01 14:20:44 +02:00
|
|
|
# Read process stdout and push to out queue
|
|
|
|
self.q_out = Queue()
|
|
|
|
self.t_out = Thread(
|
|
|
|
daemon=True, target=self.enqueue_output, args=[self.p.stdout, self.q_out]
|
|
|
|
)
|
|
|
|
self.t_out.start()
|
|
|
|
|
|
|
|
# Read process stderr and push to err queue
|
|
|
|
self.q_err = Queue()
|
|
|
|
self.t_err = Thread(
|
|
|
|
daemon=True, target=self.enqueue_output, args=[self.p.stderr, self.q_err]
|
|
|
|
)
|
|
|
|
self.t_err.start()
|
|
|
|
|
|
|
|
# Polls process and output both queues content to sys.stdout and sys.stderr
|
|
|
|
self.t_queue = Thread(daemon=True, target=self.dequeue_output)
|
|
|
|
self.t_queue.start()
|
|
|
|
|
|
|
|
def enqueue_output(self, stream, queue):
|
|
|
|
while True:
|
|
|
|
# We have to read the character one by one to ensure to
|
|
|
|
# forward every available character to the queue
|
|
|
|
# self.p.stdout.readline would block on a unfinished line
|
|
|
|
char = stream.read(1)
|
|
|
|
if not char:
|
|
|
|
# Process terminated
|
|
|
|
break
|
|
|
|
queue.put(char)
|
|
|
|
stream.close()
|
|
|
|
|
|
|
|
def __repr__(self):
|
|
|
|
"""Helpful representation of the maanaged process"""
|
|
|
|
status = (
|
|
|
|
"Running" if self.returncode is None else f"Completed ({self.returncode})"
|
2024-11-29 14:26:30 +01:00
|
|
|
)
|
2023-04-01 14:20:44 +02:00
|
|
|
repr = f"ProcessWrapper object at {hex(id(self))}"
|
|
|
|
repr += f"\n - Status: {status}"
|
|
|
|
repr += f"\n - stdout: {self.stdout}"
|
|
|
|
repr += f"\n - stderr: {self.stderr}"
|
|
|
|
return repr
|
|
|
|
|
|
|
|
def decode(self, bytes):
|
|
|
|
try:
|
|
|
|
# Try to decode the content as utf-8 first
|
|
|
|
text = bytes.decode("utf8")
|
|
|
|
except UnicodeDecodeError:
|
|
|
|
try:
|
|
|
|
# If it fails, fallback to the default locale encoding
|
|
|
|
text = bytes.decode(locale.getdefaultlocale()[1])
|
|
|
|
except UnicodeDecodeError:
|
|
|
|
# If everything fails, use representation
|
|
|
|
text = str(bytes)[2:-1]
|
|
|
|
return text
|
|
|
|
|
|
|
|
def read_content(self, queue, stream, is_stderr):
|
|
|
|
"""Write queue content to the standard stream and append it to the internal buffer"""
|
|
|
|
content = b""
|
|
|
|
while True:
|
|
|
|
try:
|
|
|
|
# While queue contains data, append it to content
|
|
|
|
content += queue.get_nowait()
|
|
|
|
except Empty:
|
|
|
|
text = self.decode(content)
|
|
|
|
if text:
|
|
|
|
# Append to the internal buffer
|
|
|
|
if is_stderr:
|
|
|
|
self.stderr += text
|
|
|
|
else:
|
|
|
|
self.stdout += text
|
|
|
|
|
|
|
|
stream.write(text)
|
|
|
|
return
|
|
|
|
|
|
|
|
def dequeue_output(self):
|
|
|
|
"""Check process every 0.1s and forward its outputs to stdout and stderr"""
|
|
|
|
|
|
|
|
# Poll process and forward its outputs to stdout and stderr
|
|
|
|
while self.p.poll() is None:
|
|
|
|
time.sleep(0.1)
|
|
|
|
self.read_content(self.q_out, sys.stdout, is_stderr=False)
|
|
|
|
self.read_content(self.q_err, sys.stderr, is_stderr=True)
|
|
|
|
|
|
|
|
# At this point, the process has terminated, so we wait for the threads to finish
|
|
|
|
self.t_out.join()
|
|
|
|
self.t_err.join()
|
|
|
|
|
|
|
|
# Reaf the remaining content of the queues
|
|
|
|
self.read_content(self.q_out, sys.stdout, is_stderr=False)
|
|
|
|
self.read_content(self.q_err, sys.stderr, is_stderr=True)
|
|
|
|
|
|
|
|
# Set returncode and emit finished signal
|
|
|
|
self.returncode = self.p.returncode
|
|
|
|
self.finished.emit(self.returncode)
|
|
|
|
|
|
|
|
def wait(self, timeout=1):
|
|
|
|
"""Wait for the managed process to finish. If timeout=-1, waits indefinitely (and freeze the GUI)"""
|
|
|
|
self.p.wait(timeout)
|
|
|
|
|
|
|
|
def write(self, data):
|
|
|
|
"""Send data to the managed process"""
|
|
|
|
try:
|
2023-04-01 14:20:44 +02:00
|
|
|
self.p.stdin.write((data + "\n").encode("utf8"))
|
|
|
|
self.p.stdin.flush()
|
2023-04-01 14:20:44 +02:00
|
|
|
except BrokenPipeError as exc:
|
|
|
|
self.p.stdout.close()
|
|
|
|
self.p.stderr.close()
|
|
|
|
self.finished.emit(self.p.poll())
|
|
|
|
|
|
|
|
def kill(self):
|
|
|
|
"""Kill the managed process"""
|
|
|
|
|
|
|
|
# Process in run with shell=True, so calling self.p.kill() would only kill the shell
|
2023-04-01 14:20:44 +02:00
|
|
|
# (i.e a text editor launched with !gedit would not close) so we need to iterate
|
2023-04-01 14:20:44 +02:00
|
|
|
# over the child processes to kill them all
|
|
|
|
|
|
|
|
try:
|
|
|
|
import psutil
|
2024-11-29 14:26:30 +01:00
|
|
|
|
2023-04-01 14:20:44 +02:00
|
|
|
if self.p.returncode is None:
|
|
|
|
process = psutil.Process(self.p.pid)
|
|
|
|
for child_process in process.children(recursive=True):
|
|
|
|
child_process.kill()
|
|
|
|
process.kill()
|
|
|
|
except ImportError:
|
|
|
|
# If psutil is not available, we try to use os.killpg to kill the process group (Unix only)
|
|
|
|
try:
|
|
|
|
os.killpg(os.getpgid(self.p.pid), signal.SIGTERM)
|
|
|
|
except AttributeError:
|
|
|
|
# If everything fails, simply kill the process. Children will not be killed
|
|
|
|
self.p.kill()
|
|
|
|
|
|
|
|
def __del__(self):
|
|
|
|
"""Ensure streams are closed when the process is destroyed"""
|
|
|
|
self.p.stdout.close()
|
|
|
|
self.p.stderr.close()
|
|
|
|
self.p.stdin.close()
|
|
|
|
try:
|
|
|
|
self.kill()
|
|
|
|
except ProcessLookupError:
|
|
|
|
pass
|