如果需要多个标准输入,python asyncio将陷入僵局

nos*_*nos 11 python git stdin subprocess python-asyncio

我编写了一个命令行工具,以git pull使用python asyncio 执行多个git repos。如果所有存储库均具有ssh无需密码的登录设置,则该方法可以正常工作。如果仅1个回购协议需要输入密码,它也可以正常工作。当多个存储库需要输入密码时,它似乎陷入僵局。

我的实现非常简单。主要逻辑是

utils.exec_async_tasks(
        utils.run_async(path, cmds) for path in repos.values())
Run Code Online (Sandbox Code Playgroud)

在这里run_async创建并等待子流程调用,然后exec_async_tasks运行所有任务。

async def run_async(path: str, cmds: List[str]):
    """
    Run `cmds` asynchronously in `path` directory
    """
    process = await asyncio.create_subprocess_exec(
        *cmds, stdout=asyncio.subprocess.PIPE, cwd=path)
    stdout, _ = await process.communicate()
    stdout and print(stdout.decode())


def exec_async_tasks(tasks: List[Coroutine]):
    """
    Execute tasks asynchronously
    """
    # TODO: asyncio API is nicer in python 3.7
    if platform.system() == 'Windows':
        loop = asyncio.ProactorEventLoop()
        asyncio.set_event_loop(loop)
    else:
        loop = asyncio.get_event_loop()

    try:
        loop.run_until_complete(asyncio.gather(*tasks))
    finally:
        loop.close()
Run Code Online (Sandbox Code Playgroud)

完整的代码库在github上

我认为问题类似于以下内容。在中run_asyncasyncio.create_subprocess_exec没有stdin的重定向,并且系统的stdin用于所有子进程(repos)。当第一个存储库要求输入密码时,asyncio调度程序会看到阻止输入,并在等待命令行输入的同时切换到第二个存储库。但是,如果第二个存储库在第一个存储库的密码输入完成之前要求输入密码,则系统的标准输入将链接到第二个存储库。并且第一个回购将永远等待输入。

我不确定如何处理这种情况。我必须为每个子流程重定向stdin吗?如果某些存储库使用无密码登录而有些则没有密码怎么办?

一些想法如下

  1. 检测何时需要输入密码create_subprocess_exec。如果是这样,则调用input()并将其结果传递给process.communicate(input)。但是我怎么能即时发现呢?

  2. 检测哪个存储库需要输入密码,并将其从异步执行中排除。最好的方法是什么?

Mar*_*ers 6

在默认配置中,当需要用户名或密码时,git直接访问/dev/tty同义词以更好地控制“控制”终端设备,例如,允许您与用户交互的设备。由于默认情况下,子进程会从其父级继承控制终端,因此您启动的所有git进程都将访问同一TTY设备。因此,是的,当尝试使用相互破坏对方预期输入的进程读取和写入同一TTY时,它们将挂起。

防止这种情况发生的一种简单方法是给每个子进程自己的会话。不同的会话每个都有不同的控制TTY。通过设置start_new_session=True

process = await asyncio.create_subprocess_exec(
    *cmds, stdout=asyncio.subprocess.PIPE, cwd=path, start_new_session=True)
Run Code Online (Sandbox Code Playgroud)

您无法真正真正地预先确定哪些git命令可能需要用户凭据,因为可以将git配置为从整个位置范围中获取凭据,并且仅在远程存储库实际上对身份验证提出挑战时才使用它们。

更糟糕的是,对于ssh://远程URL,git根本不处理身份验证,但将其留给ssh它打开的客户端进程。下面的更多内容。

但是,Git如何要求提供凭据(除以外的其他任何内容ssh)是可以配置的;请参阅gitcredentials文档。如果您的代码必须能够将凭据请求转发给最终用户,则可以利用此功能。我不会将其留给git命令来通过终端执行此操作,因为用户将如何知道特定的git命令将要接收哪些凭据,更不用说确保提示到达的问题了逻辑顺序。

相反,我会通过您的脚本路由所有对凭据的请求。您可以通过以下两种方法执行此操作:

  • 设置GIT_ASKPASS环境变量,指向git应该为每个提示运行的可执行文件。

    使用单个参数调用此可执行文件,提示显示用户。对于给定凭证所需的每条信息,分别为用户名(如果尚不知道)和密码,分别调用该信息。提示文字应使用户清楚要求什么(例如"Username for 'https://github.com': "或)"Password for 'https://someusername@github.com': "

  • 注册凭证助手 ; 它作为shell命令执行(因此可以有自己的预配置命令行参数),还有一个额外的参数告诉助手需要什么样的操作。如果将其get作为最后一个参数传递,则要求它提供给定主机和协议的凭据,或者可以告知某些凭据成功store或被拒绝erase。在所有情况下,它都可以多行key=value格式从stdin读取信息,以了解主机git尝试向其身份验证的内容。

    因此,与凭证帮手,你要提示输入用户名和密码组合在一起作为一个单一的步骤,你还可以获得有关该进程的详细信息; 处理storeerase操作使您可以更有效地缓存凭据。

Git fill首先按配置顺序询问每个已配置的凭据帮助程序(请参阅本FILES节以了解如何按顺序处理4个配置文件位置)。您可以使用添加到最后git-c credential.helper=...命令行开关在命令行上添加新的一次性帮助程序配置。如果没有凭据帮助者能够填写丢失的用户名或密码,则会向用户提示GIT_ASKPASS或提供其他提示选项

对于SSH连接,git会创建一个新的ssh子进程。然后,SSH将处理身份验证,并可能要求用户提供凭据或ssh密钥,并要求用户提供密码短语。这将再次通过来完成/dev/tty,而SSH对此则更加固执。虽然您可以将SSH_ASKPASS环境变量设置为用于提示的二进制文件,但是SSH仅在没有TTY会话且DISPLAY也已设置的情况下才使用此变量。

SSH_ASKPASS 必须是可执行文件(因此不能传入参数),并且不会提示提示凭据成功或失败。

我还要确保将当前环境变量复制到子进程中,因为如果用户已设置SSH密钥代理以缓存ssh密钥,则您希望git开始使用它们的SSH进程;通过环境变量发现关键代理。

因此,要为凭据助手创建一个连接,该连接也适用于SSH_ASKPASS,可以使用一个简单的同步脚本,该脚本从环境变量获取套接字:

#!/path/to/python3
import os, socket, sys
path = os.environ['PROMPTING_SOCKET_PATH']
operation = sys.argv[1]
if operation not in {'get', 'store', 'erase'}:
    operation, params = 'prompt', f'prompt={operation}\n'
else:
    params = sys.stdin.read()
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
    s.connect(path)
    s.sendall(f'''operation={operation}\n{params}'''.encode())
    print(s.recv(2048).decode())
Run Code Online (Sandbox Code Playgroud)

这应该设置可执行位。

然后,可以将其作为临时文件传递给git命令,或者将其预先构建,并在PROMPTING_SOCKET_PATH环境变量中添加Unix域套接字路径。它可以SSH_ASKPASS兼作提示,将操作设置为prompt

然后,该脚本使SSH和git在每个用户单独的连接中向UNIX域套接字服务器询问用户凭据。我已经使用了很大的接收缓冲区大小,我认为您不会遇到超过该协议的交换协议,也看不出任何未满的原因。它使脚本美观而简单。

相反,您可以将其用作GIT_ASKPASS命令,但随后您将无法获得有关非ssh连接凭据成功的有价值的信息。

这是UNIX域套接字服务器的演示实现,该服务器处理来自上述凭据帮助器的git和凭据请求,该服务器仅生成随机的十六进制值而不询问用户:

import asyncio
import os
import secrets
import tempfile

async def handle_git_prompt(reader, writer):
    data = await reader.read(2048)
    info = dict(line.split('=', 1) for line in data.decode().splitlines())
    print(f"Received credentials request: {info!r}")

    response = []
    operation = info.pop('operation', 'get')

    if operation == 'prompt':
        # new prompt for a username or password or pass phrase for SSH
        password = secrets.token_hex(10)
        print(f"Sending prompt response: {password!r}")
        response.append(password)

    elif operation == 'get':
        # new request for credentials, for a username (optional) and password
        if 'username' not in info:
            username = secrets.token_hex(10)
            print(f"Sending username: {username!r}")
            response.append(f'username={username}\n')

        password = secrets.token_hex(10)
        print(f"Sending password: {password!r}")
        response.append(f'password={password}\n')

    elif operation == 'store':
        # credentials were used successfully, perhaps store these for re-use
        print(f"Credentials for {info['username']} were approved")

    elif operation == 'erase':
        # credentials were rejected, if we cached anything, clear this now.
        print(f"Credentials for {info['username']} were rejected")

    writer.write(''.join(response).encode())
    await writer.drain()

    print("Closing the connection")
    writer.close()
    await writer.wait_closed()

async def main():
    with tempfile.TemporaryDirectory() as dirname:
        socket_path = os.path.join(dirname, 'credential.helper.sock')
        server = await asyncio.start_unix_server(handle_git_prompt, socket_path)

        print(f'Starting a domain socket at {server.sockets[0].getsockname()}')

        async with server:
            await server.serve_forever()

asyncio.run(main())
Run Code Online (Sandbox Code Playgroud)

请注意,凭据帮助者还可以在输出中添加quit=truequit=1,以告诉git不要寻找任何其他凭据帮助者,并且无需进一步提示。

您可以通过使用git 命令行选项传入帮助脚本(),使用该git credential <operation>命令测试凭据帮助程序是否正常工作。可以在标准输入中使用字符串,它将像git联系凭证帮助者一样解析出来;请参阅文档以获取完整的交换格式规范。/full/path/to/credhelper.py-c credential.helper=...git credentialurl=...

首先,在单独的终端中启动上述演示脚本:

$ /usr/local/bin/python3.7 git-credentials-demo.py
Starting a domain socket at /tmp/credhelper.py /var/folders/vh/80414gbd6p1cs28cfjtql3l80000gn/T/tmprxgyvecj/credential.helper.sock
Run Code Online (Sandbox Code Playgroud)

然后尝试从中获取凭据;我也包括storeerase操作的演示:

$ export PROMPTING_SOCKET_PATH="/var/folders/vh/80414gbd6p1cs28cfjtql3l80000gn/T/tmprxgyvecj/credential.helper.sock"
$ CREDHELPER="/tmp/credhelper.py"
$ echo "url=https://example.com:4242/some/path.git" | git -c "credential.helper=$CREDHELPER" credential fill
protocol=https
host=example.com:4242
username=5b5b0b9609c1a4f94119
password=e259f5be2c96fed718e6
$ echo "url=https://someuser@example.com/some/path.git" | git -c "credential.helper=$CREDHELPER" credential fill
protocol=https
host=example.com
username=someuser
password=766df0fba1de153c3e99
$ printf "protocol=https\nhost=example.com:4242\nusername=5b5b0b9609c1a4f94119\npassword=e259f5be2c96fed718e6" | git -c "credential.helper=$CREDHELPER" credential approve
$ printf "protocol=https\nhost=example.com\nusername=someuser\npassword=e259f5be2c96fed718e6" | git -c "credential.helper=$CREDHELPER" credential reject
Run Code Online (Sandbox Code Playgroud)

然后查看示例脚本的输出时,您将看到:

Received credentials request: {'operation': 'get', 'protocol': 'https', 'host': 'example.com:4242'}
Sending username: '5b5b0b9609c1a4f94119'
Sending password: 'e259f5be2c96fed718e6'
Closing the connection
Received credentials request: {'operation': 'get', 'protocol': 'https', 'host': 'example.com', 'username': 'someuser'}
Sending password: '766df0fba1de153c3e99'
Closing the connection
Received credentials request: {'operation': 'store', 'protocol': 'https', 'host': 'example.com:4242', 'username': '5b5b0b9609c1a4f94119', 'password': 'e259f5be2c96fed718e6'}
Credentials for 5b5b0b9609c1a4f94119 were approved
Closing the connection
Received credentials request: {'operation': 'erase', 'protocol': 'https', 'host': 'example.com', 'username': 'someuser', 'password': 'e259f5be2c96fed718e6'}
Credentials for someuser were rejected
Closing the connection
Run Code Online (Sandbox Code Playgroud)

请注意,如何为助手提供了一组解析的字段for protocolhost,并且省略了路径。如果您设置了git config选项credential.useHttpPath=true(或者已经为您设置了该选项),那么path=some/path.git它将被添加到传递的信息中。

对于SSH,仅通过显示以下提示来调用可执行文件:

$ $CREDHELPER "Please enter a super-secret passphrase: "
30b5978210f46bb968b2
Run Code Online (Sandbox Code Playgroud)

演示服务器已打印:

Received credentials request: {'operation': 'prompt', 'prompt': 'Please enter a super-secret passphrase: '}
Sending prompt response: '30b5978210f46bb968b2'
Closing the connection
Run Code Online (Sandbox Code Playgroud)

只需确保start_new_session=True在启动git进程时仍然设置即可,以确保强制使用SSH SSH_ASKPASS

env = {
    os.environ,
    SSH_ASKPASS='../path/to/credhelper.py',
    DISPLAY='dummy value',
    PROMPTING_SOCKET_PATH='../path/to/domain/socket',
}
process = await asyncio.create_subprocess_exec(
    *cmds, stdout=asyncio.subprocess.PIPE, cwd=path, 
    start_new_session=True, env=env)
Run Code Online (Sandbox Code Playgroud)

当然,您如何处理提示用户的问题是一个单独的问题,但是您的脚本现在具有完全控制权(每个git命令将耐心等待凭证助手返回所请求的信息),并且您可以将请求排队等待用户填写,您可以根据需要缓存凭据(以防多个命令都在等待同一主机的凭据)。