libs: add cli, sdk-py, sdk-js and move core langgraph

This commit is contained in:
vbarda
2024-06-17 20:37:25 -04:00
parent 45e22f3f12
commit 1a06d500d4
126 changed files with 5224 additions and 70 deletions
View File
+476
View File
@@ -0,0 +1,476 @@
import json
import pathlib
import shutil
import sys
from typing import Optional
import click
import click.exceptions
import langgraph_cli.config
import langgraph_cli.docker
from langgraph_cli.config import Config
from langgraph_cli.docker import DockerCapabilities
from langgraph_cli.exec import Runner, subp_exec
from langgraph_cli.progress import Progress
OPT_O = click.option(
"--docker-compose",
"-d",
help="Advanced: Path to docker-compose.yml file with additional services to launch",
type=click.Path(
exists=True,
file_okay=True,
dir_okay=False,
resolve_path=True,
path_type=pathlib.Path,
),
)
OPT_C = click.option(
"--config",
"-c",
help="""Path to configuration file declaring dependencies, graphs and environment variables.
\b
Config file must be a JSON file that has the following keys:
- "dependencies": array of dependencies for langgraph API server. Dependencies can be one of the following:
- ".", which would look for local python packages, as well as pyproject.toml, setup.py or requirements.txt in the app directory
- "./local_package"
- "<package_name>
- "graphs": mapping from graph ID to path where the compiled graph is defined, i.e. ./your_package/your_file.py:variable, where
"variable" is an instance of langgraph.graph.graph.CompiledGraph
- "env": (optional) path to .env file or a mapping from environment variable to its value
- "python_version": (optional) 3.11 or 3.12. Defaults to 3.11
- "pip_config_file": (optional) path to pip config file
- "dockerfile_lines": (optional) array of additional lines to add to Dockerfile following the import from parent image
\b
Example:
langgraph up -c langgraph.json
\b
Example:
{
"dependencies": [
"langchain_openai",
"./your_package"
],
"graphs": {
"my_graph_id": "./your_package/your_file.py:variable"
},
"env": "./.env"
}
\b
Example:
{
"python_version": "3.11",
"dependencies": [
"langchain_openai",
"."
],
"graphs": {
"my_graph_id": "./your_package/your_file.py:variable"
},
"env": {
"OPENAI_API_KEY": "secret-key"
}
}
Defaults to looking for langgraph.json in the current directory.""",
default="langgraph.json",
type=click.Path(
exists=True,
file_okay=True,
dir_okay=False,
resolve_path=True,
path_type=pathlib.Path,
),
)
OPT_PORT = click.option(
"--port",
"-p",
type=int,
default=8123,
show_default=True,
help="""
Port to expose.
\b
Example:
langgraph up --port 8000
\b
""",
)
OPT_RECREATE = click.option(
"--recreate/--no-recreate",
default=False,
show_default=True,
help="Recreate containers even if their configuration and image haven't changed",
)
OPT_PULL = click.option(
"--pull/--no-pull",
default=True,
show_default=True,
help="""
Pull latest images. Use --no-pull for running the server with locally-built images.
\b
Example:
langgraph up --no-pull
\b
""",
)
OPT_VERBOSE = click.option(
"--verbose",
is_flag=True,
default=False,
help="Show more output from the server logs",
)
OPT_DEBUGGER_PORT = click.option(
"--debugger-port",
type=int,
help="Pull the debugger image locally and serve the UI on specified port",
)
@click.group()
def cli():
pass
@OPT_RECREATE
@OPT_PULL
@OPT_PORT
@OPT_O
@OPT_C
@OPT_VERBOSE
@OPT_DEBUGGER_PORT
@click.option("--watch", is_flag=True, help="Restart on file changes")
@click.option(
"--langgraph-api-path",
type=click.Path(exists=True, file_okay=False, dir_okay=True, resolve_path=True),
hidden=True,
)
@click.option(
"--wait",
is_flag=True,
help="Wait for services to start before returning. Implies --detach",
)
@cli.command(help="Start langgraph API server")
def up(
config: pathlib.Path,
docker_compose: Optional[pathlib.Path],
port: int,
recreate: bool,
pull: bool,
watch: bool,
langgraph_api_path: Optional[pathlib.Path],
wait: bool,
verbose: bool,
debugger_port: Optional[int],
):
with Runner() as runner, Progress(message="Pulling...") as set:
capabilities = langgraph_cli.docker.check_capabilities(runner)
args, stdin = prepare(
runner,
capabilities=capabilities,
config_path=config,
docker_compose=docker_compose,
port=port,
pull=pull,
watch=watch,
langgraph_api_path=langgraph_api_path,
verbose=verbose,
debugger_port=debugger_port,
)
# add up + options
args.extend(["up", "--remove-orphans"])
if recreate:
args.extend(["--force-recreate", "--renew-anon-volumes"])
shutil.rmtree(config.parent / ".langgraph-data", ignore_errors=True)
try:
runner.run(subp_exec("docker", "volume", "rm", "langgraph-data"))
except click.exceptions.Exit:
pass
if watch:
args.append("--watch")
if wait:
args.append("--wait")
# run docker compose
set("Building...")
def on_stdout(line: str):
if "unpacking to docker.io" in line:
set("Starting...")
elif "GET /ok" in line:
debugger_origin = (
f"http://localhost:{debugger_port}"
if debugger_port
else "https://dev.smith.langchain.com"
)
set("")
sys.stdout.write(
f"""Ready!
- API: http://localhost:{port}
- Docs: http://localhost:{port}/docs
- Debugger: {debugger_origin}/studio/?baseUrl=http://127.0.0.1:{port}
"""
)
sys.stdout.flush()
return True
if capabilities.compose_type == "plugin":
compose_cmd = ["docker", "compose"]
elif capabilities.compose_type == "standalone":
compose_cmd = ["docker-compose"]
runner.run(
subp_exec(
*compose_cmd,
*args,
input=stdin,
verbose=verbose,
on_stdout=on_stdout,
)
)
@OPT_PORT
@OPT_O
@OPT_C
@OPT_VERBOSE
@OPT_DEBUGGER_PORT
@cli.command(help="Stop langgraph API server")
def down(
config: pathlib.Path,
docker_compose: Optional[pathlib.Path],
port: int,
verbose: bool,
debugger_port: Optional[int],
):
with Runner() as runner:
capabilities = langgraph_cli.docker.check_capabilities(runner)
args, stdin = prepare(
runner,
capabilities=capabilities,
config_path=config,
docker_compose=docker_compose,
port=port,
pull=False,
watch=False,
langgraph_api_path=None,
verbose=verbose,
debugger_port=debugger_port,
)
# add down + options
args.append("down")
# run docker compose
if capabilities.compose_type == "plugin":
compose_cmd = ["docker", "compose"]
elif capabilities.compose_type == "standalone":
compose_cmd = ["docker-compose"]
runner.run(subp_exec(*compose_cmd, *args, input=stdin, verbose=verbose))
@OPT_O
@OPT_C
@click.option("--follow", "-f", is_flag=True, help="Follow logs")
@cli.command(help="Show langgraph API server logs")
def logs(
config: pathlib.Path,
docker_compose: Optional[pathlib.Path],
follow: bool,
):
with Runner() as runner:
capabilities = langgraph_cli.docker.check_capabilities(runner)
args, stdin = prepare(
runner,
capabilities=capabilities,
config_path=config,
docker_compose=docker_compose,
port=8123,
pull=False,
watch=False,
verbose=False,
langgraph_api_path=None,
)
# add logs + options
args.append("logs")
if follow:
args.extend(["-f"])
# run docker compose
if capabilities.compose_type == "plugin":
compose_cmd = ["docker", "compose"]
elif capabilities.compose_type == "standalone":
compose_cmd = ["docker-compose"]
runner.run(subp_exec(*compose_cmd, *args, input=stdin, verbose=True))
@OPT_C
@OPT_PULL
@click.option(
"--tag",
"-t",
help="""Tag for the docker image.
\b
Example:
langgraph build -t my-image
\b
""",
required=True,
)
@click.option(
"--platform",
help="""Target platform(s) to build the docker image for.
\b
Example:
langgraph build --platform linux/amd64,linux/arm64
\b
""",
)
@cli.command(help="Build langgraph API server docker image")
def build(
config: pathlib.Path,
platform: Optional[str],
pull: bool,
tag: str,
):
with open(config) as f:
config_json = langgraph_cli.config.validate_config(json.load(f))
with Runner() as runner:
# check docker available
langgraph_cli.docker.check_capabilities(runner)
# pull latest images
if pull:
runner.run(
subp_exec(
"docker",
"pull",
f"langchain/langgraph-api:{config_json['python_version']}",
)
)
# apply options
args = [
"-f",
"-", # stdin
"-t",
tag,
]
if platform:
args.extend(["--platform", platform])
# apply config
stdin = langgraph_cli.config.config_to_docker(config, config_json)
# run docker build
runner.run(
subp_exec(
"docker", "build", *args, str(config.parent), input=stdin, verbose=True
)
)
@OPT_PORT
@OPT_O
@OPT_C
@cli.command(help="Build a helm chart to deploy to a Kubernetes cluster", hidden=True)
def helm(
config: pathlib.Path,
docker_compose: Optional[pathlib.Path],
port: int,
):
with open(config) as f:
config_json = langgraph_cli.config.validate_config(json.load(f))
with Runner() as runner:
# check docker available
capabilities = langgraph_cli.docker.check_capabilities(runner)
# prepare args
stdin = langgraph_cli.docker.compose(capabilities, port=port)
args = [
"--chart",
"-o=./helm",
"-v",
"-f",
"-", # stdin
]
# apply options
if docker_compose:
args.extend(["-f", str(docker_compose)])
args.append("convert")
# apply config
stdin += langgraph_cli.config.config_to_compose(config, config_json)
# run kompose convert
runner.run(subp_exec("kompose", *args, input=stdin))
def prepare_args_and_stdin(
*,
capabilities: DockerCapabilities,
config_path: pathlib.Path,
config: Config,
docker_compose: Optional[pathlib.Path],
port: int,
watch: bool,
langgraph_api_path: Optional[pathlib.Path],
debugger_port: Optional[int] = None,
):
# prepare args
stdin = langgraph_cli.docker.compose(
capabilities, port=port, debugger_port=debugger_port
)
args = [
"--project-directory",
str(config_path.parent),
]
# apply options
if docker_compose:
args.extend(["-f", str(docker_compose)])
args.extend(["-f", "-"]) # stdin
# apply config
stdin += langgraph_cli.config.config_to_compose(
config_path, config, watch=watch, langgraph_api_path=langgraph_api_path
)
return args, stdin
def prepare(
runner,
*,
capabilities: DockerCapabilities,
config_path: pathlib.Path,
docker_compose: Optional[pathlib.Path],
port: int,
pull: bool,
watch: bool,
langgraph_api_path: Optional[pathlib.Path],
verbose: bool,
debugger_port: Optional[int] = None,
):
with open(config_path) as f:
config = langgraph_cli.config.validate_config(json.load(f))
# pull latest images
if pull:
runner.run(
subp_exec(
"docker",
"pull",
f"langchain/langgraph-api:{config['python_version']}",
verbose=verbose,
)
)
args, stdin = prepare_args_and_stdin(
capabilities=capabilities,
config_path=config_path,
config=config,
docker_compose=docker_compose,
port=port,
watch=watch,
langgraph_api_path=langgraph_api_path,
debugger_port=debugger_port,
)
return args, stdin
+306
View File
@@ -0,0 +1,306 @@
import json
import os
import pathlib
import textwrap
from typing import NamedTuple, Optional, TypedDict, Union
import click
class Config(TypedDict):
python_version: str
pip_config_file: Optional[str]
dockerfile_lines: list[str]
dependencies: list[str]
graphs: dict[str, str]
env: Union[dict[str, str], str]
def validate_config(config: Config) -> Config:
config = {
"python_version": config.get("python_version", "3.11"),
"pip_config_file": config.get("pip_config_file"),
"dockerfile_lines": config.get("dockerfile_lines", []),
"dependencies": config.get("dependencies", []),
"graphs": config.get("graphs", {}),
"env": config.get("env", {}),
}
if config["python_version"] not in (
"3.11",
"3.12",
):
raise click.UsageError(
f"Unsupported Python version: {config['python_version']}. "
"Supported versions are 3.11 and 3.12."
)
if not config["dependencies"]:
raise click.UsageError(
"No dependencies found in config. "
"Add at least one dependency to 'dependencies' list."
)
if not config["graphs"]:
raise click.UsageError(
"No graphs found in config. "
"Add at least one graph to 'graphs' dictionary."
)
return config
class LocalDeps(NamedTuple):
pip_reqs: list[tuple[pathlib.Path, str]]
real_pkgs: dict[pathlib.Path, str]
faux_pkgs: dict[pathlib.Path, tuple[str, str]]
# if . is in dependencies, use it as working_dir
working_dir: Optional[str] = None
def _assemble_local_deps(config_path: pathlib.Path, config: Config) -> LocalDeps:
# ensure reserved package names are not used
reserved = {
"src",
"langgraph-api",
"langgraph_api",
"langgraph",
"langchain-core",
"langchain_core",
"pydantic",
"orjson",
"fastapi",
"uvicorn",
"psycopg",
"httpx",
"langsmith",
}
def check_reserved(name: str, ref: str):
if name in reserved:
raise ValueError(
f"Package name '{name}' used in local dep '{ref}' is reserved. "
"Rename the directory."
)
reserved.add(name)
pip_reqs = []
real_pkgs = {}
faux_pkgs = {}
working_dir = None
for local_dep in config["dependencies"]:
if not local_dep.startswith("."):
continue
resolved = config_path.parent / local_dep
# validate local dependency
if not resolved.exists():
raise FileNotFoundError(f"Could not find local dependency: {resolved}")
elif not resolved.is_dir():
raise NotADirectoryError(
f"Local dependency must be a directory: {resolved}"
)
elif not resolved.is_relative_to(config_path.parent):
raise ValueError(
f"Local dependency '{resolved}' must be a subdirectory of '{config_path.parent}'"
)
# if it's installable, add it to local_pkgs
# otherwise, add it to faux_pkgs, and create a pyproject.toml
files = os.listdir(resolved)
if "pyproject.toml" in files:
real_pkgs[resolved] = local_dep
if local_dep == ".":
working_dir = f"/deps/{resolved.name}"
elif "setup.py" in files:
real_pkgs[resolved] = local_dep
if local_dep == ".":
working_dir = f"/deps/{resolved.name}"
else:
if any(file == "__init__.py" for file in files):
# flat layout
if "-" in resolved.name:
raise ValueError(
f"Package name '{resolved.name}' contains a hyphen. "
"Rename the directory to use it as flat-layout package."
)
check_reserved(resolved.name, local_dep)
container_path = f"/deps/__outer_{resolved.name}/{resolved.name}"
else:
# src layout
container_path = f"/deps/__outer_{resolved.name}/src"
for file in files:
rfile = resolved / file
if (
rfile.is_dir()
and file != "__pycache__"
and not file.startswith(".")
):
try:
for subfile in os.listdir(rfile):
if subfile.endswith(".py"):
check_reserved(file, local_dep)
break
except PermissionError:
pass
faux_pkgs[resolved] = (local_dep, container_path)
if local_dep == ".":
working_dir = container_path
if "requirements.txt" in files:
rfile = resolved / "requirements.txt"
pip_reqs.append(
(
rfile.relative_to(config_path.parent),
f"{container_path}/requirements.txt",
)
)
return LocalDeps(pip_reqs, real_pkgs, faux_pkgs, working_dir)
def _update_graph_paths(
config_path: pathlib.Path, config: Config, local_deps: LocalDeps
) -> None:
for graph_id, import_str in config["graphs"].items():
module_str, _, attr_str = import_str.partition(":")
if not module_str or not attr_str:
message = (
'Import string "{import_str}" must be in format "<module>:<attribute>".'
)
raise ValueError(message.format(import_str=import_str))
if "/" in module_str:
resolved = config_path.parent / module_str
if not resolved.exists():
raise FileNotFoundError(f"Could not find local module: {resolved}")
elif not resolved.is_file():
raise IsADirectoryError(f"Local module must be a file: {resolved}")
else:
for path in local_deps.real_pkgs:
if resolved.is_relative_to(path):
module_str = f"/deps/{path.name}/{resolved.relative_to(path)}"
break
else:
for faux_pkg, (_, destpath) in local_deps.faux_pkgs.items():
if resolved.is_relative_to(faux_pkg):
module_str = f"{destpath}/{resolved.relative_to(faux_pkg)}"
break
else:
raise ValueError(
f"Module '{import_str}' not found in 'dependencies' list. "
"Add its containing package to 'dependencies' list."
)
# update the config
config["graphs"][graph_id] = f"{module_str}:{attr_str}"
def config_to_docker(config_path: pathlib.Path, config: Config):
# configure pip
pip_install = "pip install -c /api/constraints.txt"
if config.get("pip_config_file"):
pip_install = f"PIP_CONFIG_FILE=/pipconfig.txt {pip_install}"
pip_config_file_str = (
f"ADD {config['pip_config_file']} /pipconfig.txt"
if config.get("pip_config_file")
else ""
)
# collect dependencies
pypi_deps = [dep for dep in config["dependencies"] if not dep.startswith(".")]
local_deps = _assemble_local_deps(config_path, config)
# rewrite graph paths
_update_graph_paths(config_path, config, local_deps)
pip_pkgs_str = f"RUN {pip_install} {' '.join(pypi_deps)}" if pypi_deps else ""
if local_deps.pip_reqs:
pip_reqs_str = os.linesep.join(
f"ADD {reqpath} {destpath}" for reqpath, destpath in local_deps.pip_reqs
)
pip_reqs_str += f'{os.linesep}RUN {pip_install} {" ".join("-r " + r for _,r in local_deps.pip_reqs)}'
else:
pip_reqs_str = ""
# https://setuptools.pypa.io/en/latest/userguide/datafiles.html#package-data
# https://til.simonwillison.net/python/pyproject
faux_pkgs_str = f"{os.linesep}{os.linesep}".join(
f"""ADD {relpath} {destpath}
COPY <<EOF /deps/__outer_{fullpath.name}/pyproject.toml
[project]
name = "{fullpath.name}"
version = "0.1"
[tool.setuptools.package-data]
"*" = ["**/*"]
EOF"""
for fullpath, (relpath, destpath) in local_deps.faux_pkgs.items()
)
local_pkgs_str = os.linesep.join(
f"ADD {relpath} /deps/{fullpath.name}"
for fullpath, relpath in local_deps.real_pkgs.items()
)
return f"""FROM langchain/langgraph-api:{config['python_version']}
{os.linesep.join(config["dockerfile_lines"])}
{pip_config_file_str}
{pip_pkgs_str}
{pip_reqs_str}
{local_pkgs_str}
{faux_pkgs_str}
RUN {pip_install} -e /deps/*
ENV LANGSERVE_GRAPHS='{json.dumps(config["graphs"])}'
{f"WORKDIR {local_deps.working_dir}" if local_deps.working_dir else ""}"""
def config_to_compose(
config_path: pathlib.Path,
config: Config,
watch: bool = False,
langgraph_api_path: Optional[pathlib.Path] = None,
):
env_vars = config["env"].items() if isinstance(config["env"], dict) else {}
env_vars_str = "\n".join(f" {k}: {v}" for k, v in env_vars)
env_file_str = (
f"env_file: {config['env']}" if isinstance(config["env"], str) else ""
)
if watch:
watch_paths = [config_path] + [
config_path.parent / dep
for dep in config["dependencies"]
if dep.startswith(".")
]
watch_actions = "\n".join(
f"""- path: {path}
action: rebuild
ignore:
- .langgraph-data"""
for path in watch_paths
)
if langgraph_api_path:
watch_actions += f"""\n- path: {langgraph_api_path}
action: sync+restart
target: /api/langgraph_api"""
watch_str = f"""
develop:
watch:
{textwrap.indent(watch_actions, " ")}
"""
else:
watch_str = ""
return f"""
{textwrap.indent(env_vars_str, " ")}
{env_file_str}
pull_policy: build
build:
context: .
dockerfile_inline: |
{textwrap.indent(config_to_docker(config_path, config), " ")}
{watch_str}
"""
+162
View File
@@ -0,0 +1,162 @@
import json
import pathlib
import shutil
from typing import Literal, NamedTuple, Optional
import click.exceptions
from langgraph_cli.exec import subp_exec
ROOT = pathlib.Path(__file__).parent.resolve()
DEFAULT_POSTGRES_URI = (
"postgres://postgres:postgres@langgraph-postgres:5432/postgres?sslmode=disable"
)
DB = """
langgraph-postgres:
image: postgres:16
restart: on-failure
ports:
- "5433:5432"
environment:
POSTGRES_DB: postgres
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
volumes:
- langgraph-data:/var/lib/postgresql/data
healthcheck:
test: pg_isready -U postgres
start_period: 10s
timeout: 1s
retries: 5
"""
DEBUGGER = """
langgraph-debugger:
image: langchain/langgraph-debugger
restart: on-failure
ports:
- "{debugger_port}:80"
depends_on:
langgraph-postgres:
condition: service_healthy
"""
class Version(NamedTuple):
major: int
minor: int
patch: int
DockerComposeType = Literal["plugin", "standalone"]
class DockerCapabilities(NamedTuple):
version_docker: Version
version_compose: Version
healthcheck_start_interval: bool
compose_type: DockerComposeType = "plugin"
def _parse_version(version: str) -> Version:
parts = version.split(".", 2)
if len(parts) == 1:
major = parts[0]
minor = "0"
patch = "0"
elif len(parts) == 2:
major, minor = parts
patch = "0"
else:
major, minor, patch = parts
return Version(int(major.lstrip("v")), int(minor), int(patch.split("-")[0]))
def check_capabilities(runner) -> DockerCapabilities:
# check docker available
try:
stdout, _ = runner.run(subp_exec("docker", "info", "-f", "json", collect=True))
info = json.loads(stdout)
except (click.exceptions.Exit, json.JSONDecodeError):
raise click.UsageError("Docker not installed or not running") from None
compose_type: DockerComposeType
try:
compose = next(
p for p in info["ClientInfo"]["Plugins"] if p["Name"] == "compose"
)
compose_type = "plugin"
except (KeyError, StopIteration):
if shutil.which("docker-compose") is None:
raise click.UsageError("Docker Compose not installed") from None
compose_type = "standalone"
# parse versions
docker_version = _parse_version(info["ServerVersion"])
compose_version = _parse_version(compose["Version"])
# check capabilities
return DockerCapabilities(
version_docker=docker_version,
version_compose=compose_version,
healthcheck_start_interval=docker_version >= Version(25, 0, 0),
compose_type=compose_type,
)
def compose(
capabilities: DockerCapabilities,
*,
port: int,
debugger_port: Optional[int] = None,
# postgres://user:password@host:port/database?option=value
postgres_uri: Optional[str] = None,
) -> str:
if postgres_uri is None:
include_db = True
postgres_uri = DEFAULT_POSTGRES_URI
else:
include_db = False
db = DB.format() if include_db else ""
volumes = (
"""volumes:
langgraph-data:
driver: local
"""
if include_db
else ""
)
if db:
if capabilities.healthcheck_start_interval:
db += """
interval: 60s
start_interval: 1s"""
else:
db += """
interval: 5s"""
compose_str = f"""{volumes}services:
{db}
{DEBUGGER.format(debugger_port=debugger_port) if debugger_port else ""}
langgraph-api:
restart: on-failure
ports:
- "{port}:8000"
depends_on:
langgraph-postgres:
condition: service_healthy
environment:
POSTGRES_URI: {postgres_uri}
"""
if capabilities.healthcheck_start_interval:
compose_str += """ healthcheck:
interval: 60s
start_interval: 1s
start_period: 10s"""
return compose_str
+140
View File
@@ -0,0 +1,140 @@
import asyncio
import os
import signal
import sys
from contextlib import contextmanager
from typing import Callable, Optional, 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):
asyncio.run(coro)
yield _Runner()
async def subp_exec(
cmd: str,
*args: str,
input: Optional[str] = None,
wait: Optional[float] = None,
verbose: bool = False,
collect: bool = False,
on_stdout: Optional[Callable[[str], Optional[bool]]] = None,
) -> tuple[Optional[str], Optional[str]]:
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()
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:
os.killpg(os.getpgid(proc.pid), signal.SIGINT)
except (ProcessLookupError, KeyboardInterrupt):
pass
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: Optional[Callable[[str], Optional[bool]]] = None,
) -> Optional[bytearray]:
if collect:
ba = bytearray()
def handle(line: bytes):
nonlocal on_line
nonlocal display
if collect:
ba.extend(line)
if display:
sys.stdout.write(line.decode())
if on_line:
if on_line(line.decode()):
on_line = None
display = True
async for line in stream:
await asyncio.to_thread(handle, line)
if collect:
return ba
else:
return None
+64
View File
@@ -0,0 +1,64 @@
import sys
import threading
import time
from typing import Callable
class Progress:
delay: float = 0.1
@staticmethod
def spinning_cursor():
while True:
yield from "|/-\\"
def __init__(self, *, message=""):
self.message = message
self.spinner_generator = self.spinning_cursor()
def spinner_iteration(self):
message = self.message
sys.stdout.write(next(self.spinner_generator) + " " + message)
sys.stdout.flush()
time.sleep(self.delay)
# clear the spinner and message
sys.stdout.write(
"\b" * (len(message) + 2)
+ " " * (len(message) + 2)
+ "\b" * (len(message) + 2)
)
sys.stdout.flush()
def spinner_task(self):
while self.message:
message = self.message
sys.stdout.write(next(self.spinner_generator) + " " + message)
sys.stdout.flush()
time.sleep(self.delay)
# clear the spinner and message
sys.stdout.write(
"\b" * (len(message) + 2)
+ " " * (len(message) + 2)
+ "\b" * (len(message) + 2)
)
sys.stdout.flush()
def __enter__(self) -> Callable[[str], None]:
self.thread = threading.Thread(target=self.spinner_task)
self.thread.start()
def set_message(message):
self.message = message
if not message:
self.thread.join()
return set_message
def __exit__(self, exception, value, tb):
self.message = ""
try:
self.thread.join()
finally:
del self.thread
if exception is not None:
return False