2
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.

  • You've only got one `stdin`; if something is being redirected to it, then it isn't also connected to the terminal. One option would be to use command-line parameter(s) for the initial inputs to your program, so that there's no problem in using `input()` later. – jasonharper May 17 '22 at 16:14
  • 1
    What do you want to use this for? The only reason I can imagine to use `sys.stdin.read()` is if the input is going to contain newlines, but that's not the case here (ignoring the trailing newline since one could be added trivially). Beware the [XY problem](https://meta.stackexchange.com/q/66377/343832). Give the context. – wjandrea May 17 '22 at 16:17
  • The script needs a list of things to do, and one of the ways I'm feeding information is by using sys.stdin.read() to pipe in JSON data. Once the data is received, then the script starts to do things to the input. As it's going through the input, there are actions that the script takes that I want to get confirmation from the user on before proceeding. I know I can simply open the JSON file separately and I wouldn't have this problem. For my question, I tried to remove all fluff and present just the elements that make up the problem I'm running into. Hope this context helps. –  May 17 '22 at 16:21
  • What shell(s) are you using? I have a solution for Bash. – wjandrea May 17 '22 at 16:49
  • I'm using bash 4.4.20 on CentOS stream 8, but also testing on Windows 10. Thanks for the suggestion on the title, I'll update it. –  May 17 '22 at 16:52
  • You might try reading from `stderr` instead of `stdin`. Not sure it will work but it's worth a shot. – Mark Ransom May 17 '22 at 17:05
  • @MarkRansom: `stderr`? `stderr` is an output handle, you can't read from it any more than you can write to `stdin` (I won't swear no OS/runtime allows it, but it definitely wouldn't match any standard C or POSIX behavior I'm aware of). – ShadowRanger May 17 '22 at 17:36
  • @ShadowRanger that's why I qualified it "not sure it will work". I think in C it would work because it's just another handle to the console, but Python appears to be more picky - I just tried it and failed miserably. – Mark Ransom May 17 '22 at 19:28

2 Answers2

2

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.

ShadowRanger
  • 143,180
  • 12
  • 188
  • 271
  • I had no idea about argparse.FileType(), thanks! The reason for the `input` later on in the script is to confirm deletion of objects based on processing that has to occur on the original input, so there's no option to be able to enter this confirmation via CLI args. –  May 17 '22 at 17:16
2

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
wjandrea
  • 28,235
  • 9
  • 60
  • 81
  • 1
    This works for `bash`, but the OP seems to clearly want a portable solution (if they didn't care, they'd just use `open("/dev/tty")` unconditionally). I do like the solution in general, because process substitution is great, and it moves the problem from trying to have two different `stdin`s, which is largely insane, to just operating on an input file provided by the user, with the user having the option to have it "really" be any file-system object (IIRC, process substitution is implemented with named pipes), and the program not needing to care, it's just not portable. Up-voted though. – ShadowRanger May 17 '22 at 17:11
  • @ShadowRanger True. Regarding "the OP seems to clearly want a portable solution", I asked what shell(s) they're using and they only mentioned Bash, so I put all I had to go on. They also wrote, "being able to [use] the piped data is very convenient", which makes it sound like this feature is more of a bonus than a requirement, so I didn't stress too hard thinking about portability. – wjandrea May 17 '22 at 17:19
  • While I don't yet know that I want to go in that direction, knowing that process substitution is a thing is very helpful. Thanks! @ShadowRanger are there any resources you could point me to so that I can better understand the insanity of my ask? I'm okay adjusting my solution accordingly. I've found other comments that indicate it's something that shouldn't be done but not necessarily why. –  May 17 '22 at 18:00
  • 1
    @RichardDodson: I can't really point you to specific resources; the best I can say is "There's only one `stdin`", and the difficulty of trying to pretend there is more than one is prima facie evidence for why pretending there is more than one is frowned upon (and why the first two comments on your question basically asked "This doesn't make sense, why are you even trying to do this?"). Trying to swap out `stdin` to force use of the terminal as you go risks race conditions (what if two threads are trying to swap it out at the same time?), and it assumes there *is* a terminal (not guaranteed). – ShadowRanger May 17 '22 at 18:08
  • 1
    Other options you might consider: 1) If you're always receiving input from another process, have Python itself launch and manage the process via the `subprocess` module. 2) If you need to prompt for user input, simple `tkinter` dialogs might make sense (and would work whether or not you're attached to a terminal). 3) If you really want to keep doing what you're doing, take a look at the implementation of `getpass.getpass` (it's implemented in pure Python); it's doing what you want to do (adding a lack of echo for the typing, which you can omit). It's ugly, but that's how it must be done. – ShadowRanger May 17 '22 at 18:13