Compare commits

..
Author SHA1 Message Date
syachamaneni-lc e3907743cc merge conflict 2026-09-23 15:59:33 -07:00
syachamaneni-lc 4d5bb3c26e dev release 2026-09-23 15:55:46 -07:00
syachamaneni-lc ab851822c5 langgaph cli 2026-09-22 11:32:33 -07:00
8 changed files with 347 additions and 176 deletions
+3 -1
View File
@@ -32,6 +32,8 @@ def test(config: pathlib.Path, port: int, tag: str, verbose: bool):
docker_compose=None, docker_compose=None,
port=port, port=port,
watch=False, watch=False,
debugger_port=None,
debugger_base_url=f"http://127.0.0.1:{port}",
postgres_uri=None, postgres_uri=None,
api_version=None, api_version=None,
image=tag, image=tag,
@@ -171,5 +173,5 @@ if __name__ == "__main__":
except BaseException: except BaseException:
logger.exception("Test failed") logger.exception("Test failed")
raise raise
logger.info("Test execution finished") logger.info("Test execution finished")
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.4.32" __version__ = "0.4.32.dev0"
+3
View File
@@ -48,6 +48,9 @@ def get_anonymized_params(
if kwargs.get("docker_compose"): if kwargs.get("docker_compose"):
params["docker_compose"] = True params["docker_compose"] = True
if kwargs.get("debugger_port"):
params["debugger_port"] = True
if kwargs.get("postgres_uri"): if kwargs.get("postgres_uri"):
params["postgres_uri"] = True params["postgres_uri"] = True
+34 -89
View File
@@ -5,7 +5,6 @@ import pathlib
import shutil import shutil
import sys import sys
from collections.abc import Sequence from collections.abc import Sequence
from urllib.parse import SplitResult, urlencode, urlsplit, urlunsplit
import click import click
import click.exceptions import click.exceptions
@@ -141,6 +140,17 @@ OPT_VERBOSE = click.option(
help="Show more output from the server logs", help="Show more output from the server logs",
) )
OPT_WATCH = click.option("--watch", is_flag=True, help="Restart on file changes") OPT_WATCH = click.option("--watch", is_flag=True, help="Restart on file changes")
OPT_DEBUGGER_PORT = click.option(
"--debugger-port",
type=int,
help="Pull the debugger image locally and serve the UI on specified port",
)
OPT_DEBUGGER_BASE_URL = click.option(
"--debugger-base-url",
type=str,
help="URL used by the debugger to access LangGraph API. Defaults to http://127.0.0.1:[PORT]",
)
OPT_POSTGRES_URI = click.option( OPT_POSTGRES_URI = click.option(
"--postgres-uri", "--postgres-uri",
help="Postgres URI to use for the database. Defaults to launching a local database", help="Postgres URI to use for the database. Defaults to launching a local database",
@@ -232,94 +242,18 @@ cli.add_command(deploy)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _validated_http_url(value: str, option_name: str) -> SplitResult:
try:
parsed = urlsplit(value)
hostname = parsed.hostname
_ = parsed.port
except ValueError as exc:
raise click.UsageError(
f"{option_name} must be a valid HTTP(S) URL without credentials."
) from exc
if (
value != value.strip()
or parsed.scheme not in {"http", "https"}
or not parsed.netloc
or not hostname
or parsed.username is not None
or parsed.password is not None
):
raise click.UsageError(
f"{option_name} must be a valid HTTP(S) URL without credentials."
)
return parsed
def _studio_link(
*,
port: int,
studio_url: str | None,
api_url: str | None,
debugger_base_url: str | None,
) -> str:
if debugger_base_url is not None:
if api_url is not None and api_url != debugger_base_url:
raise click.UsageError(
"--api-url and --debugger-base-url cannot specify different URLs."
)
click.echo(
"Warning: --debugger-base-url is deprecated; use --api-url instead.",
err=True,
)
api_url = debugger_base_url
studio_url = "https://smith.langchain.com" if studio_url is None else studio_url
api_url = f"http://127.0.0.1:{port}" if api_url is None else api_url
studio_parts = _validated_http_url(studio_url, "--studio-url")
_validated_http_url(api_url, "--api-url")
if studio_parts.query or studio_parts.fragment:
raise click.UsageError(
"--studio-url must not include a query string or fragment."
)
studio_path = f"{studio_parts.path.rstrip('/')}/studio/"
return urlunsplit(
studio_parts._replace(
path=studio_path,
query=urlencode({"baseUrl": api_url}),
)
)
@OPT_RECREATE @OPT_RECREATE
@OPT_PULL @OPT_PULL
@OPT_PORT @OPT_PORT
@OPT_DOCKER_COMPOSE @OPT_DOCKER_COMPOSE
@OPT_CONFIG @OPT_CONFIG
@OPT_VERBOSE @OPT_VERBOSE
@OPT_DEBUGGER_PORT
@OPT_DEBUGGER_BASE_URL
@OPT_WATCH @OPT_WATCH
@OPT_POSTGRES_URI @OPT_POSTGRES_URI
@OPT_API_VERSION @OPT_API_VERSION
@OPT_ENGINE_RUNTIME_MODE @OPT_ENGINE_RUNTIME_MODE
@click.option(
"--studio-url",
type=str,
default=None,
help="URL of the LangGraph Studio instance. Defaults to https://smith.langchain.com",
)
@click.option(
"--api-url",
type=str,
default=None,
help="URL that LangGraph Studio uses to access the API. Defaults to http://127.0.0.1:[PORT]",
)
@click.option(
"--debugger-base-url",
type=str,
default=None,
hidden=True,
)
@click.option( @click.option(
"--image", "--image",
type=str, type=str,
@@ -350,21 +284,14 @@ def up(
watch: bool, watch: bool,
wait: bool, wait: bool,
verbose: bool, verbose: bool,
debugger_port: int | None,
debugger_base_url: str | None,
postgres_uri: str | None, postgres_uri: str | None,
api_version: str | None, api_version: str | None,
engine_runtime_mode: str, engine_runtime_mode: str,
studio_url: str | None,
api_url: str | None,
debugger_base_url: str | None,
image: str | None, image: str | None,
base_image: str | None, base_image: str | None,
): ):
studio_link = _studio_link(
port=port,
studio_url=studio_url,
api_url=api_url,
debugger_base_url=debugger_base_url,
)
click.secho("Starting LangGraph API server...", fg="green") click.secho("Starting LangGraph API server...", fg="green")
click.secho( click.secho(
"""For local dev, requires env var LANGSMITH_API_KEY with access to LangSmith Deployment. """For local dev, requires env var LANGSMITH_API_KEY with access to LangSmith Deployment.
@@ -381,6 +308,8 @@ For production use, requires a license key in env var LANGGRAPH_CLOUD_LICENSE_KE
pull=pull, pull=pull,
watch=watch, watch=watch,
verbose=verbose, verbose=verbose,
debugger_port=debugger_port,
debugger_base_url=debugger_base_url,
postgres_uri=postgres_uri, postgres_uri=postgres_uri,
api_version=api_version, api_version=api_version,
engine_runtime_mode=engine_runtime_mode, engine_runtime_mode=engine_runtime_mode,
@@ -408,12 +337,20 @@ For production use, requires a license key in env var LANGGRAPH_CLOUD_LICENSE_KE
if "unpacking to docker.io" in line: if "unpacking to docker.io" in line:
set("Starting...") set("Starting...")
elif "Application startup complete" in line: elif "Application startup complete" in line:
debugger_origin = (
f"http://localhost:{debugger_port}"
if debugger_port
else "https://smith.langchain.com"
)
debugger_base_url_query = (
debugger_base_url or f"http://127.0.0.1:{port}"
)
set("") set("")
sys.stdout.write( sys.stdout.write(
f"""Ready! f"""Ready!
- API: http://localhost:{port} - API: http://localhost:{port}
- Docs: http://localhost:{port}/docs - Docs: http://localhost:{port}/docs
- LangGraph Studio: {studio_link} - LangGraph Studio: {debugger_origin}/studio/?baseUrl={debugger_base_url_query}
""" """
) )
sys.stdout.flush() sys.stdout.flush()
@@ -998,6 +935,8 @@ def prepare_args_and_stdin(
docker_compose: pathlib.Path | None, docker_compose: pathlib.Path | None,
port: int, port: int,
watch: bool, watch: bool,
debugger_port: int | None = None,
debugger_base_url: str | None = None,
postgres_uri: str | None = None, postgres_uri: str | None = None,
api_version: str | None = None, api_version: str | None = None,
engine_runtime_mode: str = "combined_queue_worker", engine_runtime_mode: str = "combined_queue_worker",
@@ -1011,6 +950,8 @@ def prepare_args_and_stdin(
stdin = langgraph_cli.docker.compose( stdin = langgraph_cli.docker.compose(
capabilities, capabilities,
port=port, port=port,
debugger_port=debugger_port,
debugger_base_url=debugger_base_url,
postgres_uri=postgres_uri, postgres_uri=postgres_uri,
image=image, image=image,
base_image=base_image, base_image=base_image,
@@ -1048,6 +989,8 @@ def prepare(
pull: bool, pull: bool,
watch: bool, watch: bool,
verbose: bool, verbose: bool,
debugger_port: int | None = None,
debugger_base_url: str | None = None,
postgres_uri: str | None = None, postgres_uri: str | None = None,
api_version: str | None = None, api_version: str | None = None,
engine_runtime_mode: str = "combined_queue_worker", engine_runtime_mode: str = "combined_queue_worker",
@@ -1089,6 +1032,8 @@ def prepare(
docker_compose=docker_compose, docker_compose=docker_compose,
port=port, port=port,
watch=watch, watch=watch,
debugger_port=debugger_port,
debugger_base_url=debugger_base_url or f"http://127.0.0.1:{port}",
postgres_uri=postgres_uri, postgres_uri=postgres_uri,
api_version=api_version, api_version=api_version,
engine_runtime_mode=engine_runtime_mode, engine_runtime_mode=engine_runtime_mode,
+141
View File
@@ -805,6 +805,46 @@ def _find_deployment(
selector: ByName | ByAgent, selector: ByName | ByAgent,
*, *,
not_found_message: str, not_found_message: str,
<<<<<<< HEAD
agent: dict[str, str] | None = None,
) -> tuple[str | None, bool, int]:
"""Resolve an existing deployment by ID or exact name match."""
needs_creation = False
if deployment_id:
_log_deploy_step(step, f"Using deployment {deployment_id}")
_call_host_backend_with_optional_tenant(
client, lambda c: c.get_deployment(deployment_id)
)
return deployment_id, needs_creation, step + 1
if agent is not None:
_log_deploy_step(
step, f"Looking up agent '{agent['agent_id']}' in {agent['environment']}"
)
existing = _call_host_backend_with_optional_tenant(
client,
lambda c: c.list_deployments(
agent_id=agent["agent_id"], agent_environment=agent["environment"]
),
)
found_id = next(
(
dep["id"]
for dep in existing.get("resources", [])
if not dep.get("is_preview")
),
None,
)
else:
_log_deploy_step(step, f"Looking up deployment '{name}'")
found_id = _call_host_backend_with_optional_tenant(
client, lambda c: find_deployment_id_by_name(c, name)
)
em = _get_emitter()
if found_id:
deployment_id = str(found_id)
em.info(f"Found existing deployment (ID: {deployment_id})")
=======
) -> tuple[ExistingDeployment | None, int]: ) -> tuple[ExistingDeployment | None, int]:
if isinstance(selector, ByAgent): if isinstance(selector, ByAgent):
_log_deploy_step( _log_deploy_step(
@@ -832,6 +872,7 @@ def _find_deployment(
), ),
None, None,
) )
>>>>>>> origin
else: else:
_log_deploy_step(step, f"Looking up deployment '{selector.name}'") _log_deploy_step(step, f"Looking up deployment '{selector.name}'")
found = _call_host_backend_with_optional_tenant( found = _call_host_backend_with_optional_tenant(
@@ -856,12 +897,22 @@ def _create_deployment(
step: int, step: int,
*, *,
name: str | None, name: str | None,
<<<<<<< HEAD
deployment_type: str,
source: str,
config_rel: str | None = None,
secrets: list[dict[str, str]] | None = None,
agent: dict[str, str] | None = None,
) -> tuple[str, int]:
"""Create a deployment and return its ID and next step number."""
=======
source: str, source: str,
source_config: dict[str, object], source_config: dict[str, object],
source_revision_config: dict[str, object], source_revision_config: dict[str, object],
secrets: list[dict[str, str]], secrets: list[dict[str, str]],
agent: dict[str, str] | None = None, agent: dict[str, str] | None = None,
) -> tuple[CreatedDeployment, int]: ) -> tuple[CreatedDeployment, int]:
>>>>>>> origin
_log_deploy_step( _log_deploy_step(
step, step,
f"Creating deployment for agent '{agent['agent_id']}' in {agent['environment']}" f"Creating deployment for agent '{agent['agent_id']}' in {agent['environment']}"
@@ -871,9 +922,15 @@ def _create_deployment(
try: try:
created = client.create_deployment( created = client.create_deployment(
name=name, name=name,
<<<<<<< HEAD
deployment_type=deployment_type,
source=source,
config_path=config_rel,
=======
source=source, source=source,
source_config=source_config, source_config=source_config,
source_revision_config=source_revision_config, source_revision_config=source_revision_config,
>>>>>>> origin
secrets=secrets, secrets=secrets,
agent=agent, agent=agent,
) )
@@ -890,9 +947,36 @@ def _create_deployment(
"POST /v2/deployments succeeded but response missing a valid 'id'" "POST /v2/deployments succeeded but response missing a valid 'id'"
) )
if agent is not None: if agent is not None:
<<<<<<< HEAD
_get_emitter().info(f"Deployment name: {created['name']}")
_get_emitter().info(f"Deployment ID: {created_id}", deployment_id=created_id)
return created_id, step + 1
def _smith_dashboard_base_url(host_url: str | None) -> str:
"""Derive the LangSmith dashboard base URL from the API host URL."""
from urllib.parse import urlparse
if not host_url:
return "https://smith.langchain.com"
parsed = urlparse(host_url)
hostname = parsed.hostname or ""
if hostname in ("localhost", "127.0.0.1"):
return host_url.rstrip("/")
for api_host_suffix in ("api.host.langchain.com", "api.smith.langchain.com"):
if hostname == api_host_suffix:
return "https://smith.langchain.com"
if hostname.endswith(f".{api_host_suffix}"):
prefix = hostname[: -(len(api_host_suffix) + 1)]
return f"https://{prefix}.smith.langchain.com"
return "https://smith.langchain.com"
=======
_get_emitter().info(f"Deployment name: {created.get('name')}") _get_emitter().info(f"Deployment name: {created.get('name')}")
_get_emitter().info(f"Deployment ID: {created_id}", deployment_id=created_id) _get_emitter().info(f"Deployment ID: {created_id}", deployment_id=created_id)
return CreatedDeployment(created_id, created), step + 1 return CreatedDeployment(created_id, created), step + 1
>>>>>>> origin
def _get_deployment_status_url( def _get_deployment_status_url(
@@ -2226,6 +2310,15 @@ def _deploy_cmd(
validate_deploy_commands(install_command, build_command) validate_deploy_commands(install_command, build_command)
agent = None agent = None
if agent_id is not None or environment is not None: if agent_id is not None or environment is not None:
<<<<<<< HEAD
if not agent_id or not agent_id.strip() or not environment:
raise click.UsageError(
"--agent-id and --environment are required together."
)
if name is not None or deployment_id is not None:
raise click.UsageError(
"--agent-id and --environment cannot be combined with --name or --deployment-id."
=======
em.note("Note: --agent-id and --agent-environment flags are in private beta") em.note("Note: --agent-id and --agent-environment flags are in private beta")
if not agent_id or not agent_id.strip() or not environment: if not agent_id or not agent_id.strip() or not environment:
raise click.UsageError( raise click.UsageError(
@@ -2234,6 +2327,7 @@ def _deploy_cmd(
if name is not None or deployment_id is not None: if name is not None or deployment_id is not None:
raise click.UsageError( raise click.UsageError(
"--agent-id and --agent-environment cannot be combined with --name or --deployment-id." "--agent-id and --agent-environment cannot be combined with --name or --deployment-id."
>>>>>>> origin
) )
agent = {"agent_id": agent_id, "environment": environment} agent = {"agent_id": agent_id, "environment": environment}
if not config.exists(): if not config.exists():
@@ -2281,6 +2375,41 @@ def _deploy_cmd(
) )
client = _create_host_backend_client(host_url, api_key, env_vars=env_vars) client = _create_host_backend_client(host_url, api_key, env_vars=env_vars)
<<<<<<< HEAD
step = 1
deployment_id, needs_creation, step = _resolve_deployment(
client,
step,
deployment_id,
name,
not_found_message=(
"No deployment found. Will create."
if use_remote_build
else "No deployment found. Will create after build."
),
agent=agent,
)
if needs_creation:
deployment_id, step = _create_deployment(
client,
step,
name=name,
deployment_type=deployment_type,
source="internal_source" if use_remote_build else "internal_docker",
secrets=secrets,
agent=agent,
)
if not deployment_id:
raise click.ClickException("Failed to determine deployment ID")
# Scan local sources for tracked packages so the new revision carries
# the same metadata GitHub-backed deploys produce. Failures must never
# block a deploy.
=======
>>>>>>> origin
try: try:
tracked_packages = find_tracked_packages(config, config_json) or None tracked_packages = find_tracked_packages(config, config_json) or None
except Exception as exc: except Exception as exc:
@@ -2385,11 +2514,14 @@ def deploy_list(
agent_id: str | None, agent_id: str | None,
environment: str | None, environment: str | None,
) -> None: ) -> None:
<<<<<<< HEAD
=======
if agent_id is not None or environment is not None: if agent_id is not None or environment is not None:
click.secho( click.secho(
"Note: --agent-id and --agent-environment flags are in private beta", "Note: --agent-id and --agent-environment flags are in private beta",
fg="yellow", fg="yellow",
) )
>>>>>>> origin
if agent_id is not None and not agent_id.strip(): if agent_id is not None and not agent_id.strip():
raise click.UsageError("--agent-id must not be empty.") raise click.UsageError("--agent-id must not be empty.")
filters = {} filters = {}
@@ -2401,6 +2533,15 @@ def deploy_list(
deployments = _call_host_backend_with_optional_tenant( deployments = _call_host_backend_with_optional_tenant(
client, client,
lambda c: c.list_deployments(name_contains=name_contains, **filters), lambda c: c.list_deployments(name_contains=name_contains, **filters),
<<<<<<< HEAD
)
resources = response.get("resources") if isinstance(response, dict) else None
deployments = (
[item for item in resources if isinstance(item, dict)]
if isinstance(resources, list)
else []
=======
>>>>>>> origin
) )
if not deployments: if not deployments:
click.echo("No deployments found.") click.echo("No deployments found.")
+36 -1
View File
@@ -142,6 +142,29 @@ def check_capabilities(runner) -> DockerCapabilities:
) )
def debugger_compose(*, port: int | None = None, base_url: str | None = None) -> dict:
if port is None:
return ""
config = {
"langgraph-debugger": {
"image": "langchain/langgraph-debugger",
"restart": "on-failure",
"depends_on": {
"langgraph-postgres": {"condition": "service_healthy"},
},
"ports": [f'"{port}:3968"'],
}
}
if base_url:
config["langgraph-debugger"]["environment"] = {
"VITE_STUDIO_LOCAL_GRAPH_URL": base_url
}
return config
# Function to convert dictionary to YAML # Function to convert dictionary to YAML
def dict_to_yaml(d: dict, *, indent: int = 0) -> str: def dict_to_yaml(d: dict, *, indent: int = 0) -> str:
"""Convert a dictionary to a YAML string.""" """Convert a dictionary to a YAML string."""
@@ -168,6 +191,8 @@ def compose_as_dict(
capabilities: DockerCapabilities, capabilities: DockerCapabilities,
*, *,
port: int, port: int,
debugger_port: int | None = None,
debugger_base_url: str | None = None,
# postgres://user:password@host:port/database?option=value # postgres://user:password@host:port/database?option=value
postgres_uri: str | None = None, postgres_uri: str | None = None,
# If you are running against an already-built image, you can pass it here # If you are running against an already-built image, you can pass it here
@@ -228,6 +253,12 @@ def compose_as_dict(
else: else:
services["langgraph-postgres"]["healthcheck"]["interval"] = "5s" services["langgraph-postgres"]["healthcheck"]["interval"] = "5s"
# Add optional debugger service if debugger_port is specified
if debugger_port:
services["langgraph-debugger"] = debugger_compose(
port=debugger_port, base_url=debugger_base_url
)["langgraph-debugger"]
# Add langgraph-api service # Add langgraph-api service
api_environment = { api_environment = {
"REDIS_URI": "redis://langgraph-redis:6379", "REDIS_URI": "redis://langgraph-redis:6379",
@@ -258,7 +289,7 @@ def compose_as_dict(
"test": "python /api/healthcheck.py", "test": "python /api/healthcheck.py",
"interval": "60s", "interval": "60s",
"start_interval": "1s", "start_interval": "1s",
"start_period": "60s", "start_period": "10s",
} }
# Final compose dictionary with volumes included if needed # Final compose dictionary with volumes included if needed
@@ -274,6 +305,8 @@ def compose(
capabilities: DockerCapabilities, capabilities: DockerCapabilities,
*, *,
port: int, port: int,
debugger_port: int | None = None,
debugger_base_url: str | None = None,
# postgres://user:password@host:port/database?option=value # postgres://user:password@host:port/database?option=value
postgres_uri: str | None = None, postgres_uri: str | None = None,
image: str | None = None, image: str | None = None,
@@ -285,6 +318,8 @@ def compose(
compose_content = compose_as_dict( compose_content = compose_as_dict(
capabilities, capabilities,
port=port, port=port,
debugger_port=debugger_port,
debugger_base_url=debugger_base_url,
postgres_uri=postgres_uri, postgres_uri=postgres_uri,
image=image, image=image,
base_image=base_image, base_image=base_image,
+31 -80
View File
@@ -8,11 +8,10 @@ from contextlib import contextmanager
from pathlib import Path from pathlib import Path
import click import click
import pytest
from click.testing import CliRunner from click.testing import CliRunner
import langgraph_cli.deploy as deploy_module import langgraph_cli.deploy as deploy_module
from langgraph_cli.cli import _studio_link, cli, prepare_args_and_stdin from langgraph_cli.cli import cli, prepare_args_and_stdin
from langgraph_cli.config import Config, _get_pip_cleanup_lines, validate_config from langgraph_cli.config import Config, _get_pip_cleanup_lines, validate_config
from langgraph_cli.docker import DEFAULT_POSTGRES_URI, DockerCapabilities, Version from langgraph_cli.docker import DEFAULT_POSTGRES_URI, DockerCapabilities, Version
from langgraph_cli.util import clean_empty_lines from langgraph_cli.util import clean_empty_lines
@@ -57,6 +56,8 @@ def test_prepare_args_and_stdin() -> None:
Config(dependencies=[".", "../../.."], graphs={"agent": "agent.py:graph"}) Config(dependencies=[".", "../../.."], graphs={"agent": "agent.py:graph"})
) )
port = 8000 port = 8000
debugger_port = 8001
debugger_graph_url = f"http://127.0.0.1:{port}"
actual_args, actual_stdin = prepare_args_and_stdin( actual_args, actual_stdin = prepare_args_and_stdin(
capabilities=DEFAULT_DOCKER_CAPABILITIES, capabilities=DEFAULT_DOCKER_CAPABILITIES,
@@ -64,6 +65,8 @@ def test_prepare_args_and_stdin() -> None:
config=config, config=config,
docker_compose=pathlib.Path("custom-docker-compose.yml"), docker_compose=pathlib.Path("custom-docker-compose.yml"),
port=port, port=port,
debugger_port=debugger_port,
debugger_base_url=debugger_graph_url,
watch=True, watch=True,
) )
@@ -107,6 +110,16 @@ services:
retries: 5 retries: 5
interval: 60s interval: 60s
start_interval: 1s start_interval: 1s
langgraph-debugger:
image: langchain/langgraph-debugger
restart: on-failure
depends_on:
langgraph-postgres:
condition: service_healthy
ports:
- "{debugger_port}:3968"
environment:
VITE_STUDIO_LOCAL_GRAPH_URL: {debugger_graph_url}
langgraph-api: langgraph-api:
ports: ports:
- "8000:8000" - "8000:8000"
@@ -122,7 +135,7 @@ services:
test: python /api/healthcheck.py test: python /api/healthcheck.py
interval: 60s interval: 60s
start_interval: 1s start_interval: 1s
start_period: 60s start_period: 10s
pull_policy: build pull_policy: build
build: build:
@@ -165,6 +178,8 @@ def test_prepare_args_and_stdin_with_image() -> None:
Config(dependencies=[".", "../../.."], graphs={"agent": "agent.py:graph"}) Config(dependencies=[".", "../../.."], graphs={"agent": "agent.py:graph"})
) )
port = 8000 port = 8000
debugger_port = 8001
debugger_graph_url = f"http://127.0.0.1:{port}"
actual_args, actual_stdin = prepare_args_and_stdin( actual_args, actual_stdin = prepare_args_and_stdin(
capabilities=DEFAULT_DOCKER_CAPABILITIES, capabilities=DEFAULT_DOCKER_CAPABILITIES,
@@ -172,6 +187,8 @@ def test_prepare_args_and_stdin_with_image() -> None:
config=config, config=config,
docker_compose=pathlib.Path("custom-docker-compose.yml"), docker_compose=pathlib.Path("custom-docker-compose.yml"),
port=port, port=port,
debugger_port=debugger_port,
debugger_base_url=debugger_graph_url,
watch=True, watch=True,
image="my-cool-image", image="my-cool-image",
) )
@@ -216,6 +233,16 @@ services:
retries: 5 retries: 5
interval: 60s interval: 60s
start_interval: 1s start_interval: 1s
langgraph-debugger:
image: langchain/langgraph-debugger
restart: on-failure
depends_on:
langgraph-postgres:
condition: service_healthy
ports:
- "{debugger_port}:3968"
environment:
VITE_STUDIO_LOCAL_GRAPH_URL: {debugger_graph_url}
langgraph-api: langgraph-api:
ports: ports:
- "8000:8000" - "8000:8000"
@@ -232,7 +259,7 @@ services:
test: python /api/healthcheck.py test: python /api/healthcheck.py
interval: 60s interval: 60s
start_interval: 1s start_interval: 1s
start_period: 60s start_period: 10s
develop: develop:
@@ -262,82 +289,6 @@ def test_version_option() -> None:
) )
def test_up_help_shows_hosted_studio_options() -> None:
result = CliRunner().invoke(cli, ["up", "--help"])
assert result.exit_code == 0, result.output
assert "--studio-url" in result.output
assert "--api-url" in result.output
assert "--debugger-port" not in result.output
assert "--debugger-base-url" not in result.output
def test_studio_link_defaults_to_hosted_studio() -> None:
assert _studio_link(
port=8123,
studio_url=None,
api_url=None,
debugger_base_url=None,
) == ("https://smith.langchain.com/studio/?baseUrl=http%3A%2F%2F127.0.0.1%3A8123")
def test_studio_link_supports_self_hosted_and_remote_urls() -> None:
assert _studio_link(
port=8123,
studio_url="https://langsmith.example.com/prefix/",
api_url="https://api.example.com/graph?tenant=a&region=eu",
debugger_base_url=None,
) == (
"https://langsmith.example.com/prefix/studio/"
"?baseUrl=https%3A%2F%2Fapi.example.com%2Fgraph%3Ftenant%3Da%26region%3Deu"
)
def test_studio_link_supports_deprecated_debugger_base_url(capsys) -> None:
assert _studio_link(
port=8123,
studio_url=None,
api_url=None,
debugger_base_url="https://api.example.com",
).endswith("?baseUrl=https%3A%2F%2Fapi.example.com")
assert "--debugger-base-url is deprecated; use --api-url" in capsys.readouterr().err
@pytest.mark.parametrize(
("studio_url", "api_url"),
[
("javascript:alert(1)", None),
("https://user:password@example.com", None),
("https://smith.langchain.com?workspace=test", None),
(None, "file:///tmp/langgraph.sock"),
(None, "https://user:password@example.com"),
],
)
def test_studio_link_rejects_unsafe_urls(
studio_url: str | None, api_url: str | None
) -> None:
with pytest.raises(click.UsageError):
_studio_link(
port=8123,
studio_url=studio_url,
api_url=api_url,
debugger_base_url=None,
)
def test_studio_link_rejects_conflicting_api_url_aliases() -> None:
with pytest.raises(
click.UsageError,
match="cannot specify different URLs",
):
_studio_link(
port=8123,
studio_url=None,
api_url="https://api.example.com",
debugger_base_url="https://other.example.com",
)
def test_top_level_help_shows_deploy_subcommands() -> None: def test_top_level_help_shows_deploy_subcommands() -> None:
runner = CliRunner() runner = CliRunner()
+98 -4
View File
@@ -16,7 +16,7 @@ DEFAULT_DOCKER_CAPABILITIES = DockerCapabilities(
) )
def test_compose_with_custom_db(): def test_compose_with_no_debugger_and_custom_db():
port = 8123 port = 8123
custom_postgres_uri = "custom_postgres_uri" custom_postgres_uri = "custom_postgres_uri"
actual_compose_str = compose( actual_compose_str = compose(
@@ -42,7 +42,7 @@ def test_compose_with_custom_db():
assert clean_empty_lines(actual_compose_str) == expected_compose_str assert clean_empty_lines(actual_compose_str) == expected_compose_str
def test_compose_with_custom_db_and_healthcheck(): def test_compose_with_no_debugger_and_custom_db_with_healthcheck():
port = 8123 port = 8123
custom_postgres_uri = "custom_postgres_uri" custom_postgres_uri = "custom_postgres_uri"
actual_compose_str = compose( actual_compose_str = compose(
@@ -71,11 +71,39 @@ def test_compose_with_custom_db_and_healthcheck():
test: python /api/healthcheck.py test: python /api/healthcheck.py
interval: 60s interval: 60s
start_interval: 1s start_interval: 1s
start_period: 60s""" start_period: 10s"""
assert clean_empty_lines(actual_compose_str) == expected_compose_str assert clean_empty_lines(actual_compose_str) == expected_compose_str
def test_compose_with_default_db(): def test_compose_with_debugger_and_custom_db():
port = 8123
custom_postgres_uri = "custom_postgres_uri"
actual_compose_str = compose(
DEFAULT_DOCKER_CAPABILITIES,
port=port,
postgres_uri=custom_postgres_uri,
)
expected_compose_str = f"""services:
langgraph-redis:
image: redis:6
healthcheck:
test: redis-cli ping
interval: 5s
timeout: 1s
retries: 5
langgraph-api:
ports:
- "{port}:8000"
depends_on:
langgraph-redis:
condition: service_healthy
environment:
REDIS_URI: redis://langgraph-redis:6379
POSTGRES_URI: {custom_postgres_uri}"""
assert clean_empty_lines(actual_compose_str) == expected_compose_str
def test_compose_with_debugger_and_default_db():
port = 8123 port = 8123
actual_compose_str = compose(DEFAULT_DOCKER_CAPABILITIES, port=port) actual_compose_str = compose(DEFAULT_DOCKER_CAPABILITIES, port=port)
expected_compose_str = f"""volumes: expected_compose_str = f"""volumes:
@@ -274,6 +302,72 @@ def test_compose_with_api_version_and_custom_postgres():
assert clean_empty_lines(actual_compose_str) == expected_compose_str assert clean_empty_lines(actual_compose_str) == expected_compose_str
def test_compose_with_api_version_and_debugger():
"""Test compose function with api_version and debugger port."""
port = 8123
debugger_port = 8001
api_version = "0.2.74"
actual_compose_str = compose(
DEFAULT_DOCKER_CAPABILITIES,
port=port,
api_version=api_version,
debugger_port=debugger_port,
)
expected_compose_str = f"""volumes:
langgraph-data:
driver: local
services:
langgraph-redis:
image: redis:6
healthcheck:
test: redis-cli ping
interval: 5s
timeout: 1s
retries: 5
langgraph-postgres:
image: pgvector/pgvector:pg16
ports:
- "5433:5432"
environment:
POSTGRES_DB: postgres
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
command:
- postgres
- -c
- shared_preload_libraries=vector
volumes:
- langgraph-data:/var/lib/postgresql/data
healthcheck:
test: pg_isready -U postgres
start_period: 10s
timeout: 1s
retries: 5
interval: 5s
langgraph-debugger:
image: langchain/langgraph-debugger
restart: on-failure
depends_on:
langgraph-postgres:
condition: service_healthy
ports:
- "{debugger_port}:3968"
langgraph-api:
ports:
- "{port}:8000"
depends_on:
langgraph-redis:
condition: service_healthy
langgraph-postgres:
condition: service_healthy
environment:
REDIS_URI: redis://langgraph-redis:6379
POSTGRES_URI: {DEFAULT_POSTGRES_URI}"""
assert clean_empty_lines(actual_compose_str) == expected_compose_str
def test_compose_distributed_mode_with_custom_db(): def test_compose_distributed_mode_with_custom_db():
"""Test compose with engine_runtime_mode='distributed' adds N_JOBS_PER_WORKER=0.""" """Test compose with engine_runtime_mode='distributed' adds N_JOBS_PER_WORKER=0."""
port = 8123 port = 8123