Skip to content

Commit

Permalink
Run system commands with !
Browse files Browse the repository at this point in the history
  • Loading branch information
YoannQDQ authored and nyalldawson committed Apr 24, 2023
1 parent 4dfad6c commit 73e9596
Show file tree
Hide file tree
Showing 3 changed files with 218 additions and 11 deletions.
1 change: 1 addition & 0 deletions python/console/CMakeLists.txt
Expand Up @@ -7,6 +7,7 @@ set(PY_CONSOLE_FILES
console_output.py
console_editor.py
console_compile_apis.py
process_wrapper.py
__init__.py
)

Expand Down
56 changes: 45 additions & 11 deletions python/console/console_sci.py
Expand Up @@ -37,6 +37,8 @@
QgsCodeInterpreter
)

from .process_wrapper import ProcessWrapper

_init_statements = [
# Python
"import sys",
Expand Down Expand Up @@ -69,10 +71,12 @@

class PythonInterpreter(QgsCodeInterpreter, code.InteractiveInterpreter):

def __init__(self):
def __init__(self, shell):
super(QgsCodeInterpreter, self).__init__()
code.InteractiveInterpreter.__init__(self, locals=None)

self.shell = shell
self.sub_process = None
self.buffer = []

for statement in _init_statements:
Expand All @@ -82,7 +86,21 @@ def __init__(self):
pass

def execCommandImpl(self, cmd, show_input=True):
res = self.currentState()

# Child process running, input should be sent to it
if self.currentState() == 2:
sys.stdout.write(cmd + "\n")
self.sub_process.write(cmd)
return 0

if self.currentState() == 0:
cmd = cmd.strip()
if cmd.startswith("!"):
self.writeCMD(cmd)
cmd = cmd[1:]
self.sub_process = ProcessWrapper(cmd)
self.sub_process.finished.connect(self.processFinished)
return 0

if show_input:
self.writeCMD(cmd)
Expand Down Expand Up @@ -130,8 +148,22 @@ def excepthook(etype, value, tb):
finally:
sys.excepthook = hook

def currentState(self):
if self.sub_process:
return 2
return super().currentState()

def promptForState(self, state):
return "..." if state == 1 else ">>>"
if state == 2:
return " : "
elif state == 1:
return "..."
else:
return ">>>"

def processFinished(self, errorcode):
self.sub_process = None
self.shell.updatePrompt()


class ShellScintilla(QgsCodeEditorPython):
Expand All @@ -144,7 +176,7 @@ def __init__(self, parent=None):
flags=QgsCodeEditor.Flags(QgsCodeEditor.Flag.CodeFolding | QgsCodeEditor.Flag.ImmediatelyUpdateHistory))

self.parent = parent
self._interpreter = PythonInterpreter()
self._interpreter = PythonInterpreter(self)
self.setInterpreter(self._interpreter)

self.opening = ['(', '{', '[', "'", '"']
Expand Down Expand Up @@ -201,16 +233,18 @@ def on_persistent_history_cleared(self):
self.parent.callWidgetMessageBar(msgText)

def keyPressEvent(self, e):
# update the live history
self.updateSoftHistory()

# keyboard interrupt
if e.modifiers() & (
Qt.ControlModifier | Qt.MetaModifier) and e.key() == Qt.Key_C and not self.hasSelectedText():
sys.stdout.fire_keyboard_interrupt = True
if e.modifiers() & (Qt.ControlModifier | Qt.MetaModifier) and e.key() == Qt.Key_C and not self.hasSelectedText():
if self._interpreter.sub_process:
sys.stderr.write("Terminate child process\n")
self._interpreter.sub_process.kill()
self._interpreter.sub_process = None
self.updatePrompt()
return

QgsCodeEditorPython.keyPressEvent(self, e)
# update the live history
self.updateSoftHistory()
super().keyPressEvent(e)
self.updatePrompt()

def mousePressEvent(self, e):
Expand Down
172 changes: 172 additions & 0 deletions python/console/process_wrapper.py
@@ -0,0 +1,172 @@

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)

def __init__(self, command, parent=None):
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)

# 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})"
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:
self.p.stdin.write(data.encode("utf8"))
self.p.stdin.close()
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
# (i.e a text editor lauched with !gedit would not close) so we need to iterate
# over the child processes to kill them all

try:
import psutil
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

0 comments on commit 73e9596

Please sign in to comment.