diff --git a/libs/cli/langgraph_cli/config.py b/libs/cli/langgraph_cli/config.py index 4660e3223..737962740 100644 --- a/libs/cli/langgraph_cli/config.py +++ b/libs/cli/langgraph_cli/config.py @@ -383,6 +383,14 @@ class Config(TypedDict, total=False): Only relevant if Python dependencies are installed via pip. If omitted, default pip settings are used. """ + pip_installer: Optional[str] + """Optional. Python package installer to use ('auto', 'pip', 'uv'). + + - 'auto' (default): Use uv for supported base images, otherwise pip + - 'pip': Force use of pip regardless of base image support + - 'uv': Force use of uv (will fail if base image doesn't support it) + """ + dockerfile_lines: list[str] """Optional. Additional Docker instructions that will be appended to your base Dockerfile. @@ -536,6 +544,7 @@ def validate_config(config: Config) -> Config: "node_version": node_version, "python_version": python_version, "pip_config_file": config.get("pip_config_file"), + "pip_installer": config.get("pip_installer", "auto"), "_INTERNAL_docker_tag": config.get("_INTERNAL_docker_tag"), "base_image": config.get("base_image"), "image_distro": image_distro, @@ -600,6 +609,13 @@ def validate_config(config: Config) -> Config: "Must be either 'debian' or 'wolfi'." ) + if pip_installer := config.get("pip_installer"): + if pip_installer not in ["auto", "pip", "uv"]: + raise click.UsageError( + f"Invalid pip_installer: '{pip_installer}'. " + "Must be 'auto', 'pip', or 'uv'." + ) + # Validate auth config if auth_conf := config.get("auth"): if "path" in auth_conf: @@ -1114,12 +1130,21 @@ def python_config_to_docker( base_image: str, ) -> tuple[str, dict[str, str]]: """Generate a Dockerfile from the configuration.""" - if _image_supports_uv(base_image): + pip_installer = config.get("pip_installer", "auto") + + if pip_installer == "uv": install_cmd = "uv pip install --system" uv_removal = "RUN uv pip uninstall --system pip setuptools wheel && rm /usr/bin/uv /usr/bin/uvx" - else: + elif pip_installer == "pip": install_cmd = "pip install" uv_removal = "" + else: + if _image_supports_uv(base_image): + install_cmd = "uv pip install --system" + uv_removal = "RUN uv pip uninstall --system pip setuptools wheel && rm /usr/bin/uv /usr/bin/uvx" + else: + install_cmd = "pip install" + uv_removal = "" # configure pip pip_install = f"PYTHONDONTWRITEBYTECODE=1 {install_cmd} --no-cache-dir -c /api/constraints.txt" diff --git a/libs/cli/pyproject.toml b/libs/cli/pyproject.toml index 0756c8bfe..6de4b7773 100644 --- a/libs/cli/pyproject.toml +++ b/libs/cli/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "langgraph-cli" -version = "0.3.1" +version = "0.3.2" description = "CLI for interacting with LangGraph API" authors = [] requires-python = ">=3.9" diff --git a/libs/cli/schemas/schema.json b/libs/cli/schemas/schema.json index b47bf0013..b9a737c45 100644 --- a/libs/cli/schemas/schema.json +++ b/libs/cli/schemas/schema.json @@ -134,6 +134,17 @@ ], "description": "Optional. Linux distribution for the base image.\n\nMust be either 'debian' or 'wolfi'. If omitted, defaults to 'debian'.\n" }, + "pip_installer": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional. Python package installer to use ('auto', 'pip', 'uv').\n\n" + }, "store": { "anyOf": [ { @@ -287,6 +298,17 @@ ], "description": "Optional. Linux distribution for the base image.\n\nMust be either 'debian' or 'wolfi'. If omitted, defaults to 'debian'.\n" }, + "pip_installer": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional. Python package installer to use ('auto', 'pip', 'uv').\n\n" + }, "store": { "anyOf": [ { diff --git a/libs/cli/schemas/schema.v0.json b/libs/cli/schemas/schema.v0.json index b47bf0013..b9a737c45 100644 --- a/libs/cli/schemas/schema.v0.json +++ b/libs/cli/schemas/schema.v0.json @@ -134,6 +134,17 @@ ], "description": "Optional. Linux distribution for the base image.\n\nMust be either 'debian' or 'wolfi'. If omitted, defaults to 'debian'.\n" }, + "pip_installer": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional. Python package installer to use ('auto', 'pip', 'uv').\n\n" + }, "store": { "anyOf": [ { @@ -287,6 +298,17 @@ ], "description": "Optional. Linux distribution for the base image.\n\nMust be either 'debian' or 'wolfi'. If omitted, defaults to 'debian'.\n" }, + "pip_installer": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional. Python package installer to use ('auto', 'pip', 'uv').\n\n" + }, "store": { "anyOf": [ { diff --git a/libs/cli/tests/unit_tests/test_config.py b/libs/cli/tests/unit_tests/test_config.py index 112f16443..3971b60e2 100644 --- a/libs/cli/tests/unit_tests/test_config.py +++ b/libs/cli/tests/unit_tests/test_config.py @@ -1,3 +1,4 @@ +import copy import json import os import pathlib @@ -40,6 +41,7 @@ def test_validate_config(): "python_version": "3.11", "node_version": None, "pip_config_file": None, + "pip_installer": "auto", "image_distro": "debian", "dockerfile_lines": [], "env": {}, @@ -61,6 +63,7 @@ def test_validate_config(): "python_version": "3.12", "node_version": None, "pip_config_file": "pipconfig.txt", + "pip_installer": "auto", "image_distro": "debian", "dockerfile_lines": ["ARG meow"], "dependencies": [".", "langchain"], @@ -216,6 +219,74 @@ def test_validate_config_image_distro(): assert config["image_distro"] == "debian" +def test_validate_config_pip_installer(): + """Test validation of pip_installer field.""" + # Valid pip_installer values should work + config = validate_config( + { + "python_version": "3.11", + "dependencies": ["."], + "graphs": {"agent": "./agent.py:graph"}, + "pip_installer": "auto", + } + ) + assert config["pip_installer"] == "auto" + + config = validate_config( + { + "python_version": "3.11", + "dependencies": ["."], + "graphs": {"agent": "./agent.py:graph"}, + "pip_installer": "pip", + } + ) + assert config["pip_installer"] == "pip" + + config = validate_config( + { + "python_version": "3.11", + "dependencies": ["."], + "graphs": {"agent": "./agent.py:graph"}, + "pip_installer": "uv", + } + ) + assert config["pip_installer"] == "uv" + + # Missing pip_installer should default to "auto" + config = validate_config( + { + "python_version": "3.11", + "dependencies": ["."], + "graphs": {"agent": "./agent.py:graph"}, + } + ) + assert config["pip_installer"] == "auto" + + # Invalid pip_installer values should raise error + with pytest.raises(click.UsageError) as exc_info: + validate_config( + { + "python_version": "3.11", + "dependencies": ["."], + "graphs": {"agent": "./agent.py:graph"}, + "pip_installer": "conda", + } + ) + assert "Invalid pip_installer: 'conda'" in str(exc_info.value) + assert "Must be 'auto', 'pip', or 'uv'" in str(exc_info.value) + + with pytest.raises(click.UsageError) as exc_info: + validate_config( + { + "python_version": "3.11", + "dependencies": ["."], + "graphs": {"agent": "./agent.py:graph"}, + "pip_installer": "invalid", + } + ) + assert "Invalid pip_installer: 'invalid'" in str(exc_info.value) + + def test_validate_config_file(): with tempfile.TemporaryDirectory() as tmpdir: tmpdir_path = pathlib.Path(tmpdir) @@ -799,6 +870,61 @@ WORKDIR /deps/__outer_unit_tests/unit_tests""" assert additional_contexts == {} +def test_config_to_docker_pip_installer(): + """Test that pip_installer setting affects the generated Dockerfile.""" + graphs = {"agent": "./graphs/agent.py:graph"} + base_config = { + "python_version": "3.11", + "dependencies": ["."], + "graphs": graphs, + } + + # Test default (auto) behavior with UV-supporting image + config_auto = validate_config( + {**copy.deepcopy(base_config), "pip_installer": "auto"} + ) + docker_auto, _ = config_to_docker( + PATH_TO_CONFIG, config_auto, "langchain/langgraph-api:0.2.47" + ) + assert "uv pip install --system" in docker_auto + assert "rm /usr/bin/uv /usr/bin/uvx" in docker_auto + + # Test explicit pip setting + config_pip = validate_config({**copy.deepcopy(base_config), "pip_installer": "pip"}) + docker_pip, _ = config_to_docker( + PATH_TO_CONFIG, config_pip, "langchain/langgraph-api:0.2.47" + ) + assert "uv pip install --system" not in docker_pip + assert "pip install" in docker_pip + assert "rm /usr/bin/uv" not in docker_pip + + # Test explicit uv setting + config_uv = validate_config({**copy.deepcopy(base_config), "pip_installer": "uv"}) + docker_uv, _ = config_to_docker( + PATH_TO_CONFIG, config_uv, "langchain/langgraph-api:0.2.47" + ) + assert "uv pip install --system" in docker_uv + assert "rm /usr/bin/uv /usr/bin/uvx" in docker_uv + + # Test auto behavior with older image (should use pip) + config_auto_old = validate_config( + {**copy.deepcopy(base_config), "pip_installer": "auto"} + ) + docker_auto_old, _ = config_to_docker( + PATH_TO_CONFIG, config_auto_old, "langchain/langgraph-api:0.2.46" + ) + assert "uv pip install --system" not in docker_auto_old + assert "pip install" in docker_auto_old + assert "rm /usr/bin/uv" not in docker_auto_old + + # Test that missing pip_installer defaults to auto behavior + config_default = validate_config(copy.deepcopy(base_config)) + docker_default, _ = config_to_docker( + PATH_TO_CONFIG, config_default, "langchain/langgraph-api:0.2.47" + ) + assert "uv pip install --system" in docker_default + + # config_to_compose def test_config_to_compose_simple_config(): graphs = {"agent": "./agent.py:graph"} diff --git a/libs/cli/uv.lock b/libs/cli/uv.lock index 114732bbe..f73545105 100644 --- a/libs/cli/uv.lock +++ b/libs/cli/uv.lock @@ -501,7 +501,7 @@ wheels = [ [[package]] name = "langgraph-cli" -version = "0.3.1" +version = "0.3.2" source = { editable = "." } dependencies = [ { name = "click" },