2

I'm writing a PyQt programe where I'd like to allow the user to launch their preferred editor to fill in a TextEdit field.

So the goal is to launch an editor (say vim) externally on a tmp file, and upon editor closing, get its contexts into a python variable.

I've found a few similar questions like Opening vi from Python, call up an EDITOR (vim) from a python script, invoke an editor ( vim ) in python. But they are all in a "blocking" manner that works like the git commit command. What I am after is a "non-blocking" manner (because it is a GUI), something like the "Edit Source" function in zimwiki.

My current attempt:

import os
import tempfile
import threading
import subprocess

def popenAndCall(onExit, popenArgs):

    def runInThread(onExit, popenArgs):
        tmppath=popenArgs[-1]
        proc = subprocess.Popen(popenArgs)
        # this immediately finishes OPENING vim.
        rec=proc.wait()
        print('# <runInThread>: rec=', rec)
        onExit(tmppath)
        os.remove(tmppath)
        return

    thread = threading.Thread(target=runInThread, args=(onExit, popenArgs))
    thread.start()
    return thread

def openEditor():

    fd, filepath=tempfile.mkstemp()
    print('filepath=',filepath)

    def cb(tmppath):
        print('# <cb>: cb tmppath=',tmppath)
        with open(tmppath, 'r') as tmp:
            lines=tmp.readlines()
            for ii in lines:
                print('# <cb>: ii',ii)
        return

    with os.fdopen(fd, 'w') as tmp:

        cmdflag='--'
        editor_cmd='vim'
        cmd=[os.environ['TERMCMD'], cmdflag, editor_cmd, filepath]
        print('#cmd = ',cmd)

        popenAndCall(cb, cmd)
        print('done')

    return


if __name__=='__main__':

    openEditor()

I think it failed because the Popen.wait() only waits until the editor is opened, not until its closing. So it captures nothing from the editor.

Any idea how to solve this? Thanks!

EDIT:

I found this answer which I guess is related. I'm messing around trying to let os wait for the process group, but it's still not working. Code below:

def popenAndCall(onExit, popenArgs):

    def runInThread(onExit, popenArgs):
        tmppath=popenArgs[-1]
        proc = subprocess.Popen(popenArgs, preexec_fn=os.setsid)
        pid=proc.pid
        gid=os.getpgid(pid)
        #rec=proc.wait()
        rec=os.waitid(os.P_PGID, gid, os.WEXITED | os.WSTOPPED)
        print('# <runInThread>: rec=', rec, 'pid=',pid, 'gid=',gid)

        onExit(tmppath)
        os.remove(tmppath)
        return

    thread = threading.Thread(target=runInThread, args=(onExit, popenArgs))
    thread.start()
    return thread

I assume this gid=os.getpgid(pid) gives me the id of the group, and os.waitid() wait for the group. I also tried os.waitpid(gid, 0), didn't work either.

I'm on the right track?

UPDATE:

It seems that for some editors that works, like xed. vim and gvim both fails.

Jason
  • 2,950
  • 2
  • 30
  • 50
  • mine `[os.environ['TERMCMD']` is `gnome-terminal`. By *get its contexts into a variable python* I meant getting the texts in the text editor and saving that so I can paste that into a GUI widget. (For those new to this question.) – Jason Apr 28 '19 at 02:24

3 Answers3

3

With QProcess you can launch a process without blocking the Qt event loop.

In this case I use xterm since I do not know which terminal is established in TERMCMD.

from PyQt5 import QtCore, QtGui, QtWidgets


class EditorWorker(QtCore.QObject):
    finished = QtCore.pyqtSignal()

    def __init__(self, command, parent=None):
        super(EditorWorker, self).__init__(parent)
        self._temp_file = QtCore.QTemporaryFile(self)
        self._process = QtCore.QProcess(self)
        self._process.finished.connect(self.on_finished)
        self._text = ""
        if self._temp_file.open():
            program, *arguments = command
            self._process.start(
                program, arguments + [self._temp_file.fileName()]
            )

    @QtCore.pyqtSlot()
    def on_finished(self):
        if self._temp_file.isOpen():
            self._text = self._temp_file.readAll().data().decode()
            self.finished.emit()

    @property
    def text(self):
        return self._text

    def __del__(self):
        self._process.kill()


class Widget(QtWidgets.QWidget):
    def __init__(self, parent=None):
        super(Widget, self).__init__(parent)
        self._button = QtWidgets.QPushButton(
            "Launch VIM", clicked=self.on_clicked
        )
        self._text_edit = QtWidgets.QTextEdit(readOnly=True)

        lay = QtWidgets.QVBoxLayout(self)
        lay.addWidget(self._button)
        lay.addWidget(self._text_edit)

    @QtCore.pyqtSlot()
    def on_clicked(self):
        worker = EditorWorker("xterm -e vim".split(), self)
        worker.finished.connect(self.on_finished)

    @QtCore.pyqtSlot()
    def on_finished(self):
        worker = self.sender()
        prev_cursor = self._text_edit.textCursor()
        self._text_edit.moveCursor(QtGui.QTextCursor.End)
        self._text_edit.insertPlainText(worker.text)
        self._text_edit.setTextCursor(prev_cursor)
        worker.deleteLater()


if __name__ == "__main__":
    import sys

    app = QtWidgets.QApplication(sys.argv)
    w = Widget()
    w.resize(640, 480)
    w.show()
    sys.exit(app.exec_())

I guess in your case you should change

"xterm -e vim".split()

to

[os.environ['TERMCMD'], "--", "vim"]

Possible commands:

- xterm -e vim
- xfce4-terminal --disable-server -x vim

Update:

Implementing the same logic that you use with pyinotify that is to monitor the file, but in this case using QFileSystemWatcher which is a multiplatform solution:

from PyQt5 import QtCore, QtGui, QtWidgets


class EditorWorker(QtCore.QObject):
    finished = QtCore.pyqtSignal()

    def __init__(self, command, parent=None):
        super(EditorWorker, self).__init__(parent)
        self._temp_file = QtCore.QTemporaryFile(self)
        self._process = QtCore.QProcess(self)
        self._text = ""
        self._watcher = QtCore.QFileSystemWatcher(self)
        self._watcher.fileChanged.connect(self.on_fileChanged)

        if self._temp_file.open():
            self._watcher.addPath(self._temp_file.fileName())

            program, *arguments = command
            self._process.start(
                program, arguments + [self._temp_file.fileName()]
            )

    @QtCore.pyqtSlot()
    def on_fileChanged(self):
        if self._temp_file.isOpen():
            self._text = self._temp_file.readAll().data().decode()
            self.finished.emit()

    @property
    def text(self):
        return self._text

    def __del__(self):
        self._process.kill()


class Widget(QtWidgets.QWidget):
    def __init__(self, parent=None):
        super(Widget, self).__init__(parent)
        self._button = QtWidgets.QPushButton(
            "Launch VIM", clicked=self.on_clicked
        )
        self._text_edit = QtWidgets.QTextEdit(readOnly=True)

        lay = QtWidgets.QVBoxLayout(self)
        lay.addWidget(self._button)
        lay.addWidget(self._text_edit)

    @QtCore.pyqtSlot()
    def on_clicked(self):
        worker = EditorWorker("gnome-terminal -- vim".split(), self)
        worker.finished.connect(self.on_finished)

    @QtCore.pyqtSlot()
    def on_finished(self):
        worker = self.sender()
        prev_cursor = self._text_edit.textCursor()
        self._text_edit.moveCursor(QtGui.QTextCursor.End)
        self._text_edit.insertPlainText(worker.text)
        self._text_edit.setTextCursor(prev_cursor)
        worker.deleteLater()


if __name__ == "__main__":
    import sys

    app = QtWidgets.QApplication(sys.argv)
    w = Widget()
    w.resize(640, 480)
    w.show()
    sys.exit(app.exec_())
eyllanesc
  • 235,170
  • 19
  • 170
  • 241
  • I looked into zim's source and it's using `GObject.spawn_async`, I guess that's the equivalent of `QProcess`'. In my case `TERMCMD` is `gnome-terminal`. I think it's either `gnome-terminal` or my vim, this still doesn't work for `['gnome-terminal', '--', 'vim']`, or `['gvim']`, in either case `EditorWorker.finished` is triggered on editor opening, not on closing. `['xterm', '-e', 'vim']` works as expected though. So confusing. – Jason Apr 28 '19 at 00:46
  • @Jason With the help of htop I have seen that gnome-terminal - vim does not launch a process a terminal but sends it to a server managed by gnome, and this server just launched the server, and what my code is capturing the process that sends the server that is obviously deleted an instant later – eyllanesc Apr 28 '19 at 01:05
  • @Jason try with: `gnome-terminal --disable-factory -- vim`. What is your distro? – eyllanesc Apr 28 '19 at 01:32
  • mine complains `--disable-factory is no longer supported`, the version is 3.32.1. And I'm on Manjaro. – Jason Apr 28 '19 at 01:44
  • Thanks for coming back with a new solution! I did a few minor changes, nearly all based on the use case of `xed` (strange editor): I found that `temp_file.readAll()` doesn't read from `xed` wrote texts, so I used `with open()` and `read()`. After getting the contents, I have to remove the watched path, and re-add it, otherwise changes made by `xed` won't trigger for a 2nd time. And lastly I removed `worker.deleteLater()` so if the editor saves but not exits, it can still track. I'll have to put that somewhere else. – Jason Apr 30 '19 at 02:04
1

The issue I reproduced is that proc is the gnome-terminal process and not the vim process.

Here are the two options that work for me.

1) Find the process of your text editor and not that of your terminal. With the right process ID, the code can wait for the process of your text editor to finish.

With psutil (portable)

Finds the latest editor process in the list of all running processes.

import psutil
def popenAndCall(onExit, popenArgs):

    def runInThread(onExit, popenArgs):
        tmppath=popenArgs[-1]
        editor_cmd=popenArgs[-2]  # vim
        proc = subprocess.Popen(popenArgs)
        proc.wait()

        # Find the latest editor process in the list of all running processes
        editor_processes = []

        for p in psutil.process_iter():
            try:
                process_name = p.name()
                if editor_cmd in process_name:
                    editor_processes.append((process_name, p.pid))
            except:
                pass

        editor_proc = psutil.Process(editor_processes[-1][1])

        rec=editor_proc.wait()
        print('# <runInThread>: rec=', rec)
        onExit(tmppath)
        os.remove(tmppath)
        return

    thread = threading.Thread(target=runInThread, args=(onExit, popenArgs))
    thread.start()
    return thread

Without psutil (works on Linux, but not portable to Mac OS or Windows)

Draws from https://stackoverflow.com/a/2704947/241866 and the source code of psutil.

def popenAndCall(onExit, popenArgs):

    def runInThread(onExit, popenArgs):
        tmppath=popenArgs[-1]
        editor_cmd=popenArgs[-2]  # vim
        proc = subprocess.Popen(popenArgs)
        proc.wait()

        # Find the latest editor process in the list of all running processes

        pids = [pid for pid in os.listdir('/proc') if pid.isdigit()]

        editor_processes = []
        for pid in pids:
            try:
                process_name = open(os.path.join('/proc', pid, 'cmdline'), 'rb').read().split('\0')[0]
                if editor_cmd in process_name:
                    editor_processes.append((process_name, int(pid)))
            except IOError:
                continue
        editor_proc_pid = editor_processes[-1][1]

        def pid_exists(pid):
            try:
                os.kill(pid, 0)
                return True
            except:
                return 

        while True:
            if pid_exists(editor_proc_pid):
                import time
                time.sleep(1)
            else:
                break

        onExit(tmppath)
        os.remove(tmppath)
        return

    thread = threading.Thread(target=runInThread, args=(onExit, popenArgs))
    thread.start()
    return thread

2) As a last resort, you can catch a UI event before updating the text:

def popenAndCall(onExit, popenArgs):

    def runInThread(onExit, popenArgs):
        tmppath=popenArgs[-1]
        proc = subprocess.Popen(popenArgs)
        # this immediately finishes OPENING vim.
        rec=proc.wait()
        raw_input("Press Enter")  # replace this with UI event
        print('# <runInThread>: rec=', rec)
        onExit(tmppath)
        os.remove(tmppath)
        return

    thread = threading.Thread(target=runInThread, args=(onExit, popenArgs))
    thread.start()
    return thread
lecodesportif
  • 10,737
  • 9
  • 38
  • 58
  • I don't think this would work in the GUI, where I don't anywhere to put a `raw_input()`. I need to somehow capture the closing event or signal or what and make some response to that. – Jason Apr 27 '19 at 10:39
  • You mentioned the "Edit Source" function in zim. Will your users press any button in the GUI when they finish editing? – lecodesportif Apr 27 '19 at 10:42
  • I don't think so. User will only press a button to launch editor, then closing the editor updates the GUI widget, at least that's how it works in zim. – Jason Apr 27 '19 at 10:44
  • When you edit the source in zim, a dialog appears and the text is updated only after you click on OK (if you saved the source in your editor). It also updates when you leave and revisit the current note. So the update is not tied to the text editor process. You can replace the `raw_input` in my code with any other GUI event (like clicking a button). – lecodesportif Apr 27 '19 at 10:57
  • I could put that as the last resort. Is it possible to monitor the tmp file itself? Like vim will create a .swp file when a file is being edited. But other editors may not. Is there a way to tell a file is being edited? – Jason Apr 27 '19 at 11:09
  • thanks for coming back and providing help. I'm having some issue installing `psutil`. Let me work that out first. – Jason Apr 28 '19 at 08:42
  • What's the issue with installing psutil? – lecodesportif Apr 28 '19 at 10:15
  • It's [this issue](https://github.com/giampaolo/psutil/issues/1482). For some reason I can't install it in a conda environment. System-wise install worked though. – Jason Apr 28 '19 at 10:41
  • I just added a solution that works on Linux without using psutil. – lecodesportif Apr 29 '19 at 16:51
  • I tried that and it works. I'll keep an eye on the `psutil` version. One more thing though, when the editor is not "forked", for instance using `gedit`, `proc.wait()` is actually waiting for the editor already, then `editor_processes` is empty. I guess in such cases I could just skip the `while` loop. Thanks a lot! – Jason Apr 30 '19 at 01:55
  • In order to decide on the logic to apply, you could check the contents of the `cmd` variable. – lecodesportif Apr 30 '19 at 18:10
0

I think @eyllanesc's solution is very close to what zim is doing (zim is using GObject.spawn_async() and GObject.child_watch_add(), I've no experiences with GObject, I guess that's the equivalent to QProcess.start()). But we run into some problems regarding how some terminals (like gnome-terminal) handles new terminal session launching.

I tried to monitor the temporary file opened by the editor, and on writing/saving the temp file I could call my callback. The monitoring is done using pyinotify. I've tried gnome-terminal, xterm, urxvt and plain gvim, all seem to work.

Code below:

import threading
from PyQt5 import QtCore, QtGui, QtWidgets
import pyinotify


class EditorWorker(QtCore.QObject):
    file_close_sig = QtCore.pyqtSignal()
    edit_done_sig = QtCore.pyqtSignal()

    def __init__(self, command, parent=None):
        super(EditorWorker, self).__init__(parent)
        self._temp_file = QtCore.QTemporaryFile(self)
        self._process = QtCore.QProcess(self)
        #self._process.finished.connect(self.on_file_close)
        self.file_close_sig.connect(self.on_file_close)
        self._text = ""
        if self._temp_file.open():
            program, *arguments = command
            self._process.start(
                program, arguments + [self._temp_file.fileName()]
            )
            tmpfile=self._temp_file.fileName()
            # start a thread to monitor file saving/closing
            self.monitor_thread = threading.Thread(target=self.monitorFile,
                    args=(tmpfile, self.file_close_sig))
            self.monitor_thread.start()

    @QtCore.pyqtSlot()
    def on_file_close(self):
        if self._temp_file.isOpen():
            print('open')
            self._text = self._temp_file.readAll().data().decode()
            self.edit_done_sig.emit()
        else:
            print('not open')

    @property
    def text(self):
        return self._text

    def __del__(self):
        try:
            self._process.kill()
        except:
            pass

    def monitorFile(self, path, sig):

        class PClose(pyinotify.ProcessEvent):
            def my_init(self):
                self.sig=sig
                self.done=False

            def process_IN_CLOSE(self, event):
                f = event.name and os.path.join(event.path, event.name) or event.path
                self.sig.emit()
                self.done=True

        wm = pyinotify.WatchManager()
        eventHandler=PClose()
        notifier = pyinotify.Notifier(wm, eventHandler)
        wm.add_watch(path, pyinotify.IN_CLOSE_WRITE)

        try:
            while not eventHandler.done:
                notifier.process_events()
                if notifier.check_events():
                    notifier.read_events()
        except KeyboardInterrupt:
            notifier.stop()
            return


class Widget(QtWidgets.QWidget):
    def __init__(self, parent=None):
        super(Widget, self).__init__(parent)
        self._button = QtWidgets.QPushButton(
            "Launch VIM", clicked=self.on_clicked
        )
        self._text_edit = QtWidgets.QTextEdit(readOnly=True)

        lay = QtWidgets.QVBoxLayout(self)
        lay.addWidget(self._button)
        lay.addWidget(self._text_edit)

    @QtCore.pyqtSlot()
    def on_clicked(self):
        worker = EditorWorker(["gnome-terminal", '--', "vim"], self)
        worker.edit_done_sig.connect(self.on_edit_done)

    @QtCore.pyqtSlot()
    def on_edit_done(self):
        worker = self.sender()
        prev_cursor = self._text_edit.textCursor()
        self._text_edit.moveCursor(QtGui.QTextCursor.End)
        self._text_edit.insertPlainText(worker.text)
        self._text_edit.setTextCursor(prev_cursor)
        worker.deleteLater()


if __name__ == "__main__":
    import sys

    app = QtWidgets.QApplication(sys.argv)
    w = Widget()
    w.resize(640, 480)
    w.show()
    sys.exit(app.exec_())

BUT pyinotify only works in Linux. If you could find a cross-platform solution (at least on Mac) please let me know.

UPDATE: this doesn't seem to be robust. pyinotify reports file writing instead of just file closing. I'm depressed.

Jason
  • 2,950
  • 2
  • 30
  • 50