a7d6d88f6f
CI / changes (push) Has been cancelled
CI / cd libs/checkpoint (push) Has been cancelled
CI / cd libs/checkpoint-conformance (push) Has been cancelled
CI / cd libs/checkpoint-postgres (push) Has been cancelled
CI / cd libs/checkpoint-sqlite (push) Has been cancelled
CI / cd libs/cli (push) Has been cancelled
CI / cd libs/prebuilt (push) Has been cancelled
CI / cd libs/sdk-py (push) Has been cancelled
CI / cd libs/langgraph (push) Has been cancelled
CI / Check SDK methods matching (push) Has been cancelled
CI / Check CLI schema hasn't changed #3.13 (push) Has been cancelled
CI / CLI integration test (push) Has been cancelled
CI / sdk-py integration test (push) Has been cancelled
CI / CI Success (push) Has been cancelled
baseline / benchmark (push) Has been cancelled
Deploy Redirects to GitHub Pages / deploy (push) Has been cancelled
175 lines
5.1 KiB
Python
175 lines
5.1 KiB
Python
import asyncio
|
|
import signal
|
|
import sys
|
|
from collections.abc import Callable
|
|
from contextlib import contextmanager
|
|
from typing import cast
|
|
|
|
import click.exceptions
|
|
|
|
|
|
@contextmanager
|
|
def Runner():
|
|
if hasattr(asyncio, "Runner"):
|
|
with asyncio.Runner() as runner:
|
|
yield runner
|
|
else:
|
|
|
|
class _Runner:
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *args):
|
|
pass
|
|
|
|
def run(self, coro):
|
|
return asyncio.run(coro)
|
|
|
|
yield _Runner()
|
|
|
|
|
|
async def subp_exec(
|
|
cmd: str,
|
|
*args: str,
|
|
input: str | None = None,
|
|
wait: float | None = None,
|
|
verbose: bool = False,
|
|
collect: bool = False,
|
|
on_stdout: Callable[[str], bool | None] | None = None,
|
|
) -> tuple[str | None, str | None]:
|
|
if verbose:
|
|
cmd_str = f"+ {cmd} {' '.join(map(str, args))}"
|
|
if input:
|
|
print(cmd_str, " <\n", "\n".join(filter(None, input.splitlines())), sep="")
|
|
else:
|
|
print(cmd_str)
|
|
if wait:
|
|
await asyncio.sleep(wait)
|
|
|
|
try:
|
|
proc = await asyncio.create_subprocess_exec(
|
|
cmd,
|
|
*args,
|
|
stdin=asyncio.subprocess.PIPE if input else None,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
|
|
def signal_handler():
|
|
# make sure process exists, then terminate it
|
|
if proc.returncode is None:
|
|
proc.terminate()
|
|
|
|
original_sigint_handler = signal.getsignal(signal.SIGINT)
|
|
if sys.platform == "win32":
|
|
|
|
def handle_windows_signal(signum, frame):
|
|
signal_handler()
|
|
original_sigint_handler(signum, frame)
|
|
|
|
signal.signal(signal.SIGINT, handle_windows_signal)
|
|
# NOTE: we're not adding a handler for SIGTERM since it's ignored on Windows
|
|
else:
|
|
loop = asyncio.get_event_loop()
|
|
loop.add_signal_handler(signal.SIGINT, signal_handler)
|
|
loop.add_signal_handler(signal.SIGTERM, signal_handler)
|
|
|
|
empty_fut: asyncio.Future = asyncio.Future()
|
|
empty_fut.set_result(None)
|
|
stdout, stderr, _ = await asyncio.gather(
|
|
monitor_stream(
|
|
cast(asyncio.StreamReader, proc.stdout),
|
|
collect=True,
|
|
display=verbose,
|
|
on_line=on_stdout,
|
|
),
|
|
monitor_stream(
|
|
cast(asyncio.StreamReader, proc.stderr),
|
|
collect=True,
|
|
display=verbose,
|
|
),
|
|
proc._feed_stdin(input.encode()) if input else empty_fut, # type: ignore[attr-defined]
|
|
)
|
|
returncode = await proc.wait()
|
|
if (
|
|
returncode is not None
|
|
and returncode != 0 # success
|
|
and returncode != 130 # user interrupt
|
|
):
|
|
sys.stdout.write(stdout.decode() if stdout else "")
|
|
sys.stderr.write(stderr.decode() if stderr else "")
|
|
raise click.exceptions.Exit(returncode)
|
|
if collect:
|
|
return (
|
|
stdout.decode() if stdout else None,
|
|
stderr.decode() if stderr else None,
|
|
)
|
|
else:
|
|
return None, None
|
|
finally:
|
|
try:
|
|
if proc.returncode is None:
|
|
try:
|
|
proc.terminate()
|
|
except (ProcessLookupError, KeyboardInterrupt):
|
|
pass
|
|
|
|
if sys.platform == "win32":
|
|
signal.signal(signal.SIGINT, original_sigint_handler)
|
|
else:
|
|
loop.remove_signal_handler(signal.SIGINT)
|
|
loop.remove_signal_handler(signal.SIGTERM)
|
|
except UnboundLocalError:
|
|
pass
|
|
|
|
|
|
async def monitor_stream(
|
|
stream: asyncio.StreamReader,
|
|
collect: bool = False,
|
|
display: bool = False,
|
|
on_line: Callable[[str], bool | None] | None = None,
|
|
) -> bytearray | None:
|
|
if collect:
|
|
ba = bytearray()
|
|
|
|
def handle(line: bytes, overrun: bool):
|
|
nonlocal on_line
|
|
nonlocal display
|
|
|
|
if display:
|
|
sys.stdout.buffer.write(line)
|
|
if overrun:
|
|
return
|
|
if collect:
|
|
ba.extend(line)
|
|
if on_line:
|
|
if on_line(line.decode()):
|
|
on_line = None
|
|
display = True
|
|
|
|
"""Adapted from asyncio.StreamReader.readline() to handle LimitOverrunError."""
|
|
sep = b"\n"
|
|
seplen = len(sep)
|
|
while True:
|
|
try:
|
|
line = await stream.readuntil(sep)
|
|
overrun = False
|
|
except asyncio.IncompleteReadError as e:
|
|
line = e.partial
|
|
overrun = False
|
|
except asyncio.LimitOverrunError as e:
|
|
if stream._buffer.startswith(sep, e.consumed):
|
|
line = stream._buffer[: e.consumed + seplen]
|
|
else:
|
|
line = stream._buffer.clear()
|
|
overrun = True
|
|
stream._maybe_resume_transport()
|
|
await asyncio.to_thread(handle, line, overrun)
|
|
if line == b"":
|
|
break
|
|
|
|
if collect:
|
|
return ba
|
|
else:
|
|
return None
|