Getting live output from asyncio subprocess
Question:
I’m trying to use Python asyncio subprocesses to start an interactive SSH session and automatically input the password. The actual use case doesn’t matter but it helps illustrate my problem. This is my code:
proc = await asyncio.create_subprocess_exec(
'ssh', '[email protected]',
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
stdin=asyncio.subprocess.PIPE,
)
# This loop could be replaced by async for, I imagine
while True:
buf = await proc.stdout.read()
if not buf:
break
print(f'stdout: { buf }')
I expected it to work something like asyncio streams, where I can create two tasks/subroutines/futures, one to listen to the StreamReader
(in this case given by proc.stdout
), the other to write to StreamWriter (proc.stdin
).
However, it doesn’t work as expected. The first few lines of output from the ssh command are printed directly to the terminal, until it gets to the password prompt (or host key prompt, as the case may be) and waits for manual input. I expected to be able to read the first few lines, check whether it was asking for password or the host prompt, and write to the StreamReader accordingly.
The only time it runs the line print(f'stdout: { buf }')
is after I press enter, when it prints, obviously, that "stderr: b’Host key verification failed.rn’".
I also tried the recommended proc.communicate()
, which isn’t as neat as using StreamReader/Writer, but it has the same problem: Execution freezes while it waits for manual input.
How is this actually supposed to work? If it’s not how I imagined, why not, and is there any way to achieve this without resorting to some sort of busy loop in a thread?
PS: I’m explaining using ssh just for clarity. I ended up using plink for what I wanted, but I want to understand how to do this with python to run arbitrary commands.
Answers:
Here demonstration of live output.
Briefly, run bash process -> with stdin pass an ‘ls’ command -> async read result from the stdout
proc = await asyncio.create_subprocess_exec(
'/bin/bash', '-i',
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
stdin=asyncio.subprocess.PIPE,
)
proc.stdin.write(b'ls rn')
await proc.stdin.drain()
try:
while True:
# wait line for 3 seconds or raise an error
line = await asyncio.wait_for( proc.stdout.readline(), 3 )
print(line)
except asyncio.TimeoutError:
pass
Using this technique I was not able to enter server with ssh and "password",
I stacked with the error "bash: no job control in this shell" after command ‘ssh -tt user@localhost ‘
Have you tried using AsyncSSH
library? (which uses python’s asyncio framework). Seems like this is what you’re looking for.
import asyncio, asyncssh, sys
async def run_client():
async with asyncssh.connect('localhost', username='myuser', password='secretpw') as conn:
result = await conn.run('ls abc', check=True)
print(result.stdout, end='')
try:
asyncio.get_event_loop().run_until_complete(run_client())
except (OSError, asyncssh.Error) as exc:
sys.exit('SSH connection failed: ' + str(exc))
It also has support for ssh keys with client_keys
param. Check the documentation. There are many examples for interactive input, i/o redirect, etc.
This isn’t a problem specific to asyncio. The ssh
process does not interact with the stdin
and stdout
streams, but rather accesses the TTY device directly, in order to ensure that password entry is properly secured.
You have three options to work around this:
-
Don’t use ssh
, but some other SSH client, one that doesn’t expect to a TTY to control. For asyncio, you could use the asyncssh
library. This library directly implements the SSH protocol and so doesn’t require a separate process, and it accepts username and password credentials directly.
-
Provide a pseudo-tty for SSH to talk to, one your Python program controls. The pexpect
library provides a high-level API that does this for you and can be used to fully control the ssh
command.
-
Set up an alternative password prompter for ssh to use. The ssh
program can let something else handle password entry if there is no TTY, via the SSH_ASKPASS
environment variable. Most versions of ssh
are quite picky about when they’ll accept SSH_ASKPASS
however, you need to set DISPLAY
too, use the -n
command-line switch for ssh
and use the setsid
command to run ssh
in a new session, disconnected from any TTY.
I’ve previously described how to use SSH_ASKPASS
with asyncio in an answer to a question about git
and ssh
.
The path of least resistance is to use pexpect, as it supports asyncio natively (any method that accepts async_=True
can be used as a coroutine):
import pexpect
proc = pexpect.spawn('ssh [email protected]')
await child.expect('password:', timeout=120, async_=True)
child.sendline(password_for_user)
If anyone else landed here for a more generic answer to the question, see the following example:
import asyncio
async def _read_stream(stream, cb):
while True:
line = await stream.readline()
if line:
cb(line)
else:
break
async def _stream_subprocess(cmd, stdout_cb, stderr_cb):
process = await asyncio.create_subprocess_exec(*cmd,
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
await asyncio.wait([
_read_stream(process.stdout, stdout_cb),
_read_stream(process.stderr, stderr_cb)
])
return await process.wait()
def execute(cmd, stdout_cb, stderr_cb):
loop = asyncio.get_event_loop()
rc = loop.run_until_complete(
_stream_subprocess(
cmd,
stdout_cb,
stderr_cb,
))
loop.close()
return rc
if __name__ == '__main__':
print(execute(
["bash", "-c", "echo stdout && sleep 1 && echo stderr 1>&2 && sleep 1 && echo done"],
lambda x: print("STDOUT: %s" % x),
lambda x: print("STDERR: %s" % x),
))
import asyncio
from asyncio import create_subprocess_shell
from asyncio.subprocess import PIPE, STDOUT
import sys
async def main():
# create a subprocess in asyncio and connect its stdout to the stdin of another subprocess
p1 = await create_subprocess_shell("python myfile.py",
stdout=PIPE, stderr=STDOUT)
while True:
if p1.stdout.at_eof():
break
stdout = (await p1.stdout.readline()).decode()
if stdout:
print(f'[stdout] {stdout}')
await p1.wait()
I’m trying to use Python asyncio subprocesses to start an interactive SSH session and automatically input the password. The actual use case doesn’t matter but it helps illustrate my problem. This is my code:
proc = await asyncio.create_subprocess_exec(
'ssh', '[email protected]',
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
stdin=asyncio.subprocess.PIPE,
)
# This loop could be replaced by async for, I imagine
while True:
buf = await proc.stdout.read()
if not buf:
break
print(f'stdout: { buf }')
I expected it to work something like asyncio streams, where I can create two tasks/subroutines/futures, one to listen to the StreamReader
(in this case given by proc.stdout
), the other to write to StreamWriter (proc.stdin
).
However, it doesn’t work as expected. The first few lines of output from the ssh command are printed directly to the terminal, until it gets to the password prompt (or host key prompt, as the case may be) and waits for manual input. I expected to be able to read the first few lines, check whether it was asking for password or the host prompt, and write to the StreamReader accordingly.
The only time it runs the line print(f'stdout: { buf }')
is after I press enter, when it prints, obviously, that "stderr: b’Host key verification failed.rn’".
I also tried the recommended proc.communicate()
, which isn’t as neat as using StreamReader/Writer, but it has the same problem: Execution freezes while it waits for manual input.
How is this actually supposed to work? If it’s not how I imagined, why not, and is there any way to achieve this without resorting to some sort of busy loop in a thread?
PS: I’m explaining using ssh just for clarity. I ended up using plink for what I wanted, but I want to understand how to do this with python to run arbitrary commands.
Here demonstration of live output.
Briefly, run bash process -> with stdin pass an ‘ls’ command -> async read result from the stdout
proc = await asyncio.create_subprocess_exec(
'/bin/bash', '-i',
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
stdin=asyncio.subprocess.PIPE,
)
proc.stdin.write(b'ls rn')
await proc.stdin.drain()
try:
while True:
# wait line for 3 seconds or raise an error
line = await asyncio.wait_for( proc.stdout.readline(), 3 )
print(line)
except asyncio.TimeoutError:
pass
Using this technique I was not able to enter server with ssh and "password",
I stacked with the error "bash: no job control in this shell" after command ‘ssh -tt user@localhost ‘
Have you tried using AsyncSSH
library? (which uses python’s asyncio framework). Seems like this is what you’re looking for.
import asyncio, asyncssh, sys
async def run_client():
async with asyncssh.connect('localhost', username='myuser', password='secretpw') as conn:
result = await conn.run('ls abc', check=True)
print(result.stdout, end='')
try:
asyncio.get_event_loop().run_until_complete(run_client())
except (OSError, asyncssh.Error) as exc:
sys.exit('SSH connection failed: ' + str(exc))
It also has support for ssh keys with client_keys
param. Check the documentation. There are many examples for interactive input, i/o redirect, etc.
This isn’t a problem specific to asyncio. The ssh
process does not interact with the stdin
and stdout
streams, but rather accesses the TTY device directly, in order to ensure that password entry is properly secured.
You have three options to work around this:
-
Don’t use
ssh
, but some other SSH client, one that doesn’t expect to a TTY to control. For asyncio, you could use theasyncssh
library. This library directly implements the SSH protocol and so doesn’t require a separate process, and it accepts username and password credentials directly. -
Provide a pseudo-tty for SSH to talk to, one your Python program controls. The
pexpect
library provides a high-level API that does this for you and can be used to fully control thessh
command. -
Set up an alternative password prompter for ssh to use. The
ssh
program can let something else handle password entry if there is no TTY, via theSSH_ASKPASS
environment variable. Most versions ofssh
are quite picky about when they’ll acceptSSH_ASKPASS
however, you need to setDISPLAY
too, use the-n
command-line switch forssh
and use thesetsid
command to runssh
in a new session, disconnected from any TTY.I’ve previously described how to use
SSH_ASKPASS
with asyncio in an answer to a question aboutgit
andssh
.
The path of least resistance is to use pexpect, as it supports asyncio natively (any method that accepts async_=True
can be used as a coroutine):
import pexpect
proc = pexpect.spawn('ssh [email protected]')
await child.expect('password:', timeout=120, async_=True)
child.sendline(password_for_user)
If anyone else landed here for a more generic answer to the question, see the following example:
import asyncio
async def _read_stream(stream, cb):
while True:
line = await stream.readline()
if line:
cb(line)
else:
break
async def _stream_subprocess(cmd, stdout_cb, stderr_cb):
process = await asyncio.create_subprocess_exec(*cmd,
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
await asyncio.wait([
_read_stream(process.stdout, stdout_cb),
_read_stream(process.stderr, stderr_cb)
])
return await process.wait()
def execute(cmd, stdout_cb, stderr_cb):
loop = asyncio.get_event_loop()
rc = loop.run_until_complete(
_stream_subprocess(
cmd,
stdout_cb,
stderr_cb,
))
loop.close()
return rc
if __name__ == '__main__':
print(execute(
["bash", "-c", "echo stdout && sleep 1 && echo stderr 1>&2 && sleep 1 && echo done"],
lambda x: print("STDOUT: %s" % x),
lambda x: print("STDERR: %s" % x),
))
import asyncio
from asyncio import create_subprocess_shell
from asyncio.subprocess import PIPE, STDOUT
import sys
async def main():
# create a subprocess in asyncio and connect its stdout to the stdin of another subprocess
p1 = await create_subprocess_shell("python myfile.py",
stdout=PIPE, stderr=STDOUT)
while True:
if p1.stdout.at_eof():
break
stdout = (await p1.stdout.readline()).decode()
if stdout:
print(f'[stdout] {stdout}')
await p1.wait()