diff --git a/libs/cli/langgraph_cli/cli.py b/libs/cli/langgraph_cli/cli.py index 5178e2084..07a71c8b9 100644 --- a/libs/cli/langgraph_cli/cli.py +++ b/libs/cli/langgraph_cli/cli.py @@ -274,23 +274,13 @@ def _build( tag: str, passthrough: Sequence[str] = (), ): - base_image = base_image or ( - "langchain/langgraphjs-api" - if config_json.get("node_version") - else "langchain/langgraph-api" - ) - # pull latest images if pull: runner.run( subp_exec( "docker", "pull", - ( - f"{base_image}:{config_json['node_version']}" - if config_json.get("node_version") - else f"{base_image}:{config_json['python_version']}" - ), + langgraph_cli.config.docker_tag(config_json, base_image), verbose=True, ) ) @@ -450,11 +440,7 @@ def dockerfile(save_path: str, config: pathlib.Path, add_docker_compose: bool) - dockerfile, additional_contexts = langgraph_cli.config.config_to_docker( config, config_json, - ( - "langchain/langgraphjs-api" - if config_json.get("node_version") - else "langchain/langgraph-api" - ), + None, ) with open(str(save_path), "w", encoding="utf-8") as f: f.write(dockerfile) @@ -719,11 +705,7 @@ def prepare_args_and_stdin( config_path, config, watch=watch, - base_image=( - "langchain/langgraphjs-api" - if config.get("node_version") - else "langchain/langgraph-api" - ), + base_image=langgraph_cli.config.default_base_image(config), ) return args, stdin @@ -750,11 +732,7 @@ def prepare( subp_exec( "docker", "pull", - ( - f"langchain/langgraphjs-api:{config_json['node_version']}" - if config_json.get("node_version") - else f"langchain/langgraph-api:{config_json['python_version']}" - ), + langgraph_cli.config.docker_tag(config_json), verbose=verbose, ) ) diff --git a/libs/cli/langgraph_cli/config.py b/libs/cli/langgraph_cli/config.py index 9cd135ff2..d26065011 100644 --- a/libs/cli/langgraph_cli/config.py +++ b/libs/cli/langgraph_cli/config.py @@ -8,7 +8,10 @@ from typing import Any, Literal, NamedTuple, Optional, TypedDict, Union import click MIN_NODE_VERSION = "20" +DEFAULT_NODE_VERSION = "20" + MIN_PYTHON_VERSION = "3.11" +DEFAULT_PYTHON_VERSION = "3.11" class TTLConfig(TypedDict, total=False): @@ -440,38 +443,48 @@ def _parse_node_version(version_str: str) -> int: ) from None +def _is_python_graph(spec: Union[str, dict]) -> bool: + """Check if a graph is a Python graph based on the file extension.""" + + # handle new style config + if isinstance(spec, dict): + spec = spec.get("path") + + file_path = spec.split(":")[0] + file_ext = os.path.splitext(file_path)[1] + return file_ext in [".py", ".pyx", ".pyd", ".pyi"] + + def validate_config(config: Config) -> Config: """Validate a configuration dictionary.""" - config = ( - { - "node_version": config.get("node_version"), - "dockerfile_lines": config.get("dockerfile_lines", []), - "dependencies": config.get("dependencies", []), - "graphs": config.get("graphs", {}), - "env": config.get("env", {}), - "store": config.get("store"), - "auth": config.get("auth"), - "http": config.get("http"), - "checkpointer": config.get("checkpointer"), - "ui": config.get("ui"), - "ui_config": config.get("ui_config"), - } - if config.get("node_version") - else { - "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", {}), - "store": config.get("store"), - "auth": config.get("auth"), - "http": config.get("http"), - "checkpointer": config.get("checkpointer"), - "ui": config.get("ui"), - "ui_config": config.get("ui_config"), - } + + graphs = config.get("graphs", {}) + + some_python = any(_is_python_graph(spec) for spec in graphs.values()) + some_node = any(not _is_python_graph(spec) for spec in graphs.values()) + + node_version = config.get( + "node_version", DEFAULT_NODE_VERSION if some_node else None ) + python_version = config.get( + "python_version", DEFAULT_PYTHON_VERSION if some_python else None + ) + + config = { + "node_version": node_version, + "python_version": python_version, + "pip_config_file": config.get("pip_config_file"), + "dependencies": config.get("dependencies", []), + "dockerfile_lines": config.get("dockerfile_lines", []), + "graphs": config.get("graphs", {}), + "env": config.get("env", {}), + "store": config.get("store"), + "auth": config.get("auth"), + "http": config.get("http"), + "checkpointer": config.get("checkpointer"), + "ui": config.get("ui"), + "ui_config": config.get("ui_config"), + } if config.get("node_version"): node_version = config["node_version"] @@ -1085,26 +1098,11 @@ ADD {relpath} /deps/{name} for fullpath, (relpath, name) in local_deps.real_pkgs.items() ) - ui_inst_str: str = "" - install_node_str: str = "" - - if config.get("ui") and local_deps.working_dir: - install_node_str = "RUN /storage/install-node.sh" - - ui_inst: list[str] = [] - ui_inst.append(f"ENV LANGGRAPH_UI='{json.dumps(config['ui'])}'") - if config.get("ui_config"): - ui_inst.append( - f"ENV LANGGRAPH_UI_CONFIG='{json.dumps(config['ui_config'])}'" - ) - - ui_inst.append( - f"RUN cd {local_deps.working_dir} && {_get_node_pm_install_cmd(config_path, config)} && tsx /api/langgraph_api/js/build.mts", - ) - - ui_inst_str = f"""# -- Installing UI dependencies -- -{os.linesep.join(ui_inst)} -# -- End of UI dependencies install --""" + install_node_str: str = ( + "RUN /storage/install-node.sh" + if (config.get("ui") or config.get("node_version")) and local_deps.working_dir + else "" + ) installs = f"{os.linesep}{os.linesep}".join( filter( @@ -1136,8 +1134,24 @@ ADD {relpath} /deps/{name} f"ENV LANGGRAPH_CHECKPOINTER='{json.dumps(checkpointer_config)}'" ) - graphs = config["graphs"] - env_vars.append(f"ENV LANGSERVE_GRAPHS='{json.dumps(graphs)}'") + if (ui := config.get("ui")) is not None: + env_vars.append(f"ENV LANGGRAPH_UI='{json.dumps(ui)}'") + + if (ui_config := config.get("ui_config")) is not None: + env_vars.append(f"ENV LANGGRAPH_UI_CONFIG='{json.dumps(ui_config)}'") + + env_vars.append(f"ENV LANGSERVE_GRAPHS='{json.dumps(config['graphs'])}'") + + js_inst_str: str = "" + if (config.get("ui") or config.get("node_version")) and local_deps.working_dir: + js_inst_str = os.linesep.join( + [ + "# -- Installing JS dependencies --", + f"ENV NODE_VERSION={config.get('node_version') or DEFAULT_NODE_VERSION}", + f"RUN cd {local_deps.working_dir} && {_get_node_pm_install_cmd(config_path, config)} && tsx /api/langgraph_api/js/build.mts", + "# -- End of JS dependencies install --", + ] + ) docker_file_contents = [ f"FROM {base_image}:{config['python_version']}", @@ -1151,7 +1165,7 @@ ADD {relpath} /deps/{name} "# -- End of local dependencies install --", os.linesep.join(env_vars), "", - ui_inst_str, + js_inst_str, "", PIP_CLEANUP_LINES, # Add pip cleanup after all installations are complete "", @@ -1176,51 +1190,70 @@ def node_config_to_docker( ) -> tuple[str, dict[str, str]]: faux_path = f"/deps/{config_path.parent.name}" install_cmd = _get_node_pm_install_cmd(config_path, config) - store_config = config.get("store") - env_additional_config = ( - "" - if not store_config - else f""" -ENV LANGGRAPH_STORE='{json.dumps(store_config)}' -""" - ) + + env_vars: list[str] = [] + + if (store_config := config.get("store")) is not None: + env_vars.append(f"ENV LANGGRAPH_STORE='{json.dumps(store_config)}'") + if (auth_config := config.get("auth")) is not None: - env_additional_config += f""" -ENV LANGGRAPH_AUTH='{json.dumps(auth_config)}' -""" + env_vars.append(f"ENV LANGGRAPH_AUTH='{json.dumps(auth_config)}'") + if (http_config := config.get("http")) is not None: - env_additional_config += f""" -ENV LANGGRAPH_HTTP='{json.dumps(http_config)}' -""" + env_vars.append(f"ENV LANGGRAPH_HTTP='{json.dumps(http_config)}'") + if (checkpointer_config := config.get("checkpointer")) is not None: - env_additional_config += f""" -ENV LANGGRAPH_CHECKPOINTER='{json.dumps(checkpointer_config)}' -""" + env_vars.append( + f"ENV LANGGRAPH_CHECKPOINTER='{json.dumps(checkpointer_config)}'" + ) - return ( - f"""FROM {base_image}:{config['node_version']} + if ui := config.get("ui"): + env_vars.append(f"ENV LANGGRAPH_UI='{json.dumps(ui)}'") -{os.linesep.join(config["dockerfile_lines"])} + if ui_config := config.get("ui_config"): + env_vars.append(f"ENV LANGGRAPH_UI_CONFIG='{json.dumps(ui_config)}'") -ADD . {faux_path} + env_vars.append(f"ENV LANGSERVE_GRAPHS='{json.dumps(config['graphs'])}'") -RUN cd {faux_path} && {install_cmd} -{env_additional_config} -ENV LANGSERVE_GRAPHS='{json.dumps(config["graphs"])}' -{f"ENV LANGGRAPH_UI='{json.dumps(config['ui'])}'" if config.get("ui") else ""} -{f"ENV LANGGRAPH_UI_CONFIG='{json.dumps(config['ui_config'])}'" if config.get("ui_config") else ""} + docker_file_contents = [ + f"FROM {base_image}:{config['node_version']}", + "", + os.linesep.join(config["dockerfile_lines"]), + "", + f"ADD . {faux_path}", + "", + f"RUN cd {faux_path} && {install_cmd}", + "", + os.linesep.join(env_vars), + "", + f"WORKDIR {faux_path}", + "", + 'RUN (test ! -f /api/langgraph_api/js/build.mts && echo "Prebuild script not found, skipping") || tsx /api/langgraph_api/js/build.mts', + ] -WORKDIR {faux_path} + return os.linesep.join(docker_file_contents), {} -RUN (test ! -f /api/langgraph_api/js/build.mts && echo "Prebuild script not found, skipping") || tsx /api/langgraph_api/js/build.mts""", - {}, - ) + +def default_base_image(config: Config) -> str: + if config.get("node_version") and not config.get("python_version"): + return "langchain/langgraphjs-api" + return "langchain/langgraph-api" + + +def docker_tag(config: Config, base_image: Optional[str] = None) -> str: + base_image = base_image or default_base_image(config) + + if config.get("node_version") and not config.get("python_version"): + return f"{base_image}:{config['node_version']}" + return f"{base_image}:{config['python_version']}" def config_to_docker( - config_path: pathlib.Path, config: Config, base_image: str + config_path: pathlib.Path, config: Config, base_image: Optional[str] = None ) -> tuple[str, dict[str, str]]: - if config.get("node_version"): + base_image = base_image or default_base_image(config) + + if config.get("node_version") and not config.get("python_version"): return node_config_to_docker(config_path, config, base_image) return python_config_to_docker(config_path, config, base_image) @@ -1229,9 +1262,11 @@ def config_to_docker( def config_to_compose( config_path: pathlib.Path, config: Config, - base_image: str, + base_image: Optional[str] = None, watch: bool = False, ) -> str: + base_image = base_image or default_base_image(config) + 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 = ( diff --git a/libs/cli/tests/unit_tests/cli/test_cli.py b/libs/cli/tests/unit_tests/cli/test_cli.py index 4140ee9a5..8762448e6 100644 --- a/libs/cli/tests/unit_tests/cli/test_cli.py +++ b/libs/cli/tests/unit_tests/cli/test_cli.py @@ -178,8 +178,9 @@ def test_dockerfile_command_basic() -> None: """Test the 'dockerfile' command with basic configuration.""" runner = CliRunner() config_content = { - "node_version": "20", # Add any other necessary configuration fields + "python_version": "3.11", "graphs": {"agent": "agent.py:graph"}, + "dependencies": ["."], } with temporary_config_folder(config_content) as temp_dir: diff --git a/libs/cli/tests/unit_tests/multiplatform/js.mts b/libs/cli/tests/unit_tests/multiplatform/js.mts new file mode 100644 index 000000000..e69de29bb diff --git a/libs/cli/tests/unit_tests/multiplatform/python.py b/libs/cli/tests/unit_tests/multiplatform/python.py new file mode 100644 index 000000000..e69de29bb diff --git a/libs/cli/tests/unit_tests/test_config.py b/libs/cli/tests/unit_tests/test_config.py index 8b49bbe38..a70960a7b 100644 --- a/libs/cli/tests/unit_tests/test_config.py +++ b/libs/cli/tests/unit_tests/test_config.py @@ -27,8 +27,10 @@ def test_validate_config(): "agent": "./agent.py:graph", }, } + actual_config = validate_config(expected_config) expected_config = { "python_version": "3.11", + "node_version": None, "pip_config_file": None, "dockerfile_lines": [], "env": {}, @@ -40,13 +42,13 @@ def test_validate_config(): "ui_config": None, **expected_config, } - actual_config = validate_config(expected_config) assert actual_config == expected_config # full config env = ".env" expected_config = { "python_version": "3.12", + "node_version": None, "pip_config_file": "pipconfig.txt", "dockerfile_lines": ["ARG meow"], "dependencies": [".", "langchain"], @@ -69,16 +71,12 @@ def test_validate_config(): # check wrong python version raises with pytest.raises(click.UsageError): - validate_config( - { - "python_version": "3.9", - } - ) + validate_config({"python_version": "3.9"}) # check missing dependencies key raises with pytest.raises(click.UsageError): validate_config( - {"python_version": "3.9", "graphs": {"agent": "./agent.py:graph"}}, + {"python_version": "3.9", "graphs": {"agent": "./agent.py:graph"}} ) # check missing graphs key raises @@ -196,6 +194,47 @@ def test_validate_config_file(): validate_config_file(config_path) +def test_validate_config_multiplatform(): + # default node + config = validate_config( + {"dependencies": ["."], "graphs": {"js": "./js.mts:graph"}} + ) + assert config["node_version"] == "20" + assert config["python_version"] is None + + # default multiplatform + config = validate_config( + { + "node_version": "22", + "python_version": "3.12", + "dependencies": ["."], + "graphs": {"python": "./python.py:graph", "js": "./js.mts:graph"}, + } + ) + assert config["node_version"] == "22" + assert config["python_version"] == "3.12" + + # default multiplatform (full infer) + graphs = {"python": "./python.py:graph", "js": "./js.mts:graph"} + config = validate_config({"dependencies": ["."], "graphs": graphs}) + assert config["node_version"] == "20" + assert config["python_version"] == "3.11" + + # default multiplatform (partial node) + config = validate_config( + {"node_version": "22", "dependencies": ["."], "graphs": graphs} + ) + assert config["node_version"] == "22" + assert config["python_version"] == "3.11" + + # default multiplatform (partial python) + config = validate_config( + {"python_version": "3.12", "dependencies": ["."], "graphs": graphs} + ) + assert config["node_version"] == "20" + assert config["python_version"] == "3.12" + + # config_to_docker def test_config_to_docker_simple(): graphs = {"agent": "./agent.py:graph"} @@ -507,9 +546,9 @@ ARG foo ADD . /deps/unit_tests RUN cd /deps/unit_tests && npm i ENV LANGGRAPH_AUTH='{"path": "./graphs/auth.mts:auth"}' -ENV LANGSERVE_GRAPHS='{"agent": "./graphs/agent.js:graph"}' ENV LANGGRAPH_UI='{"agent": "./graphs/agent.ui.jsx"}' ENV LANGGRAPH_UI_CONFIG='{"shared": ["nuqs"]}' +ENV LANGSERVE_GRAPHS='{"agent": "./graphs/agent.js:graph"}' WORKDIR /deps/unit_tests RUN (test ! -f /api/langgraph_api/js/build.mts && echo "Prebuild script not found, skipping") || tsx /api/langgraph_api/js/build.mts""" @@ -548,12 +587,54 @@ RUN set -ex && \\ # -- Installing all local dependencies -- RUN PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt -e /deps/* # -- End of local dependencies install -- -ENV LANGSERVE_GRAPHS='{{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}}' -# -- Installing UI dependencies -- ENV LANGGRAPH_UI='{{"agent": "./graphs/agent.ui.jsx"}}' ENV LANGGRAPH_UI_CONFIG='{{"shared": ["nuqs"]}}' +ENV LANGSERVE_GRAPHS='{{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}}' +# -- Installing JS dependencies -- +ENV NODE_VERSION=20 RUN cd /deps/__outer_unit_tests/unit_tests && npm i && tsx /api/langgraph_api/js/build.mts -# -- End of UI dependencies install -- +# -- End of JS dependencies install -- +{PIP_CLEANUP_LINES} +WORKDIR /deps/__outer_unit_tests/unit_tests""" + + assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin + assert additional_contexts == {} + + +def test_config_to_docker_multiplatform(): + graphs = { + "python": "./multiplatform/python.py:graph", + "js": "./multiplatform/js.mts:graph", + } + actual_docker_stdin, additional_contexts = config_to_docker( + PATH_TO_CONFIG, + validate_config( + {"node_version": "22", "dependencies": ["."], "graphs": graphs} + ), + "langchain/langgraph-api", + ) + + expected_docker_stdin = f"""FROM langchain/langgraph-api:3.11 +RUN /storage/install-node.sh +# -- Adding non-package dependency unit_tests -- +ADD . /deps/__outer_unit_tests/unit_tests +RUN set -ex && \\ + for line in '[project]' \\ + 'name = "unit_tests"' \\ + 'version = "0.1"' \\ + '[tool.setuptools.package-data]' \\ + '"*" = ["**/*"]'; do \\ + echo "$line" >> /deps/__outer_unit_tests/pyproject.toml; \\ + done +# -- End of non-package dependency unit_tests -- +# -- Installing all local dependencies -- +RUN PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt -e /deps/* +# -- End of local dependencies install -- +ENV LANGSERVE_GRAPHS='{{"python": "/deps/__outer_unit_tests/unit_tests/multiplatform/python.py:graph", "js": "/deps/__outer_unit_tests/unit_tests/multiplatform/js.mts:graph"}}' +# -- Installing JS dependencies -- +ENV NODE_VERSION=22 +RUN cd /deps/__outer_unit_tests/unit_tests && npm i && tsx /api/langgraph_api/js/build.mts +# -- End of JS dependencies install -- {PIP_CLEANUP_LINES} WORKDIR /deps/__outer_unit_tests/unit_tests"""