Compare commits

...
Author SHA1 Message Date
David Asamu 757415f150 implement cache optimization for requirements generation 2026-02-17 16:29:51 +00:00
4 changed files with 479 additions and 46 deletions
+336 -39
View File
@@ -1,4 +1,5 @@
import json import json
import logging
import os import os
import pathlib import pathlib
import re import re
@@ -10,9 +11,19 @@ import click
from langgraph_cli.schemas import Config, Distros from langgraph_cli.schemas import Config, Distros
logger = logging.getLogger(__name__)
MIN_NODE_VERSION = "20" MIN_NODE_VERSION = "20"
DEFAULT_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" MIN_PYTHON_VERSION = "3.11"
DEFAULT_PYTHON_VERSION = "3.11" DEFAULT_PYTHON_VERSION = "3.11"
@@ -318,6 +329,30 @@ def validate_config_file(config_path: pathlib.Path) -> Config:
return validated 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): class LocalDeps(NamedTuple):
"""A container for referencing and managing local Python dependencies. """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 additional_contexts: A list of paths to directories that contain local
dependencies in parent directories. These directories are added to the dependencies in parent directories. These directories are added to the
Docker build context to ensure that the Dockerfile can access them. 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]] pip_reqs: list[tuple[pathlib.Path, str]]
@@ -374,6 +414,124 @@ class LocalDeps(NamedTuple):
working_dir: str | None = None working_dir: str | None = None
# if there are local dependencies in parent directories, use additional_contexts # if there are local dependencies in parent directories, use additional_contexts
additional_contexts: list[pathlib.Path] = None 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: 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 = {} faux_pkgs = {}
working_dir: str | None = None working_dir: str | None = None
additional_contexts: list[pathlib.Path] = [] additional_contexts: list[pathlib.Path] = []
pkgs_missing_reqs: list[ReqGenSpec] = []
for local_dep in config["dependencies"]: for local_dep in config["dependencies"]:
if not local_dep.startswith("."): 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. # 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. # For faux packages, we'll also check for presence of requirements.txt.
files = os.listdir(resolved) 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: if "pyproject.toml" in files or "setup.py" in files:
# real package # real package
@@ -448,6 +609,31 @@ def _assemble_local_deps(config_path: pathlib.Path, config: Config) -> LocalDeps
# set working_dir # set working_dir
if local_dep == ".": if local_dep == ".":
working_dir = f"/deps/{container_name}" 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: else:
# We could not find a pyproject.toml or setup.py, so treat as a faux package # 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): 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 == ".": if local_dep == ".":
working_dir = container_path working_dir = container_path
# If the faux package has a requirements.txt, we'll add requirement_path = f"{container_path}/requirements.txt"
# 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",
)
)
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( def _update_graph_paths(
@@ -883,22 +1078,106 @@ def python_config_to_docker(
pip_pkgs_str = ( pip_pkgs_str = (
f"RUN {local_reqs_pip_install} {' '.join(pypi_deps)}" if pypi_deps else "" f"RUN {local_reqs_pip_install} {' '.join(pypi_deps)}" if pypi_deps else ""
) )
if local_deps.pip_reqs: # --- Compute optimized package set ---
pip_reqs_str = os.linesep.join( optimized_pkg_paths: set[pathlib.Path] = set()
( if pip_installer == "uv":
f"COPY --from=outer-{reqpath.name} requirements.txt {destpath}" level = _get_cache_optimize_level()
if reqpath.parent in local_deps.additional_contexts logger.info("Docker cache optimization level: %s", level)
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 --"""
else: # Source 1: packages with generated requirements
pip_reqs_str = "" 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://setuptools.pypa.io/en/latest/userguide/datafiles.html#package-data
# https://til.simonwillison.net/python/pyproject # https://til.simonwillison.net/python/pyproject
@@ -926,18 +1205,34 @@ RUN set -ex && \\
for fullpath, (relpath, destpath) in local_deps.faux_pkgs.items() for fullpath, (relpath, destpath) in local_deps.faux_pkgs.items()
) )
local_pkgs_str = os.linesep.join( # --- Split real package source copy into pre-loop and post-loop ---
( local_pkgs_pre_parts: list[str] = []
f"""# -- Adding local package {relpath} -- local_pkgs_post_parts: list[str] = []
COPY --from={name} . /deps/{name}
# -- End of local package {relpath} --""" for fullpath, (relpath, name) in local_deps.real_pkgs.items():
if fullpath in local_deps.additional_contexts if fullpath in local_deps.additional_contexts:
else f"""# -- Adding local package {relpath} -- add_line = f"COPY --from={name} . /deps/{name}"
ADD {relpath} /deps/{name} else:
# -- End of local package {relpath} --""" add_line = f"ADD {relpath} /deps/{name}"
)
for fullpath, (relpath, name) in local_deps.real_pkgs.items() 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 = ( install_node_str: str = (
"RUN /storage/install-node.sh" "RUN /storage/install-node.sh"
@@ -952,8 +1247,9 @@ ADD {relpath} /deps/{name}
install_node_str, install_node_str,
pip_config_file_str, pip_config_file_str,
pip_pkgs_str, pip_pkgs_str,
generated_reqs_str,
pip_reqs_str, pip_reqs_str,
local_pkgs_str, local_pkgs_pre_str,
faux_pkgs_str, faux_pkgs_str,
], ],
) )
@@ -1033,6 +1329,7 @@ ADD {relpath} /deps/{name}
fi; \ fi; \
done""", done""",
"# -- End of local dependencies install --", "# -- End of local dependencies install --",
local_pkgs_post_str,
os.linesep.join(env_vars), os.linesep.join(env_vars),
"", "",
js_inst_str, js_inst_str,
+20 -3
View File
@@ -1,4 +1,5 @@
import json import json
import os
import pathlib import pathlib
import re import re
import shutil import shutil
@@ -6,6 +7,7 @@ import tempfile
import textwrap import textwrap
from contextlib import contextmanager from contextlib import contextmanager
from pathlib import Path from pathlib import Path
from unittest.mock import patch
from click.testing import CliRunner from click.testing import CliRunner
@@ -47,6 +49,7 @@ def temporary_config_folder(config_content: dict, levels: int = 0):
shutil.rmtree(temp_dir) shutil.rmtree(temp_dir)
@patch.dict(os.environ, {"LANGGRAPH_CACHE_OPTIMIZE": "lock"})
def test_prepare_args_and_stdin() -> None: def test_prepare_args_and_stdin() -> None:
# this basically serves as an end-to-end test for using config and docker helpers # this basically serves as an end-to-end test for using config and docker helpers
config_path = pathlib.Path(__file__).parent / "langgraph.json" config_path = pathlib.Path(__file__).parent / "langgraph.json"
@@ -143,15 +146,29 @@ services:
dockerfile_inline: | dockerfile_inline: |
# syntax=docker/dockerfile:1.4 # syntax=docker/dockerfile:1.4
FROM langchain/langgraph-api:3.11 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 . -- # -- Adding local package . --
ADD . /deps/cli ADD . /deps/cli
# -- End of local package . -- # -- End of local package . --
# -- Adding local package ../../.. --
COPY --from=cli_1 . /deps/cli_1
# -- End of local package ../../.. --
# -- Installing all local dependencies -- # -- 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 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 -- # -- 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"}}' ENV LANGSERVE_GRAPHS='{{"agent": "agent.py:graph"}}'
{textwrap.indent(textwrap.dedent(FORMATTED_CLEANUP_LINES), " ")} {textwrap.indent(textwrap.dedent(FORMATTED_CLEANUP_LINES), " ")}
WORKDIR /deps/cli WORKDIR /deps/cli
@@ -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=}"
+23 -4
View File
@@ -4,6 +4,7 @@ import os
import pathlib import pathlib
import tempfile import tempfile
import textwrap import textwrap
from unittest.mock import patch
import click import click
import pytest import pytest
@@ -420,6 +421,7 @@ def test_validate_config_multiplatform():
# config_to_docker # config_to_docker
@patch.dict(os.environ, {"LANGGRAPH_CACHE_OPTIMIZE": "lock"})
def test_config_to_docker_simple(): def test_config_to_docker_simple():
graphs = {"agent": "./agent.py:graph"} graphs = {"agent": "./agent.py:graph"}
actual_docker_stdin, additional_contexts = config_to_docker( actual_docker_stdin, additional_contexts = config_to_docker(
@@ -436,10 +438,17 @@ def test_config_to_docker_simple():
expected_docker_stdin = f"""\ expected_docker_stdin = f"""\
# syntax=docker/dockerfile:1.4 # syntax=docker/dockerfile:1.4
FROM langchain/langgraph-api:3.11 FROM langchain/langgraph-api:3.11
# -- Installing local requirements -- # -- Generate requirements.txt for packages without one --
COPY --from=outer-requirements.txt requirements.txt /deps/outer-graphs_reqs_a/graphs_reqs_a/requirements.txt # Copy packaging metadata files
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 COPY --from=examples pyproject.toml /tmp/dep_metadata/examples/pyproject.toml
# -- End of local requirements install -- # 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 -- # -- Adding local package ../../examples --
COPY --from=examples . /deps/examples COPY --from=examples . /deps/examples
# -- End of local package ../../examples -- # -- End of local package ../../examples --
@@ -647,6 +656,7 @@ ENV LANGSERVE_GRAPHS='{{"agent": "/deps/outer-graphs/src/agent.py:graph"}}'
assert additional_contexts == {} assert additional_contexts == {}
@patch.dict(os.environ, {"LANGGRAPH_CACHE_OPTIMIZE": "lock"})
def test_config_to_docker_pyproject(): def test_config_to_docker_pyproject():
pyproject_str = """[project] pyproject_str = """[project]
name = "custom" name = "custom"
@@ -670,6 +680,15 @@ dependencies = ["langchain"]"""
os.remove(pyproject_path) os.remove(pyproject_path)
expected_docker_stdin = ( expected_docker_stdin = (
"""FROM langchain/langgraph-api:3.11 """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 . -- # -- Adding local package . --
ADD . /deps/unit_tests ADD . /deps/unit_tests
# -- End of local package . -- # -- End of local package . --