Search code examples
pythonpython-3.xsubprocesswaitxdgutils

Python3/Linux - Open text file in default editor and wait until done


I need to wait until the user is done editing a text file in the default graphical application (Debian and derivates).

If I use xdg-open with subprocess.call (which usually waits) it will continue after opening the file in the editor. I assume because xdg-open itself starts the editor asynchronously.

I finally got a more or less working code by retrieving the launcher for the text/plain mime-type and use that with Gio.DesktopAppInfo.new to get the command for the editor. Provided that the editor is not already open in which case the process ends while the editor is still open.

I have added solutions checking the process.pid and polling for the process. Both end in an indefinite loop.

It seems such a overly complicated way to wait for the process to finish. So, is there a more robust way to do this?

#! /usr/bin/env python3

import subprocess
from gi.repository import Gio
import os
from time import sleep
import sys


def open_launcher(my_file):
    print('launcher open')
    app = subprocess.check_output(['xdg-mime', 'query', 'default', 'text/plain']).decode('utf-8').strip()
    print(app)
    launcher = Gio.DesktopAppInfo.new(app).get_commandline().split()[0]
    print(launcher)
    subprocess.call([launcher, my_file])
    print('launcher close')
    
def open_xdg(my_file):
    print('xdg open')
    subprocess.call(['xdg-open', my_file])
    print('xdg close')
    
def check_pid(pid):        
    """ Check For the existence of a unix pid. """
    try:
        os.kill(int(pid), 0)
    except OSError:
        return False
    else:
        return True
    
def open_pid(my_file):
    pid = subprocess.Popen(['xdg-open', my_file]).pid
    while check_pid(pid):
        print(pid)
        sleep(1)
        
def open_poll(my_file):
    proc = subprocess.Popen(['xdg-open', my_file])
    while not proc.poll():
        print(proc.poll())
        sleep(1)
        
def open_ps(my_file):
    subprocess.call(['xdg-open', my_file])
    pid = subprocess.check_output("ps -o pid,cmd -e | grep %s | head -n 1 | awk '{print $1}'" % my_file, shell=True).decode('utf-8')
    while check_pid(pid):
        print(pid)
        sleep(1)
        
def open_popen(my_file):
    print('popen open')
    process = subprocess.Popen(['xdg-open', my_file])
    process.wait()
    print(process.returncode)
    print('popen close')


# This will end the open_xdg function while the editor is open.
# However, if the editor is already open, open_launcher will finish while the editor is still open.
#open_launcher('test.txt')

# This solution opens the file but the process terminates before the editor is closed.
#open_xdg('test.txt')

# This will loop indefinately printing the pid even after closing the editor.
# If you check for the pid in another terminal you see the pid with: [xdg-open] <defunct>.
#open_pid('test.txt')

# This will print None once after which 0 is printed indefinately: the subprocess ends immediately.
#open_poll('test.txt')

# This seems to work, even when the editor is already open.
# However, I had to use head -n 1 to prevent returning multiple pids.
#open_ps('test.txt')

# Like open_xdg, this opens the file but the process terminates before the editor is closed.
open_popen('test.txt')

Solution

  • Instead of trying to poll a PID, you can simply wait for the child process to terminate, using subprocess.Popen.wait():

    Wait for child process to terminate. Set and return returncode attribute.

    Additionally, getting the first part of get_commandline() is not guaranteed to be the launcher. The string returned by get_commandline() will match the Exec key spec, meaning the %u, %U, %f, and %F field codes in the returned string should be replaced with the correct values.

    Here is some example code, based on your xdg-mime approach:

    #!/usr/bin/env python3
    import subprocess
    import shlex
    from gi.repository import Gio
    
    my_file = 'test.txt'
    
    # Get default application
    app = subprocess.check_output(['xdg-mime', 'query', 'default', 'text/plain']).decode('utf-8').strip()
    
    # Get command to run
    command = Gio.DesktopAppInfo.new(app).get_commandline()
    
    # Handle file paths with spaces by quoting the file path
    my_file_quoted = "'" + my_file + "'"
    
    # Replace field codes with the file path
    # Also handle special case of the atom editor
    command = command.replace('%u', my_file_quoted)\
        .replace('%U', my_file_quoted)\
        .replace('%f', my_file_quoted)\
        .replace('%F', my_file_quoted if app != 'atom.desktop' else '--wait ' + my_file_quoted)
    
    # Run the default application, and wait for it to terminate
    process = subprocess.Popen(
        shlex.split(command), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
    process.wait()
    
    # Now the exit code of the text editor process is available as process.returncode
    

    I have a few remarks on my sample code.

    Remark 1: Handling spaces in file paths

    It is important the file path to be opened is wrapped in quotes, otherwise shlex.split(command) will split the filename on spaces.

    Remark 2: Escaped % characters

    The Exec key spec states

    Literal percentage characters must be escaped as %%.

    My use of replace() then could potentially replace % characters that were escaped. For simplicity, I chose to ignore this edge case.

    Remark 3: atom

    I assumed the desired behaviour is to always wait until the graphical editor has closed. In the case of the atom text editor, it will terminate immediately on launching the window unless the --wait option is provided. For this reason, I conditionally add the --wait option if the default editor is atom.

    Remark 4: subprocess.DEVNULL

    subprocess.DEVNULL is new in python 3.3. For older python versions, the following can be used instead:

    with open(os.devnull, 'w') as DEVNULL:
        process = subprocess.Popen(
            shlex.split(command), stdout=DEVNULL, stderr=DEVNULL)
    

    Testing

    I tested my example code above on Ubuntu with the GNOME desktop environment. I tested with the following graphical text editors: gedit, mousepad, and atom.