3

I have written a little *nix utility that 'reruns' a given command every time it detects filesystem changes. So I pass it the command to run as a quoted parameter, e.g.

rerun "my command"

Rerun is written in Python, and ends up calling:

subprocess.call("my command", shell=True, executable=USERS_DEFAULT_SHELL)

Where my default shell is '/bin/bash' in my case. However, the shell that subprocess.call invokes is not an 'interactive' shell, and so does not recognise the shell functions and aliases defined in my .bashrc.

Man bash tells me that to start an interactive shell, I pass '-i' to /bin/bash. But, predictably,

subprocess.call(..., executable='/bin/bash -i')

doesn't work - it fails to find an executable of that name. (And even if it did work, I'm trying to make this function for whatever shell is the user's default, not just Bash. Probably '-i' doesn't do the same for all other shells.)

How can I, from Python, execute "my command" exactly as it would have been interpreted had the user typed it into a terminal?

Jonathan Hartley
  • 15,462
  • 9
  • 79
  • 80
  • did you try subprocess.Popen with shell=True instead of subprocess.call? – Evgeny Prokurat Aug 02 '14 at 22:32
  • 1
    The most direct way to make sure it works exactly as if typed into a terminal is probably to use subprocess to start the terminal itself, then send the command via stdin. – BrenBarn Aug 02 '14 at 22:34
  • 1
    Does [the answer to this question](http://stackoverflow.com/questions/6856119/can-i-use-an-alias-to-execute-a-program-from-a-python-script) work for you? The question is slightly different but I think the solution there should be applicable here as well. – BrenBarn Aug 02 '14 at 22:36
  • @EvgenyProkurat The implementation of 'call' starts with "with Popen(*args, **kwargs) as p: ..." – Jonathan Hartley Aug 02 '14 at 22:41
  • @BrenBarn. Aha, I see what you're saying. Use subprocess.call (with shell=False, the default) to invoke a shell, passing my desired command as an argument to it. A quick experiment shows this does indeed work. Only downside is I have to specify '-i' as an extra param to '/bin/bash', and presumably this won't work for people using other shells? Not sure. – Jonathan Hartley Aug 02 '14 at 22:44
  • 1
    @JonathanHartley: You could read the SHELL environment variable to see which shell to call if you really want. But yes, it may get complicated if you're trying to reinstate exactly the shell environment that the user had, since who knows what tricks they may be pulling. – BrenBarn Aug 02 '14 at 22:48
  • @EvgenyProkurat A good idea! But "man system" says it always uses /bin/sh. – Jonathan Hartley Aug 02 '14 at 22:48
  • @BrenBarn Unfortunately, (although it should work), you'll get an error when you try to write to stdin. I struggled with this problem for a really long time when I was programming an IDE, and I ended up just using the BDB (I was going to call PDB and let users press buttons to control the PDB functions). – Thomas Hobohm Aug 02 '14 at 22:55
  • @ThomasHobohm I think I can work around that, because I would only be sending one command to the shell, so I can pass it using '-c' as I invoke bash, rather than piping in commands to stdin. – Jonathan Hartley Aug 03 '14 at 07:08
  • @BrenBarn rici's answer below builds on what you suggested. Have some upvotes. – Jonathan Hartley Aug 03 '14 at 07:11

1 Answers1

8

Posix requires the -i option to a shell to initiate the shell in "interactive mode". The precise definition of interactive mode varies from shell to shell -- obviously zsh and csh aren't going to try to interpret commands in .bashrc -- but using the -i flag should Do The Right Thing with all reasonable shells.

Normally, you would pass the argument by calling subprocess.call (or some Popen variant) with a list:

subprocess.call(['bash', '-i'])

Of course, that won't respect the user's shell preference. You should be able to get that from the SHELL environment variable:

subprocess.call([os.getenv('SHELL'), '-i'])

In order to get the shell to execute a particular command line, you need to use the -c command-line option, which is also Posix-standard so it should work on all shells:

subprocess.call([os.getenv('SHELL'), '-i', '-c', command_to_run])

This will work fine in a number of cases, but it can fail if the shell decides to exec the last (or only) command in command_to_run (see this answer on http://unix.stackexchange.com for some details.) and you subsequently attempt to invoke another shell to execute another command.

Consider, for example, the simple python program:

import subprocess
subprocess.call(['/bin/bash', '-i', '-c', 'ls'])
subprocess.call(['/bin/bash', '-i', '-c', 'echo second']);

The first bash process is started. Since it is an interactive shell, it creates a new process group and attaches the terminal to that process group. It then examines the command to run, determine that it is a simple command which runs an external utility, and consequently that it can exec the command. So it does that, replacing itself with the ls utility, which is now the leader of the terminal process group. When the ls terminates, the terminal process group becomes empty, but the terminal is still attached to it. So when the second bash process is started, it tries to create a new process group and attach the terminal to that process group, but that's not possible because the terminal is in a kind of limbo. According to the Posix standard (Base Definitions, §11.1.2):

When there is no longer any process whose process ID or process group ID matches the foreground process group ID, the terminal shall have no foreground process group. It is unspecified whether the terminal has a foreground process group when there is a process whose process ID matches the foreground process group ID, but whose process group ID does not. No actions defined in POSIX.1-2008, other than allocation of a controlling terminal or a successful call to tcsetpgrp(), shall cause a process group to become the foreground process group of the terminal.

With bash, this only happens if the string passed as the value of the -c argument is a simple command, so there is a simple workaround: make sure that the string is not a simple command. For example,

subprocess.call([os.getenv('SHELL'), '-i', '-c', ':;' + command_to_run])

which prepends a no-op to the command, making it a compound command. However, that won't work with other shells which are more aggressive in the tail-call optimization. So the general solution needs to follow the path suggested by Posix, also taking care of the description of the tcsetpgrp system call:

Attempts to use tcsetpgrp() from a process which is a member of a background process group on a fildes associated with its controlling terminal shall cause the process group to be sent a SIGTTOU signal. If the calling thread is blocking SIGTTOU signals or the process is ignoring SIGTTOU signals, the process shall be allowed to perform the operation, and no signal is sent.

Since the default action on a SIGTTOU signal is to stop the process, we need to ignore or block the signal. So we end up with the following:

!/usr/bin/python
import signal
import subprocess
import os

# Ignore SIGTTOU
signal.signal(signal.SIGTTOU, signal.SIG_IGN)

def run_command_in_shell(cmd):
  # Run the command
  subprocess.call([os.getenv('SHELL'), '-i', '-c', cmd])
  # Retrieve the terminal
  os.tcsetpgrp(0,os.getpgrp())

run_command_in_shell('ls')
run_command_in_shell('ls')
Community
  • 1
  • 1
rici
  • 234,347
  • 28
  • 237
  • 341
  • Ah. Thanks. I wasn't expecting SHELL to be set in sub-processes started by the shell ('rerun' itself) (but it is), and didn't realise I could rely on it being set by various shells. So instead of getting the user's current shell (which, without SHELL, I didn't know how to get from a subprocess), I have been using the user's default shell from /etc/passwd. SHELL sounds better if it's reliable. – Jonathan Hartley Aug 03 '14 at 07:09
  • Two successive calls to subprocess.call as shown above will SIGSTOP the Python script! The second call cannot acquire the terminal for its stdin. Some clues on how to fix it here, http://stackoverflow.com/questions/14941749/cannot-start-two-interactive-shells-using-popen but I haven't got working code yet... – Jonathan Hartley Aug 03 '14 at 15:18
  • 1
    @JonathanHartley: I can't reproduce that problem. I can reproduce the problem with `subprocess.Popen().communicate`, as in your link in your comment, but as far as I can see, `subprocess.call` providing the command to run in a `-c` argument (as in my third invocation) works just fine. – rici Aug 03 '14 at 17:31
  • Interesting. I'm on Python3.4, Bash, Ubuntu 13.10. I can repro with a 3 line python script. """import subprocess; subprocess.call(['/bin/bash', '-i', '-c', 'ls']); subprocess.call(['/bin/bash', '-i', '-c', 'ls']);""" I wonder if it's something in my user .bashrc or somesuch. Will try with a fresh new user account. – Jonathan Hartley Aug 04 '14 at 10:47
  • 1
    @JonathanHartley: I haven't tested that thoroughly, but it worked on my tests. Let me know if it fails. (You have to read down to the bottom for the answer. Sorry it's so long.) – rici Aug 04 '14 at 16:17
  • The recent amendments to the answer (using signal.signal and tcsetpgrp) work perfectly. Thank you heaps!!! – Jonathan Hartley Aug 08 '14 at 21:06
  • My project, "rerun", which uses @rici's answer since v1.0.21, to allow users to rerun commands which include aliases or shell functions. You earned a mention in the README 'Thank you' section. :-) https://pypi.python.org/pypi/rerun – Jonathan Hartley Aug 08 '14 at 21:13