From 0d8a8c5847703e78b88b3b2e3fb224a4d3adff63 Mon Sep 17 00:00:00 2001 From: William FH <13333726+hinthornw@users.noreply.github.com> Date: Tue, 8 Jul 2025 12:41:47 -0700 Subject: [PATCH] feat: [CLI] Add arg to retain build deps (setuptools, pip, wheel) (#5404) --- docs/docs/cloud/reference/cli.md | 1 + libs/cli/examples/langgraph.json | 1 + libs/cli/langgraph_cli/config.py | 124 ++++++++++++++++++---- libs/cli/pyproject.toml | 2 +- libs/cli/schemas/schema.json | 36 ++++++- libs/cli/schemas/schema.v0.json | 36 ++++++- libs/cli/schemas/version.schema.json | 46 ++++++++ libs/cli/tests/unit_tests/cli/test_cli.py | 7 +- libs/cli/tests/unit_tests/test_config.py | 57 +++++++++- libs/cli/uv.lock | 2 +- 10 files changed, 280 insertions(+), 32 deletions(-) create mode 100644 libs/cli/schemas/version.schema.json diff --git a/docs/docs/cloud/reference/cli.md b/docs/docs/cloud/reference/cli.md index b43be5485..2181f59e2 100644 --- a/docs/docs/cloud/reference/cli.md +++ b/docs/docs/cloud/reference/cli.md @@ -51,6 +51,7 @@ The LangGraph CLI requires a JSON configuration file that follows this [schema]( | `node_version` | Specify `node_version: 20` to use LangGraph.js. | | `pip_config_file` | Path to `pip` config file. | | `pip_installer` | _(Added in v0.3)_ Optional. Python package installer selector. It can be set to `"auto"`, `"pip"`, or `"uv"`. From version 0.3 onward the default strategy is to run `uv pip`, which typically delivers faster builds while remaining a drop-in replacement. In the uncommon situation where `uv` cannot handle your dependency graph or the structure of your `pyproject.toml`, specify `"pip"` here to revert to the earlier behaviour. | + | `keep_pkg_tools` | _(Added in v0.3.4)_ Optional. Control whether to retain Python packaging tools (`pip`, `setuptools`, `wheel`) in the final image. Accepted values: . By default, all three tools are uninstalled. | | `dockerfile_lines` | Array of additional lines to add to Dockerfile following the import from parent image. | | `checkpointer` | Configuration for the checkpointer. Contains a `ttl` field which is an object with the following keys: | | `http` | HTTP server configuration with the following fields: | diff --git a/libs/cli/examples/langgraph.json b/libs/cli/examples/langgraph.json index 51eb14be9..0d8563e55 100644 --- a/libs/cli/examples/langgraph.json +++ b/libs/cli/examples/langgraph.json @@ -8,6 +8,7 @@ "scikit-learn", "./graphs" ], + "keep_pkg_tools": false, "graphs": { "agent": "./graphs/agent.py:graph", "storm": "./graphs/storm.py:graph" diff --git a/libs/cli/langgraph_cli/config.py b/libs/cli/langgraph_cli/config.py index cd0356350..ce1c6e8b9 100644 --- a/libs/cli/langgraph_cli/config.py +++ b/libs/cli/langgraph_cli/config.py @@ -338,7 +338,7 @@ class HttpConfig(TypedDict, total=False): Default is False. """ disable_meta: bool - """Optional. If True, all meta endpoints (/ok, /info, /metrics, /docs) are disabled. + """Optional. If True, all meta endpoints (/openapi.json, /info, /metrics, /docs) are disabled. Default is False. """ @@ -471,21 +471,61 @@ class Config(TypedDict, total=False): """Optional. Named definitions of UI components emitted by the agent, each pointing to a JS/TS file. """ + keep_pkg_tools: Optional[Union[bool, list[str]]] + """Optional. Control whether to retain Python packaging tools in the final image. + + Allowed tools are: "pip", "setuptools", "wheel". + You can also set to true to include all packaging tools. + """ -PIP_CLEANUP_LINES = """# -- Ensure user deps didn't inadvertently overwrite langgraph-api + +_BUILD_TOOLS = ("pip", "setuptools", "wheel") + + +def _get_pip_cleanup_lines( + install_cmd: str, + to_uninstall: Optional[tuple[str]], + pip_installer: Literal["uv", "pip"], +) -> str: + commands = [ + f"""# -- Ensure user deps didn't inadvertently overwrite langgraph-api RUN mkdir -p /api/langgraph_api /api/langgraph_runtime /api/langgraph_license && \ - touch /api/langgraph_api/__init__.py /api/langgraph_runtime/__init__.py /api/langgraph_license/__init__.py +touch /api/langgraph_api/__init__.py /api/langgraph_runtime/__init__.py /api/langgraph_license/__init__.py RUN PYTHONDONTWRITEBYTECODE=1 {install_cmd} --no-cache-dir --no-deps -e /api # -- End of ensuring user deps didn't inadvertently overwrite langgraph-api -- -# -- Removing pip from the final image ~<:===~~~ -- -RUN pip uninstall -y pip setuptools wheel && \ - rm -rf /usr/local/lib/python*/site-packages/pip* /usr/local/lib/python*/site-packages/setuptools* /usr/local/lib/python*/site-packages/wheel* && \ - find /usr/local/bin -name "pip*" -delete || true -# pip removal for wolfi -RUN rm -rf /usr/lib/python*/site-packages/pip* /usr/lib/python*/site-packages/setuptools* /usr/lib/python*/site-packages/wheel* && \ - find /usr/bin -name "pip*" -delete || true -{uv_removal} -# -- End of pip removal --""" +# -- Removing build deps from the final image ~<:===~~~ --""" + ] + if to_uninstall: + for pack in to_uninstall: + if pack not in _BUILD_TOOLS: + raise ValueError( + f"Invalid build tool: {pack}; must be one of {', '.join(_BUILD_TOOLS)}" + ) + packs_str = " ".join(sorted(to_uninstall)) + commands.append(f"RUN pip uninstall -y {packs_str}") + # Ensure the directories are removed entirely + packages_rm = " ".join( + f"/usr/local/lib/python*/site-packages/{pack}*" for pack in to_uninstall + ) + if "pip" in to_uninstall: + packages_rm += ' && find /usr/local/bin -name "pip*" -delete || true' + commands.append(f"RUN rm -rf {packages_rm}") + wolfi_packages_rm = " ".join( + f"/usr/lib/python*/site-packages/{pack}*" for pack in to_uninstall + ) + if "pip" in to_uninstall: + wolfi_packages_rm += ' && find /usr/bin -name "pip*" -delete || true' + commands.append(f"RUN rm -rf {wolfi_packages_rm}") + if pip_installer == "uv": + commands.append( + f"RUN uv pip uninstall --system {packs_str} && rm /usr/bin/uv /usr/bin/uvx" + ) + else: + if pip_installer == "uv": + commands.append( + "RUN rm /usr/bin/uv /usr/bin/uvx\n# -- End of build deps removal --" + ) + return "\n".join(commands) def _parse_version(version_str: str) -> tuple[int, int]: @@ -563,6 +603,7 @@ def validate_config(config: Config) -> Config: "checkpointer": config.get("checkpointer"), "ui": config.get("ui"), "ui_config": config.get("ui_config"), + "keep_pkg_tools": config.get("keep_pkg_tools"), } if config.get("node_version"): @@ -635,6 +676,22 @@ def validate_config(config: Config) -> Config: f"Invalid http.app format: '{http_conf['app']}'. " "Must be in format './path/to/file.py:attribute_name'" ) + if keep_pkg_tools := config.get("keep_pkg_tools"): + if isinstance(keep_pkg_tools, list): + for tool in keep_pkg_tools: + if tool not in _BUILD_TOOLS: + raise ValueError( + f"Invalid keep_pkg_tools: '{tool}'. " + "Must be one of 'pip', 'setuptools', 'wheel'." + ) + elif keep_pkg_tools is True: + pass + else: + raise ValueError( + f"Invalid keep_pkg_tools: '{keep_pkg_tools}'. " + "Must be bool or list[str] (with values" + " 'pip', 'setuptools', and/or 'wheel')." + ) return config @@ -1128,6 +1185,27 @@ def _image_supports_uv(base_image: str) -> bool: return version >= min_uv +def get_build_tools_to_uninstall(config: Config) -> tuple[str]: + keep_pkg_tools = config.get("keep_pkg_tools") + if not keep_pkg_tools: + return _BUILD_TOOLS + if keep_pkg_tools is True: + return () + expected = _BUILD_TOOLS + if isinstance(keep_pkg_tools, list): + for tool in keep_pkg_tools: + if tool not in expected: + raise ValueError( + f"Invalid build tool to uninstall: {tool}. Expected one of {expected}" + ) + return tuple(sorted(set(_BUILD_TOOLS) - set(keep_pkg_tools))) + else: + raise ValueError( + f"Invalid value for keep_pkg_tools: {keep_pkg_tools}." + " Expected True or a list containing any of {expected}." + ) + + def python_config_to_docker( config_path: pathlib.Path, config: Config, @@ -1135,20 +1213,18 @@ def python_config_to_docker( ) -> tuple[str, dict[str, str]]: """Generate a Dockerfile from the configuration.""" pip_installer = config.get("pip_installer", "auto") - + build_tools_to_uninstall = get_build_tools_to_uninstall(config) + if pip_installer == "auto": + if _image_supports_uv(base_image): + pip_installer = "uv" + else: + pip_installer = "pip" 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" 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 = "" + raise ValueError(f"Invalid pip_installer: {pip_installer}") # configure pip pip_install = f"PYTHONDONTWRITEBYTECODE=1 {install_cmd} --no-cache-dir -c /api/constraints.txt" @@ -1297,7 +1373,11 @@ ADD {relpath} /deps/{name} js_inst_str, "", # Add pip cleanup after all installations are complete - PIP_CLEANUP_LINES.format(install_cmd=install_cmd, uv_removal=uv_removal), + _get_pip_cleanup_lines( + install_cmd=install_cmd, + to_uninstall=build_tools_to_uninstall, + pip_installer=pip_installer, + ), "", f"WORKDIR {local_deps.working_dir}" if local_deps.working_dir else "", ] diff --git a/libs/cli/pyproject.toml b/libs/cli/pyproject.toml index 1a781e6f2..8c959ccab 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.3" +version = "0.3.4" 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 c069f7e9e..19d8893a4 100644 --- a/libs/cli/schemas/schema.json +++ b/libs/cli/schemas/schema.json @@ -134,6 +134,23 @@ ], "description": "Optional. Linux distribution for the base image.\n\nMust be either 'debian' or 'wolfi'. If omitted, defaults to 'debian'.\n" }, + "keep_pkg_tools": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ], + "description": "Optional. Control whether to retain Python packaging tools in the final image.\n\nYou can also set to true to include all packaging tools.\n" + }, "pip_installer": { "anyOf": [ { @@ -298,6 +315,23 @@ ], "description": "Optional. Linux distribution for the base image.\n\nMust be either 'debian' or 'wolfi'. If omitted, defaults to 'debian'.\n" }, + "keep_pkg_tools": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ], + "description": "Optional. Control whether to retain Python packaging tools in the final image.\n\nYou can also set to true to include all packaging tools.\n" + }, "pip_installer": { "anyOf": [ { @@ -505,7 +539,7 @@ }, "disable_meta": { "type": "boolean", - "description": "Optional. If True, all meta endpoints (/ok, /info, /metrics, /docs) are disabled.\n\nDefault is False.\n" + "description": "Optional. If True, all meta endpoints (/openapi.json, /info, /metrics, /docs) are disabled.\n\nDefault is False.\n" }, "disable_runs": { "type": "boolean", diff --git a/libs/cli/schemas/schema.v0.json b/libs/cli/schemas/schema.v0.json index c069f7e9e..19d8893a4 100644 --- a/libs/cli/schemas/schema.v0.json +++ b/libs/cli/schemas/schema.v0.json @@ -134,6 +134,23 @@ ], "description": "Optional. Linux distribution for the base image.\n\nMust be either 'debian' or 'wolfi'. If omitted, defaults to 'debian'.\n" }, + "keep_pkg_tools": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ], + "description": "Optional. Control whether to retain Python packaging tools in the final image.\n\nYou can also set to true to include all packaging tools.\n" + }, "pip_installer": { "anyOf": [ { @@ -298,6 +315,23 @@ ], "description": "Optional. Linux distribution for the base image.\n\nMust be either 'debian' or 'wolfi'. If omitted, defaults to 'debian'.\n" }, + "keep_pkg_tools": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ], + "description": "Optional. Control whether to retain Python packaging tools in the final image.\n\nYou can also set to true to include all packaging tools.\n" + }, "pip_installer": { "anyOf": [ { @@ -505,7 +539,7 @@ }, "disable_meta": { "type": "boolean", - "description": "Optional. If True, all meta endpoints (/ok, /info, /metrics, /docs) are disabled.\n\nDefault is False.\n" + "description": "Optional. If True, all meta endpoints (/openapi.json, /info, /metrics, /docs) are disabled.\n\nDefault is False.\n" }, "disable_runs": { "type": "boolean", diff --git a/libs/cli/schemas/version.schema.json b/libs/cli/schemas/version.schema.json new file mode 100644 index 000000000..041b130c1 --- /dev/null +++ b/libs/cli/schemas/version.schema.json @@ -0,0 +1,46 @@ +{ + "$id": "https://github.com/langchain-ai/langgraph/libs/cli/schemas/version.schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "LangGraph Platform configuration (when building via the langgraph-cli).", + "type": "object", + "oneOf": [ + { + "allOf": [ + { + "oneOf": [ + { + "properties": { + "version": { + "type": "string", + "maxLength": 0 + } + }, + "required": ["version"] + }, + { + "not": { + "required": ["version"] + } + } + ] + }, + { + "$ref": "https://raw.githubusercontent.com/langchain-ai/langgraph/main/libs/cli/schemas/schema.json" + } + ] + }, + { + "allOf": [ + { + "properties": { + "version": { "const": "v0" } + }, + "required": ["version"] + }, + { + "$ref": "https://raw.githubusercontent.com/langchain-ai/langgraph/main/libs/cli/schemas/schema.v0.json" + } + ] + } + ] +} diff --git a/libs/cli/tests/unit_tests/cli/test_cli.py b/libs/cli/tests/unit_tests/cli/test_cli.py index 268f44e00..e6d0a17b4 100644 --- a/libs/cli/tests/unit_tests/cli/test_cli.py +++ b/libs/cli/tests/unit_tests/cli/test_cli.py @@ -10,13 +10,14 @@ from pathlib import Path from click.testing import CliRunner from langgraph_cli.cli import cli, prepare_args_and_stdin -from langgraph_cli.config import PIP_CLEANUP_LINES, Config, 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.util import clean_empty_lines -FORMATTED_CLEANUP_LINES = PIP_CLEANUP_LINES.format( +FORMATTED_CLEANUP_LINES = _get_pip_cleanup_lines( install_cmd="uv pip install --system", - uv_removal="RUN uv pip uninstall --system pip setuptools wheel && rm /usr/bin/uv /usr/bin/uvx", + to_uninstall=("pip", "setuptools", "wheel"), + pip_installer="uv", ) DEFAULT_DOCKER_CAPABILITIES = DockerCapabilities( version_docker=Version(26, 1, 1), diff --git a/libs/cli/tests/unit_tests/test_config.py b/libs/cli/tests/unit_tests/test_config.py index 3971b60e2..691c1e888 100644 --- a/libs/cli/tests/unit_tests/test_config.py +++ b/libs/cli/tests/unit_tests/test_config.py @@ -9,7 +9,8 @@ import click import pytest from langgraph_cli.config import ( - PIP_CLEANUP_LINES, + _BUILD_TOOLS, + _get_pip_cleanup_lines, config_to_compose, config_to_docker, docker_tag, @@ -18,9 +19,10 @@ from langgraph_cli.config import ( ) from langgraph_cli.util import clean_empty_lines -FORMATTED_CLEANUP_LINES = PIP_CLEANUP_LINES.format( +FORMATTED_CLEANUP_LINES = _get_pip_cleanup_lines( install_cmd="uv pip install --system", - uv_removal="RUN uv pip uninstall --system pip setuptools wheel && rm /usr/bin/uv /usr/bin/uvx", + to_uninstall=("pip", "setuptools", "wheel"), + pip_installer="uv", ) PATH_TO_CONFIG = pathlib.Path(__file__).parent / "test_config.json" @@ -51,6 +53,7 @@ def test_validate_config(): "http": None, "ui": None, "ui_config": None, + "keep_pkg_tools": None, **expected_config, } assert actual_config == expected_config @@ -77,6 +80,7 @@ def test_validate_config(): "http": None, "ui": None, "ui_config": None, + "keep_pkg_tools": None, } actual_config = validate_config(expected_config) assert actual_config == expected_config @@ -925,6 +929,53 @@ def test_config_to_docker_pip_installer(): assert "uv pip install --system" in docker_default +def test_config_retain_build_tools(): + graphs = {"agent": "./graphs/agent.py:graph"} + base_config = { + "python_version": "3.11", + "dependencies": ["."], + "graphs": graphs, + } + config_true = validate_config( + {**copy.deepcopy(base_config), "keep_pkg_tools": True} + ) + docker_true, _ = config_to_docker( + PATH_TO_CONFIG, config_true, "langchain/langgraph-api:0.2.47" + ) + assert not any( + "/usr/local/lib/python*/site-packages/" + pckg + "*" in docker_true + for pckg in _BUILD_TOOLS + ) + assert "RUN pip uninstall -y pip setuptools wheel" not in docker_true + config_false = validate_config( + {**copy.deepcopy(base_config), "keep_pkg_tools": False} + ) + docker_false, _ = config_to_docker( + PATH_TO_CONFIG, config_false, "langchain/langgraph-api:0.2.47" + ) + assert all( + "/usr/local/lib/python*/site-packages/" + pckg + "*" in docker_false + for pckg in _BUILD_TOOLS + ) + assert "RUN pip uninstall -y pip setuptools wheel" in docker_false + config_list = validate_config( + {**copy.deepcopy(base_config), "keep_pkg_tools": ["pip", "setuptools"]} + ) + docker_list, _ = config_to_docker( + PATH_TO_CONFIG, config_list, "langchain/langgraph-api:0.2.47" + ) + assert all( + "/usr/local/lib/python*/site-packages/" + pckg + "*" in docker_list + for pckg in ("wheel",) + ) + assert not any( + "/usr/local/lib/python*/site-packages/" + pckg + "*" in docker_list + for pckg in ("pip", "setuptools") + ) + assert "RUN pip uninstall -y wheel" in docker_list + assert "RUN pip uninstall -y pip setuptools" not in docker_list + + # 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 a6a0d0270..b312f2e06 100644 --- a/libs/cli/uv.lock +++ b/libs/cli/uv.lock @@ -522,7 +522,7 @@ wheels = [ [[package]] name = "langgraph-cli" -version = "0.3.3" +version = "0.3.4" source = { editable = "." } dependencies = [ { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },