26

I'm trying to create a WebSocket command line client that waits for messages from a WebSocket server but waits for user input at the same time.

Regularly polling multiple online sources every second works fine on the server, (the one running at localhost:6789 in this example), but instead of using Python's normal sleep() method, it uses asyncio.sleep(), which makes sense because sleeping and asynchronously sleeping aren't the same thing, at least not under the hood.

Similarly, waiting for user input and asynchronously waiting for user input aren't the same thing, but I can't figure out how to asynchronously wait for user input in the same way that I can asynchronously wait for an arbitrary amount of seconds, so that the client can deal with incoming messages from the WebSocket server while simultaneously waiting for user input.

The comment below in the else-clause of monitor_cmd() hopefully explains what I'm getting at:

import asyncio
import json
import websockets

async def monitor_ws():
    uri = 'ws://localhost:6789'
    async with websockets.connect(uri) as websocket:
        async for message in websocket:
            print(json.dumps(json.loads(message), indent=2, sort_keys=True))

async def monitor_cmd():
    while True:

        sleep_instead = False

        if sleep_instead:
            await asyncio.sleep(1)
            print('Sleeping works fine.')
        else:
            # Seems like I need the equivalent of:
            # line = await asyncio.input('Is this your line? ')
            line = input('Is this your line? ')
            print(line)
try:
    asyncio.get_event_loop().run_until_complete(asyncio.wait([
        monitor_ws(),
        monitor_cmd()
    ]))
except KeyboardInterrupt:
    quit()

This code just waits for input indefinitely and does nothing else in the meantime, and I understand why. What I don't understand, is how to fix it. :)

Of course, if I'm thinking about this problem in the wrong way, I'd be very happy to learn how to remedy that as well.

Teekin
  • 12,581
  • 15
  • 55
  • 67
  • 1
    [aioconsole](https://pypi.org/project/aioconsole/) may be what you need here. – user4815162342 Oct 18 '19 at 19:13
  • @user4815162342: Yes! That was exactly what I needed, actually! :) The docs aren't too great, but having imported aioconsole, this was exactly the line that worked: line = await aioconsole.ainput('Is this your line? ') If you put that line in as an answer, I'll mark it as the correct one. – Teekin Oct 18 '19 at 21:21

5 Answers5

23

You can use the aioconsole third-party package to interact with stdin in an asyncio-friendly manner:

line = await aioconsole.ainput('Is this your line? ')
user4815162342
  • 141,790
  • 18
  • 296
  • 355
23

Borrowing heavily from aioconsole, if you would rather avoid using an external library you could define your own async input function:

async def ainput(string: str) -> str:
    await asyncio.get_event_loop().run_in_executor(
            None, lambda s=string: sys.stdout.write(s+' '))
    return await asyncio.get_event_loop().run_in_executor(
            None, sys.stdin.readline)
mfurseman
  • 748
  • 8
  • 11
2

Borrowing heavily from aioconsole, there are 2 ways to handle.

  1. start a new daemon thread:
import sys
import asyncio
import threading
from concurrent.futures import Future


async def run_as_daemon(func, *args):
    future = Future()
    future.set_running_or_notify_cancel()

    def daemon():
        try:
            result = func(*args)
        except Exception as e:
            future.set_exception(e)
        else:
            future.set_result(result)

    threading.Thread(target=daemon, daemon=True).start()
    return await asyncio.wrap_future(future)


async def main():
    data = await run_as_daemon(sys.stdin.readline)
    print(data)


if __name__ == "__main__":
    asyncio.run(main())

  1. use stream reader:
import sys
import asyncio


async def get_steam_reader(pipe) -> asyncio.StreamReader:
    loop = asyncio.get_event_loop()
    reader = asyncio.StreamReader(loop=loop)
    protocol = asyncio.StreamReaderProtocol(reader)
    await loop.connect_read_pipe(lambda: protocol, pipe)
    return reader


async def main():
    reader = await get_steam_reader(sys.stdin)
    data = await reader.readline()
    print(data)


if __name__ == "__main__":
    asyncio.run(main())

ppd0705
  • 51
  • 2
1

Python 3.9 introduces asyncio.to_thread, which can be used to simplify the code in mfurseman's answer:

async def ainput(string: str) -> str:
    await asyncio.to_thread(sys.stdout.write, f'{string} ')
    return await asyncio.to_thread(sys.stdin.readline)

Note that sys.stdin.readline returns the newline character '\n', while input does not. If you would like ainput to exclude the newline character, I suggest the following alteration:

async def ainput(string: str) -> str:
    await asyncio.to_thread(sys.stdout.write, f'{string} ')
    return (await asyncio.to_thread(sys.stdin.readline)).rstrip('\n')
Leland Hepworth
  • 876
  • 9
  • 16
1
  1. solution with prompt-toolkit:

    from prompt_toolkit import PromptSession
    from prompt_toolkit.patch_stdout import patch_stdout
    
    async def my_coroutine():
        session = PromptSession()
        while True:
            with patch_stdout():
                result = await session.prompt_async('Say something: ')
            print('You said: %s' % result)
    
  2. solution with asyncio:

    import asyncio
    import sys
    
    async def get_stdin_reader() -> asyncio.StreamReader:
        stream_reader = asyncio.StreamReader()
        protocol = asyncio.StreamReaderProtocol(stream_reader)
        loop = asyncio.get_running_loop()
        await loop.connect_read_pipe(lambda: protocol, sys.stdin)
        return stream_reader
    
    async def main():
        stdin_reader = await get_stdin_reader()
        while True:
            print('input: ', end='', flush=True)
            line = await stdin_reader.readline()
            print(f'your input: {line.decode()}')
    
    asyncio.run(main())
    
giskard
  • 11
  • 2