mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-08-30 11:49:38 +02:00
libs: add cli, sdk-py, sdk-js and move core langgraph
This commit is contained in:
@@ -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
|
||||
@@ -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}
|
||||
"""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user