From 757415f1507a1bfd8719132b8015e9b257513dee Mon Sep 17 00:00:00 2001 From: David Asamu Date: Tue, 17 Feb 2026 16:29:51 +0000 Subject: [PATCH] implement cache optimization for requirements generation --- libs/cli/langgraph_cli/config.py | 375 ++++++++++++++++-- libs/cli/tests/unit_tests/cli/test_cli.py | 23 +- .../unit_tests/test_cache_optimization.py | 100 +++++ libs/cli/tests/unit_tests/test_config.py | 27 +- 4 files changed, 479 insertions(+), 46 deletions(-) create mode 100644 libs/cli/tests/unit_tests/test_cache_optimization.py diff --git a/libs/cli/langgraph_cli/config.py b/libs/cli/langgraph_cli/config.py index 0e80b1e33..3904ff4c9 100644 --- a/libs/cli/langgraph_cli/config.py +++ b/libs/cli/langgraph_cli/config.py @@ -1,4 +1,5 @@ import json +import logging import os import pathlib import re @@ -10,9 +11,19 @@ import click from langgraph_cli.schemas import Config, Distros +logger = logging.getLogger(__name__) + MIN_NODE_VERSION = "20" DEFAULT_NODE_VERSION = "20" +CONSTRAINTS_PATH = "/api/constraints.txt" + +CACHE_OPTIMIZE_LEVELS = {"off", "lock", "pyproject", "all"} +CACHE_OPTIMIZE_ENV_VAR = "LANGGRAPH_CACHE_OPTIMIZE" +DEFAULT_CACHE_OPTIMIZE = "lock" + +METADATA_STAGING_DIR = "/tmp/dep_metadata" + MIN_PYTHON_VERSION = "3.11" DEFAULT_PYTHON_VERSION = "3.11" @@ -318,6 +329,30 @@ def validate_config_file(config_path: pathlib.Path) -> Config: return validated +class ReqGenSpec(NamedTuple): + """Specification for a local package that needs requirements.txt generation. + + Created for every real package missing a requirements.txt, regardless of + cache optimization eligibility. The eligibility decision happens later + in ``python_config_to_docker()`` via ``_is_optimizable()``. + + Attributes: + host_pkg_path: Absolute host path of the package directory. + container_pkg_path: Target path for the package source in the container. + container_req_path: Full path to requirements.txt inside the container. + package_type: ``"pyproject"`` or ``"setup"``. + has_uv_lock: True if the package includes uv.lock. + stage_name: BuildKit context name if outside the build context, else None. + """ + + host_pkg_path: pathlib.Path + container_pkg_path: str + container_req_path: str + package_type: Literal["pyproject", "setup"] + has_uv_lock: bool + stage_name: str | None + + class LocalDeps(NamedTuple): """A container for referencing and managing local Python dependencies. @@ -365,6 +400,11 @@ class LocalDeps(NamedTuple): additional_contexts: A list of paths to directories that contain local dependencies in parent directories. These directories are added to the Docker build context to ensure that the Dockerfile can access them. + + pkgs_missing_reqs: A list of ``ReqGenSpec`` entries for real packages that + do not have a ``requirements.txt`` file. Populated by + ``_assemble_local_deps()`` and consumed by ``python_config_to_docker()`` + for cache-optimized Dockerfile generation. """ pip_reqs: list[tuple[pathlib.Path, str]] @@ -374,6 +414,124 @@ class LocalDeps(NamedTuple): working_dir: str | None = None # if there are local dependencies in parent directories, use additional_contexts additional_contexts: list[pathlib.Path] = None + # real packages that need requirements.txt generation for cache optimization + pkgs_missing_reqs: list[ReqGenSpec] | None = None + + +def _get_cache_optimize_level() -> str: + """Read the cache optimization level from environment. + + Returns one of: "off", "lock", "pyproject", "all". + Falls back to DEFAULT_CACHE_OPTIMIZE on invalid values (with warning). + """ + raw = os.getenv(CACHE_OPTIMIZE_ENV_VAR, DEFAULT_CACHE_OPTIMIZE).strip().lower() + if raw in CACHE_OPTIMIZE_LEVELS: + return raw + logger.warning( + "Invalid %s=%r. Falling back to %r.", + CACHE_OPTIMIZE_ENV_VAR, + raw, + DEFAULT_CACHE_OPTIMIZE, + ) + return DEFAULT_CACHE_OPTIMIZE + + +def _is_optimizable(spec: ReqGenSpec, level: str) -> bool: + """Determine if a package is eligible for deferred source copy. + + Eligibility tiers (cumulative): + - "off": no packages optimized + - "lock": pyproject.toml + uv.lock only (safest, default) + - "pyproject": all pyproject.toml packages (with or without lock) + - "all": all real packages including setup.py + """ + if level == "off": + return False + if level == "lock": + return spec.package_type == "pyproject" and spec.has_uv_lock + if level == "pyproject": + return spec.package_type == "pyproject" + if level == "all": + return spec.package_type in {"pyproject", "setup"} + return False # unknown level — fail safe + + +def _metadata_files(spec: ReqGenSpec) -> list[str]: + """Return the list of packaging metadata files to copy for requirements generation.""" + files = ["pyproject.toml"] if spec.package_type == "pyproject" else ["setup.py"] + if spec.package_type == "pyproject" and spec.has_uv_lock: + files.append("uv.lock") + if spec.package_type == "setup" and (spec.host_pkg_path / "setup.cfg").exists(): + files.append("setup.cfg") + return files + + +def _get_reqs_gen_cmd(spec: ReqGenSpec) -> str: + """Return the shell command to generate requirements.txt from packaging metadata.""" + if spec.package_type == "pyproject" and spec.has_uv_lock: + return "uv export --no-hashes --no-dev --no-emit-local -o 'requirements.txt'" + if spec.package_type == "pyproject": + return ( + f"uv pip compile pyproject.toml -o 'requirements.txt'" + f" --constraint {CONSTRAINTS_PATH}" + ) + return ( + f"uv pip compile setup.py -o 'requirements.txt' --constraint {CONSTRAINTS_PATH}" + ) + + +def _staging_path(spec: ReqGenSpec) -> str: + """Return the staging directory path for a spec's metadata files. + + Metadata is staged in a temporary directory separate from ``/deps/`` + so the install loop does not encounter incomplete package directories. + """ + # Use the last component of container_pkg_path as the staging subdir + name = spec.container_pkg_path.rstrip("/").rsplit("/", 1)[-1] + return f"{METADATA_STAGING_DIR}/{name}" + + +def _generate_requirements_from_metadata( + config_path: pathlib.Path, + specs: list[ReqGenSpec], +) -> str: + """Generate Dockerfile lines to create requirements.txt from packaging metadata. + + Metadata files are staged under ``METADATA_STAGING_DIR`` (not ``/deps/``) + so the install loop does not find incomplete package directories. + + Only processes the given ``specs``. Returns an empty string when the + list is empty. + """ + if not specs: + return "" + + resolved_config_parent = config_path.resolve().parent + + docker_lines = ["# -- Generate requirements.txt for packages without one --"] + docker_lines.append("# Copy packaging metadata files") + + for spec in sorted(specs, key=lambda s: s.container_pkg_path): + staging = _staging_path(spec) + for file_name in _metadata_files(spec): + if spec.stage_name: + docker_lines.append( + f"COPY --from={spec.stage_name} {file_name} {staging}/{file_name}" + ) + else: + file_relpath = (spec.host_pkg_path / file_name).relative_to( + resolved_config_parent + ) + docker_lines.append(f"ADD {file_relpath} {staging}/{file_name}") + + docker_lines.append("") + docker_lines.append("# Generate requirements.txt from packaging metadata") + for spec in sorted(specs, key=lambda s: s.container_pkg_path): + staging = _staging_path(spec) + docker_lines.append(f"RUN cd '{staging}' && {_get_reqs_gen_cmd(spec)}") + + docker_lines.append("# -- End of requirements.txt generation --") + return os.linesep.join(docker_lines) def _assemble_local_deps(config_path: pathlib.Path, config: Config) -> LocalDeps: @@ -409,6 +567,7 @@ def _assemble_local_deps(config_path: pathlib.Path, config: Config) -> LocalDeps faux_pkgs = {} working_dir: str | None = None additional_contexts: list[pathlib.Path] = [] + pkgs_missing_reqs: list[ReqGenSpec] = [] for local_dep in config["dependencies"]: if not local_dep.startswith("."): @@ -435,6 +594,8 @@ def _assemble_local_deps(config_path: pathlib.Path, config: Config) -> LocalDeps # If found, treat as a real package, if not treat as a faux package. # For faux packages, we'll also check for presence of requirements.txt. files = os.listdir(resolved) + # requirement_path is set in both branches and used after them + requirement_path: str = "" if "pyproject.toml" in files or "setup.py" in files: # real package @@ -448,6 +609,31 @@ def _assemble_local_deps(config_path: pathlib.Path, config: Config) -> LocalDeps # set working_dir if local_dep == ".": working_dir = f"/deps/{container_name}" + + requirement_path = f"/deps/{container_name}/requirements.txt" + + # Track real packages that need requirements.txt generation + if "requirements.txt" not in files: + has_pyproject = "pyproject.toml" in files + has_uv_lock = "uv.lock" in files + pkg_type: Literal["pyproject", "setup"] = ( + "pyproject" if has_pyproject else "setup" + ) + + stage_name = None + if resolved in additional_contexts: + stage_name = container_name + + pkgs_missing_reqs.append( + ReqGenSpec( + host_pkg_path=resolved, + container_pkg_path=f"/deps/{container_name}", + container_req_path=f"{METADATA_STAGING_DIR}/{container_name}/requirements.txt", + package_type=pkg_type, + has_uv_lock=has_uv_lock if has_pyproject else False, + stage_name=stage_name, + ) + ) else: # We could not find a pyproject.toml or setup.py, so treat as a faux package if any(file == "__init__.py" for file in files): @@ -480,18 +666,27 @@ def _assemble_local_deps(config_path: pathlib.Path, config: Config) -> LocalDeps if local_dep == ".": working_dir = container_path - # If the faux package has a requirements.txt, we'll add - # the path to the list of requirements to install. - if "requirements.txt" in files: - rfile = resolved / "requirements.txt" - pip_reqs.append( - ( - rfile, - f"{container_path}/requirements.txt", - ) - ) + requirement_path = f"{container_path}/requirements.txt" - return LocalDeps(pip_reqs, real_pkgs, faux_pkgs, working_dir, additional_contexts) + # If the package has a requirements.txt, register it for pre-installation. + # This applies to BOTH real and faux packages. + if "requirements.txt" in files: + rfile = resolved / "requirements.txt" + pip_reqs.append( + ( + rfile, + requirement_path, + ) + ) + + return LocalDeps( + pip_reqs, + real_pkgs, + faux_pkgs, + working_dir, + additional_contexts, + pkgs_missing_reqs, + ) def _update_graph_paths( @@ -883,22 +1078,106 @@ def python_config_to_docker( pip_pkgs_str = ( f"RUN {local_reqs_pip_install} {' '.join(pypi_deps)}" if pypi_deps else "" ) - if local_deps.pip_reqs: - pip_reqs_str = os.linesep.join( - ( - f"COPY --from=outer-{reqpath.name} requirements.txt {destpath}" - if reqpath.parent in local_deps.additional_contexts - else f"ADD {reqpath.relative_to(config_path.parent)} {destpath}" - ) - for reqpath, destpath in local_deps.pip_reqs - ) - pip_reqs_str += f"{os.linesep}RUN {local_reqs_pip_install} {' '.join('-r ' + r for _, r in local_deps.pip_reqs)}" - pip_reqs_str = f"""# -- Installing local requirements -- -{pip_reqs_str} -# -- End of local requirements install --""" + # --- Compute optimized package set --- + optimized_pkg_paths: set[pathlib.Path] = set() + if pip_installer == "uv": + level = _get_cache_optimize_level() + logger.info("Docker cache optimization level: %s", level) - else: - pip_reqs_str = "" + # Source 1: packages with generated requirements + for spec in local_deps.pkgs_missing_reqs or []: + if _is_optimizable(spec, level): + optimized_pkg_paths.add(spec.host_pkg_path) + + # Source 2: real packages with existing requirements.txt + # Their deps are already pre-installed via pip_reqs, so deferred copy is safe — + # but still subject to the same tier eligibility as generated-requirements packages. + if level != "off": + for reqpath, _ in local_deps.pip_reqs or []: + for fullpath in local_deps.real_pkgs: + if reqpath.parent != fullpath: + continue + # Build a synthetic spec to reuse the same eligibility check + pkg_files = os.listdir(fullpath) + has_pyproject = "pyproject.toml" in pkg_files + has_setup = "setup.py" in pkg_files + if not has_pyproject and not has_setup: + continue + synthetic = ReqGenSpec( + host_pkg_path=fullpath, + container_pkg_path="", # unused by _is_optimizable + container_req_path="", # unused by _is_optimizable + package_type="pyproject" if has_pyproject else "setup", + has_uv_lock="uv.lock" in pkg_files, + stage_name=None, # unused by _is_optimizable + ) + if _is_optimizable(synthetic, level): + optimized_pkg_paths.add(fullpath) + + # --- Generate requirements.txt from packaging metadata --- + # Always generate for ALL real packages missing requirements.txt (when using uv). + # This benefits caching even for non-deferred packages because the generated + # requirements.txt install layer is cached and deps are pre-installed before + # the install loop. + all_specs = list(local_deps.pkgs_missing_reqs or []) + generated_reqs_str = ( + _generate_requirements_from_metadata(config_path, all_specs) + if pip_installer == "uv" + else "" + ) + + # --- Build combined requirements install block --- + # Gather all requirement paths: existing pip_reqs + generated specs + copy_lines: list[str] = [] + all_req_paths: list[str] = [] + + # Existing pip_reqs (from packages that already have requirements.txt) + if local_deps.pip_reqs: + for reqpath, destpath in local_deps.pip_reqs: + # For optimized (deferred) packages, redirect the requirements.txt + # copy to the staging directory so we don't create a partial /deps/ + # directory that the install loop would trip over. + resolved_pkg = reqpath.parent.resolve() + if resolved_pkg in optimized_pkg_paths: + name = destpath.rstrip("/").rsplit("/", 2)[-2] + effective_dest = f"{METADATA_STAGING_DIR}/{name}/requirements.txt" + else: + effective_dest = destpath + if reqpath.parent in local_deps.additional_contexts: + if reqpath.parent in local_deps.real_pkgs: + stage_name = local_deps.real_pkgs[reqpath.parent][1] + else: + stage_name = f"outer-{reqpath.parent.name}" + copy_lines.append( + f"COPY --from={stage_name} requirements.txt {effective_dest}" + ) + else: + copy_lines.append( + f"ADD {reqpath.relative_to(config_path.parent)} {effective_dest}" + ) + all_req_paths.append(effective_dest) + + # Generated requirements from specs (all real packages with generation, uv only) + if pip_installer == "uv": + for spec in sorted( + local_deps.pkgs_missing_reqs or [], key=lambda s: s.container_req_path + ): + all_req_paths.append(spec.container_req_path) + + pip_reqs_str = "" + if all_req_paths: + all_req_paths = sorted(set(all_req_paths)) + install_lines = [ + f"RUN {local_reqs_pip_install} -r '{p}'" for p in all_req_paths + ] + pip_reqs_str = f"""# -- Installing from requirements.txt files -- +{os.linesep.join(copy_lines)} +{os.linesep.join(install_lines)} +# -- End of requirements.txt install --""" + # Clean up leading/trailing blank lines in case copy_lines is empty + pip_reqs_str = pip_reqs_str.replace( + os.linesep + os.linesep + "RUN", os.linesep + "RUN" + ) # https://setuptools.pypa.io/en/latest/userguide/datafiles.html#package-data # https://til.simonwillison.net/python/pyproject @@ -926,18 +1205,34 @@ RUN set -ex && \\ for fullpath, (relpath, destpath) in local_deps.faux_pkgs.items() ) - local_pkgs_str = os.linesep.join( - ( - f"""# -- Adding local package {relpath} -- -COPY --from={name} . /deps/{name} -# -- End of local package {relpath} --""" - if fullpath in local_deps.additional_contexts - else f"""# -- Adding local package {relpath} -- -ADD {relpath} /deps/{name} -# -- End of local package {relpath} --""" - ) - for fullpath, (relpath, name) in local_deps.real_pkgs.items() - ) + # --- Split real package source copy into pre-loop and post-loop --- + local_pkgs_pre_parts: list[str] = [] + local_pkgs_post_parts: list[str] = [] + + for fullpath, (relpath, name) in local_deps.real_pkgs.items(): + if fullpath in local_deps.additional_contexts: + add_line = f"COPY --from={name} . /deps/{name}" + else: + add_line = f"ADD {relpath} /deps/{name}" + + if fullpath in optimized_pkg_paths: + # Deferred: full source copy + no-deps editable install after the loop + local_pkgs_post_parts.append( + f"# -- Adding full source for local package {relpath} --\n" + f"{add_line}\n" + f"RUN cd /deps/{name} && {global_reqs_pip_install} --no-deps -e .\n" + f"# -- End of full source for local package {relpath} --" + ) + else: + # Non-optimized: keep in pre-loop position (unchanged behavior) + local_pkgs_pre_parts.append( + f"# -- Adding local package {relpath} --\n" + f"{add_line}\n" + f"# -- End of local package {relpath} --" + ) + + local_pkgs_pre_str = os.linesep.join(local_pkgs_pre_parts) + local_pkgs_post_str = os.linesep.join(local_pkgs_post_parts) install_node_str: str = ( "RUN /storage/install-node.sh" @@ -952,8 +1247,9 @@ ADD {relpath} /deps/{name} install_node_str, pip_config_file_str, pip_pkgs_str, + generated_reqs_str, pip_reqs_str, - local_pkgs_str, + local_pkgs_pre_str, faux_pkgs_str, ], ) @@ -1033,6 +1329,7 @@ ADD {relpath} /deps/{name} fi; \ done""", "# -- End of local dependencies install --", + local_pkgs_post_str, os.linesep.join(env_vars), "", js_inst_str, diff --git a/libs/cli/tests/unit_tests/cli/test_cli.py b/libs/cli/tests/unit_tests/cli/test_cli.py index 58ef68343..db9287aa8 100644 --- a/libs/cli/tests/unit_tests/cli/test_cli.py +++ b/libs/cli/tests/unit_tests/cli/test_cli.py @@ -1,4 +1,5 @@ import json +import os import pathlib import re import shutil @@ -6,6 +7,7 @@ import tempfile import textwrap from contextlib import contextmanager from pathlib import Path +from unittest.mock import patch from click.testing import CliRunner @@ -47,6 +49,7 @@ def temporary_config_folder(config_content: dict, levels: int = 0): shutil.rmtree(temp_dir) +@patch.dict(os.environ, {"LANGGRAPH_CACHE_OPTIMIZE": "lock"}) def test_prepare_args_and_stdin() -> None: # this basically serves as an end-to-end test for using config and docker helpers config_path = pathlib.Path(__file__).parent / "langgraph.json" @@ -143,15 +146,29 @@ services: dockerfile_inline: | # syntax=docker/dockerfile:1.4 FROM langchain/langgraph-api:3.11 + # -- Generate requirements.txt for packages without one -- + # Copy packaging metadata files + ADD pyproject.toml /tmp/dep_metadata/cli/pyproject.toml + COPY --from=cli_1 pyproject.toml /tmp/dep_metadata/cli_1/pyproject.toml + COPY --from=cli_1 uv.lock /tmp/dep_metadata/cli_1/uv.lock + # Generate requirements.txt from packaging metadata + RUN cd '/tmp/dep_metadata/cli' && uv pip compile pyproject.toml -o 'requirements.txt' --constraint /api/constraints.txt + RUN cd '/tmp/dep_metadata/cli_1' && uv export --no-hashes --no-dev --no-emit-local -o 'requirements.txt' + # -- End of requirements.txt generation -- + # -- Installing from requirements.txt files -- + RUN PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir -c /api/constraints.txt -r '/tmp/dep_metadata/cli/requirements.txt' + RUN PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir -c /api/constraints.txt -r '/tmp/dep_metadata/cli_1/requirements.txt' + # -- End of requirements.txt install -- # -- Adding local package . -- ADD . /deps/cli # -- End of local package . -- - # -- Adding local package ../../.. -- - COPY --from=cli_1 . /deps/cli_1 - # -- End of local package ../../.. -- # -- Installing all local dependencies -- RUN for dep in /deps/*; do echo "Installing $$dep"; if [ -d "$$dep" ]; then echo "Installing $$dep"; (cd "$$dep" && PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir -c /api/constraints.txt -e .); fi; done # -- End of local dependencies install -- + # -- Adding full source for local package ../../.. -- + COPY --from=cli_1 . /deps/cli_1 + RUN cd /deps/cli_1 && PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir -c /api/constraints.txt --no-deps -e . + # -- End of full source for local package ../../.. -- ENV LANGSERVE_GRAPHS='{{"agent": "agent.py:graph"}}' {textwrap.indent(textwrap.dedent(FORMATTED_CLEANUP_LINES), " ")} WORKDIR /deps/cli diff --git a/libs/cli/tests/unit_tests/test_cache_optimization.py b/libs/cli/tests/unit_tests/test_cache_optimization.py new file mode 100644 index 000000000..28079f5fd --- /dev/null +++ b/libs/cli/tests/unit_tests/test_cache_optimization.py @@ -0,0 +1,100 @@ +import json +import os +import pathlib +from unittest.mock import patch + +import pytest + +from langgraph_cli.config import config_to_docker, validate_config + +FIXTURES_ROOT = ( + pathlib.Path(__file__).parent.parent / "local_cache_validation" / "projects" +) +CACHE_ENV = "LANGGRAPH_CACHE_OPTIMIZE" +TIERS = ("off", "lock", "pyproject", "all") + +pytestmark = pytest.mark.skipif( + os.getenv("RUN_LOCAL_CACHE_VALIDATION") != "1", + reason="Local-only cache optimization TDD tests.", +) + + +def _dockerfile_for_fixture(fixture_name: str, tier: str) -> str: + config_path = FIXTURES_ROOT / fixture_name / "langgraph.json" + with open(config_path, encoding="utf-8") as f: + raw = json.load(f) + config = validate_config(raw) + + with patch.dict(os.environ, {CACHE_ENV: tier}): + dockerfile, _ = config_to_docker( + config_path=config_path, + config=config, + base_image="langchain/langgraph-api", + ) + return dockerfile + + +@pytest.mark.parametrize( + "fixture_name,expected", + [ + ( + "real_pyproject_lock", + {"off": False, "lock": True, "pyproject": True, "all": True}, + ), + ( + "real_pyproject_no_lock", + {"off": False, "lock": False, "pyproject": True, "all": True}, + ), + ( + "real_setup_py", + {"off": False, "lock": False, "pyproject": False, "all": True}, + ), + ( + "faux_package", + {"off": False, "lock": False, "pyproject": False, "all": False}, + ), + ( + "pip_installer_fallback", + {"off": False, "lock": False, "pyproject": False, "all": False}, + ), + ], +) +def test_tier_deferred_copy_matrix( + fixture_name: str, expected: dict[str, bool] +) -> None: + for tier in TIERS: + dockerfile = _dockerfile_for_fixture(fixture_name, tier) + has_no_deps_editable = "--no-deps -e ." in dockerfile + assert has_no_deps_editable is expected[tier], ( + f"{fixture_name=} {tier=} expected deferred copy {expected[tier]}, " + f"got {has_no_deps_editable}" + ) + + +def test_requirements_before_no_deps_for_eligible_tier() -> None: + dockerfile = _dockerfile_for_fixture("real_pyproject_lock", "lock") + reqs_idx = dockerfile.find("Installing from requirements.txt files") + no_deps_idx = dockerfile.find("--no-deps -e .") + assert reqs_idx != -1 + assert no_deps_idx != -1 + assert reqs_idx < no_deps_idx + + +def test_generation_command_selection_by_fixture() -> None: + docker_lock = _dockerfile_for_fixture("real_pyproject_lock", "lock") + assert "uv export --no-hashes --no-dev --no-emit-local" in docker_lock + + docker_pyproject = _dockerfile_for_fixture("real_pyproject_no_lock", "pyproject") + assert "uv pip compile pyproject.toml" in docker_pyproject + + docker_setup = _dockerfile_for_fixture("real_setup_py", "all") + assert "uv pip compile setup.py" in docker_setup + + +def test_pip_installer_fallback_ignores_tier() -> None: + outputs = { + tier: _dockerfile_for_fixture("pip_installer_fallback", tier) for tier in TIERS + } + first = outputs["off"] + for tier, dockerfile in outputs.items(): + assert dockerfile == first, f"Expected identical dockerfile for {tier=}" diff --git a/libs/cli/tests/unit_tests/test_config.py b/libs/cli/tests/unit_tests/test_config.py index eecc887ed..49ae81fe0 100644 --- a/libs/cli/tests/unit_tests/test_config.py +++ b/libs/cli/tests/unit_tests/test_config.py @@ -4,6 +4,7 @@ import os import pathlib import tempfile import textwrap +from unittest.mock import patch import click import pytest @@ -420,6 +421,7 @@ def test_validate_config_multiplatform(): # config_to_docker +@patch.dict(os.environ, {"LANGGRAPH_CACHE_OPTIMIZE": "lock"}) def test_config_to_docker_simple(): graphs = {"agent": "./agent.py:graph"} actual_docker_stdin, additional_contexts = config_to_docker( @@ -436,10 +438,17 @@ def test_config_to_docker_simple(): expected_docker_stdin = f"""\ # syntax=docker/dockerfile:1.4 FROM langchain/langgraph-api:3.11 -# -- Installing local requirements -- -COPY --from=outer-requirements.txt requirements.txt /deps/outer-graphs_reqs_a/graphs_reqs_a/requirements.txt -RUN PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir -c /api/constraints.txt -r /deps/outer-graphs_reqs_a/graphs_reqs_a/requirements.txt -# -- End of local requirements install -- +# -- Generate requirements.txt for packages without one -- +# Copy packaging metadata files +COPY --from=examples pyproject.toml /tmp/dep_metadata/examples/pyproject.toml +# Generate requirements.txt from packaging metadata +RUN cd '/tmp/dep_metadata/examples' && uv pip compile pyproject.toml -o 'requirements.txt' --constraint /api/constraints.txt +# -- End of requirements.txt generation -- +# -- Installing from requirements.txt files -- +COPY --from=outer-graphs_reqs_a requirements.txt /deps/outer-graphs_reqs_a/graphs_reqs_a/requirements.txt +RUN PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir -c /api/constraints.txt -r '/deps/outer-graphs_reqs_a/graphs_reqs_a/requirements.txt' +RUN PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir -c /api/constraints.txt -r '/tmp/dep_metadata/examples/requirements.txt' +# -- End of requirements.txt install -- # -- Adding local package ../../examples -- COPY --from=examples . /deps/examples # -- End of local package ../../examples -- @@ -647,6 +656,7 @@ ENV LANGSERVE_GRAPHS='{{"agent": "/deps/outer-graphs/src/agent.py:graph"}}' assert additional_contexts == {} +@patch.dict(os.environ, {"LANGGRAPH_CACHE_OPTIMIZE": "lock"}) def test_config_to_docker_pyproject(): pyproject_str = """[project] name = "custom" @@ -670,6 +680,15 @@ dependencies = ["langchain"]""" os.remove(pyproject_path) expected_docker_stdin = ( """FROM langchain/langgraph-api:3.11 +# -- Generate requirements.txt for packages without one -- +# Copy packaging metadata files +ADD pyproject.toml /tmp/dep_metadata/unit_tests/pyproject.toml +# Generate requirements.txt from packaging metadata +RUN cd '/tmp/dep_metadata/unit_tests' && uv pip compile pyproject.toml -o 'requirements.txt' --constraint /api/constraints.txt +# -- End of requirements.txt generation -- +# -- Installing from requirements.txt files -- +RUN PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir -c /api/constraints.txt -r '/tmp/dep_metadata/unit_tests/requirements.txt' +# -- End of requirements.txt install -- # -- Adding local package . -- ADD . /deps/unit_tests # -- End of local package . --