Home > Software design >  How can I take piped input from another application AND take user input after?
How can I take piped input from another application AND take user input after?

Time:05-18

import sys

stdin_input = sys.stdin.read()
print(f"Info loaded from stdin: {stdin_input}")

user_input = input("User input goes here: ")

Error received:

C:\>echo "hello" | python winput.py
Info loaded from stdin: "hello"

User input goes here: Traceback (most recent call last):
  File "C:\winput.py", line 6, in <module>
    user_input = input("User input goes here: ")
EOFError: EOF when reading a line

I've recently learned this is because sys.stdin is being used for FIFO, which leaves it closed after reading.

I can make it work on CentOS by adding sys.stdin = open("/dev/tty") after stdin_input = sys.stdin.read() based on this question, but this doesn't work for Windows.

Preferably rather than identifying the OS and assigning a new value to sys.stdin accordingly, I'd rather approach it dynamically. Is there a way to identify what the equivalent of /dev/tty would be in every case, without necessarily having to know /dev/tty or the equivalent is for the specific OS?

Edit:

The reason for the sys.stdin.read() is to take in JSON input piped from another application. I also have an option to read the JSON data from a file, but being able to used the piped data is very convenient. Once the data is received, I'd like to get user input separately.

I'm currently working around my problem with the following:

if os.name == "posix":
    sys.stdin = open("/dev/tty")
elif os.name == "nt":
    sys.stdin = open("con")
else:
    raise RunTimeError(
        f"Error trying to assign to sys.stdin due to unknown os {os.name}"
    )

This may very well work in all cases but it would still be preferable to know what /dev/tty or con or whatever the equivalent is for the OS is dynamically. If it's not possible and my workaround is the best solution, I'm okay with that.

CodePudding user response:

Since you're using Bash, you can avoid this problem by using process substitution, which is like a pipe, but delivered via a temporary filename argument instead of via stdin.

That would look like:

winput.py <(another-application)

Then in your Python script, receive the argument and handle it accordingly:

import json
import sys

with open(sys.argv[1]) as f:
    d = json.load(f)
print(d)

user_input = input("User input goes here: ")
print('User input:', user_input)

(sys.argv is just used for demo. In a real script I'd use argparse.)

Example run:

$ tmp.py <(echo '{"someKey": "someValue"}')
{'someKey': 'someValue'}
User input goes here: 6
User input: 6

The other massive advantage of this is that it works seamlessly with actual filenames, for example:

$ cat test.json
{"foo": "bar"}
$ tmp.py test.json
{'foo': 'bar'}
User input goes here: x
User input: x

CodePudding user response:

So your real issue is that sys.stdin can be only one of two things:

  1. Connected to the typed input from the terminal
  2. Connected to some file-like object that is not the terminal (actual file, pipe, whatever)

It doesn't matter that you consumed all of sys.stdin by doing sys.stdin.read(), when sys.stdin was redirected to some file-system object, you lost the ability to read from the terminal via sys.stdin.

In practice, I'd strongly suggest not trying to do this. Use argparse and accept whatever you were considering accepting via input from the command line and avoid the whole problem (in practice, I basically never see real production code that's not a REPL of some sort dynamically interacting with the user via stdin/stdout interactions; for non-REPL cases, sys.stdin is basically always either unused or piped from a file/program, because writing clean user-interaction code like this is a pain, and it's a pain for the user to have to type their responses without making mistakes). The input that might come for a file or stdin can be handled by passing type=argparse.FileType() to the add_argument call in question, and the user can then opt to pass either a file name or - (where - means "Read from stdin"), leaving your code looking like:

parser = argparse.ArgumentParser('Program description here')
parser.add_argument('inputfile', type=argparse.FileType(), help='Description here; pass "-" to read from stdin')
parser.add_argument('-c', '--cmd', action='append', help='User commands to execute after processing input file')
args = parser.parse_args()

with args.inputfile as f:
    data = f.read()

for cmd in args.cmd:
    # Do stuff based on cmd

The user can then do:

otherprogram_that_generates_data | myprogram.py - -c 'command 1' -c 'command 2'

or:

myprogram.py file_containing_data -c 'command 1' -c 'command 2'

or (on shells with process substitution, like bash, as an alternative to the first use case):

myprogram.py <(otherprogram_that_generates_data) -c 'command 1' -c 'command 2'

and it works either way.

If you must do this, your existing solution is really the only reasonable solution, but you can make it a little cleaner factoring it out and only making the path dynamic, not the whole code path:

import contextlib
import os
import sys

TTYNAMES = {"posix": "/dev/tty", "nt": "con"}

@contextlib.contextmanager
def stdin_from_terminal():
    try:
        ttyname = TTYNAMES[os.name]
    except KeyError:
        raise OSError(f"{os.name} does not support manually reading from the terminal")
    with open(ttyname) as tty:
        sys.stdin, oldstdin = tty, sys.stdin
        try:
            yield
        finally:
            sys.stdin = oldstdin

This will probably die with an OSError subclass on the open call if run without a connected terminal, e.g. when launched with pythonw on Windows (another reason not to use this design), or launched in non-terminal ways on UNIX-likes, but that's better than silently misbehaving.

You'd use it with just:

with stdin_from_terminal():
    user_input = input("User input goes here: ")

and it would restore the original sys.stdin automatically when the with block is exited.

  • Related