mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-09-02 13:28:43 +02:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
757415f150 |
@@ -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,
|
||||||
|
|||||||
@@ -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=}"
|
||||||
@@ -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 . --
|
||||||
|
|||||||
Reference in New Issue
Block a user