When using pty.fork()
the child process is told it's writing to an actual terminal, a tty, just like one you normally use. However, it's writing to a pty, a psuedo-terminal, which is a tty controlled by another program.
There is only a single fd because the child program is writing what it would to a terminal. This is a combination of stdout, stderr, and any terminal escape codes. stdout/stderr do not have any meaning in this context as they are printed to a terminal, and they are not accessible individually when a program is connected to a pty (just like when you read a program's output you can't tell which stream is which).
You can still redirect stdout or stderr to a file if you want. That would be done in the forked part of the code run by the child. You could redirect its standard streams or redirect the subprocess' streams.
Here is an example program based on sdaau's answer (their answer does not work in Python3).
#!/usr/bin/env python3
import sys
import os
import time
import pty
import subprocess
def log(chars):
sys.stdout.write(" > " + chars + "\n")
def main():
# fork this script such that a child process writes to a pty that is
# controlled or "spied on" by the parent process
(child_pid, fd) = pty.fork()
# A new child process has been spawned and is continuing from here.
# The original parent process is also continuing from here.
# They have "forked".
if child_pid == 0:
log("This is the child process fork, pid %s" % os.getpid())
log("Child process will run a subprocess controlled by the parent process")
log("All output, including this text, will be written to a pty and handled ")
log("by the parent process.")
# redirect stdout/stderr if you want to here
subprocess.run(["bash"])
else:
log("This is the parent process fork, pid %s" % os.getpid())
log("the fd being read from, %s, is not stdout nor stderr; it is " % fd)
log("simply what the child is trying to write to its tty. ")
log("stdout/stderr are combined along with terminal escape codes.")
print()
# Read initial output of child process before "typing" anything in its pty
sys.stdout.write(os.read(fd, 1024).decode())
print()
# Run any bash commands you want. I/O to the fd is handled as if you are typing
# at a terminal.
os.write(fd, "ls\n".encode())
os.write(fd, "which git\n".encode())
# you can even test tab completions
os.write(fd, "git sta\t\t".encode())
while True:
log("parent will read 1024 bytes that the child wrote to its pty")
log("if no new output is available, parent will wait. Exit with ctrl+c.\n")
# take out decode() to see raw bytes the child wrote to its pty
sys.stdout.write(os.read(fd, 1024).decode())
time.sleep(1)
if __name__ == "__main__":
main()