cli: patch to convert graphs paths to posix (#3318)

* Convert graph paths to POSIX for docker.
* Will set up windows build in a separate PR (some issue w/ pulling down
the image) https://github.com/langchain-ai/langgraph/pull/3271
* Fix for https://github.com/langchain-ai/langgraph/issues/2061
This commit is contained in:
Eugene Yurtsev
2025-02-06 17:29:57 -05:00
committed by GitHub
parent eb19b80d13
commit e9b0bc4d3b
2 changed files with 173 additions and 33 deletions
+6 -3
View File
@@ -1,8 +1,10 @@
"""CLI entrypoint for LangGraph API server."""
import os
import pathlib
import shutil
import sys
from typing import Callable, Optional, Sequence
from typing import Callable, List, Optional, Sequence, Tuple
import click
import click.exceptions
@@ -659,7 +661,7 @@ def prepare_args_and_stdin(
debugger_port: Optional[int] = None,
debugger_base_url: Optional[str] = None,
postgres_uri: Optional[str] = None,
):
) -> Tuple[List[str], str]:
# prepare args
stdin = langgraph_cli.docker.compose(
capabilities,
@@ -703,7 +705,8 @@ def prepare(
debugger_port: Optional[int] = None,
debugger_base_url: Optional[str] = None,
postgres_uri: Optional[str] = None,
):
) -> Tuple[List[str], str]:
"""Prepare the arguments and stdin for running the LangGraph API server."""
config_json = langgraph_cli.config.validate_config_file(config_path)
# pull latest images
if pull:
+167 -30
View File
@@ -86,15 +86,42 @@ class AuthConfig(TypedDict, total=False):
class Config(TypedDict, total=False):
"""Configuration for langgraph-cli."""
python_version: str
"""Python version to use."""
node_version: Optional[str]
"""Node.js version to use."""
pip_config_file: Optional[str]
"""Path to a pip configuration file."""
dockerfile_lines: list[str]
"""Additional lines to add to the Dockerfile."""
dependencies: list[str]
"""Additional Python dependencies to install."""
graphs: dict[str, str]
"""Mapping of graph names to their definitions."""
env: Union[dict[str, str], str]
"""Environment variables to set.
If a dictionary is provided, the keys are environment variable names
and the values are the corresponding environment variable values.
If a string is provided, it is interpreted as a path to a file containing
environment variables in the format KEY=VALUE, with one environment variable
per line.
"""
store: Optional[StoreConfig]
"""Configuration for vector embeddings in store."""
auth: Optional[AuthConfig]
"""Configuration for authentication."""
def _parse_version(version_str: str) -> tuple[int, int]:
@@ -120,6 +147,7 @@ def _parse_node_version(version_str: str) -> int:
def validate_config(config: Config) -> Config:
"""Validate a configuration dictionary."""
config = (
{
"node_version": config.get("node_version"),
@@ -197,6 +225,7 @@ def validate_config(config: Config) -> Config:
def validate_config_file(config_path: pathlib.Path) -> Config:
"""Load and validate a configuration file."""
with open(config_path) as f:
config = json.load(f)
validated = validate_config(config)
@@ -239,7 +268,51 @@ def validate_config_file(config_path: pathlib.Path) -> Config:
class LocalDeps(NamedTuple):
pip_reqs: list[tuple[pathlib.Path, str]]
"""A container for referencing and managing local Python dependencies.
A "local dependency" is any entry in the config's `dependencies` list
that starts with "." (dot), denoting a relative path
to a local directory containing Python code.
For each local dependency, the system inspects its directory to
determine how it should be installed inside the Docker container.
Specifically, we detect:
- **Real packages**: Directories containing a `pyproject.toml` or a `setup.py`.
These can be installed with pip as a regular Python package.
- **Faux packages**: Directories that do not include a `pyproject.toml` or
`setup.py` but do contain Python files and possibly an `__init__.py`. For
these, the code dynamically generates a minimal `pyproject.toml` in the
Docker image so that they can still be installed with pip.
- **Requirements files**: If a local dependency directory
has a `requirements.txt`, it is tracked so that those dependencies
can be installed within the Docker container before installing the local package.
Attributes:
pip_reqs: A list of (host_requirements_path, container_requirements_path)
tuples. Each entry points to a local `requirements.txt` file and where
it should be placed inside the Docker container before running `pip install`.
real_pkgs: A dictionary mapping a local directory path (host side) to the
same dependency string from the config. These directories contain the
necessary files (e.g., `pyproject.toml` or `setup.py`) to be installed
as a standard Python package with pip.
faux_pkgs: A dictionary mapping a local directory path (host side) to a
tuple of (dependency_string, container_package_path). For these
directories—called "faux packages"—the code will generate a minimal
`pyproject.toml` inside the Docker image. This ensures that pip
recognizes them as installable packages, even though they do not
natively include packaging metadata.
working_dir: The path inside the Docker container to use as the working
directory. If the local dependency `"."` is present in the config, this
field captures the path where that dependency will appear in the
container (e.g., `/deps/<name>` or similar). Otherwise, it may be `None`.
"""
pip_reqs: list[tuple[str, str]]
real_pkgs: dict[pathlib.Path, str]
faux_pkgs: dict[pathlib.Path, tuple[str, str]]
# if . is in dependencies, use it as working_dir
@@ -279,8 +352,11 @@ def _assemble_local_deps(config_path: pathlib.Path, config: Config) -> LocalDeps
for local_dep in config["dependencies"]:
if not local_dep.startswith("."):
# If the dependency is not a local path, skip it
continue
# Verify that the local dependency can be resolved
# (e.g., this would raise an informative error if a user mistyped a path).
resolved = config_path.parent / local_dep
# validate local dependency
@@ -295,18 +371,22 @@ def _assemble_local_deps(config_path: pathlib.Path, config: Config) -> LocalDeps
f"Local dependency '{resolved}' must be a subdirectory of '{config_path.parent}'"
)
# if it's installable, add it to local_pkgs
# otherwise, add it to faux_pkgs, and create a pyproject.toml
# Check for pyproject.toml or setup.py
# 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)
if "pyproject.toml" in files:
# real package
real_pkgs[resolved] = local_dep
if local_dep == ".":
working_dir = f"/deps/{resolved.name}"
elif "setup.py" in files:
# real package
real_pkgs[resolved] = local_dep
if local_dep == ".":
working_dir = f"/deps/{resolved.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):
# flat layout
if "-" in resolved.name:
@@ -336,6 +416,9 @@ def _assemble_local_deps(config_path: pathlib.Path, config: Config) -> LocalDeps
faux_pkgs[resolved] = (local_dep, container_path)
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(
@@ -351,6 +434,42 @@ def _assemble_local_deps(config_path: pathlib.Path, config: Config) -> LocalDeps
def _update_graph_paths(
config_path: pathlib.Path, config: Config, local_deps: LocalDeps
) -> None:
"""Remap each graph's import path to the correct in-container path.
The config may contain entries in `graphs` that look like this:
{
"my_graph": "./mygraphs/main.py:graph_function"
}
or
{
"my_graph": "./src/some_subdir/my_file.py:my_graph"
}
which indicate a local file (on the host) followed by a colon and a
callable/object attribute within that file.
During the Docker build, local directories are copied into special
`/deps/` subdirectories, so they can be installed or referenced in
the container. This function updates each graph's import path to
reflect its new location **inside** the Docker container.
Paths inside the container must be POSIX-style paths (even if
the host system is Windows).
Args:
config_path: The path to the config file (e.g. `langgraph.json`).
config: The validated configuration dictionary.
local_deps: An object containing references to local dependencies:
- real Python packages (with a `pyproject.toml` or `setup.py`)
- “faux” packages that need minimal metadata to be installable
- potential `requirements.txt` for local dependencies
- container work directory (if "." is in `dependencies`)
Raises:
ValueError: If the import string is not in the format `<module>:<attribute>`
or if the referenced local file is not found in `dependencies`.
FileNotFoundError: If the local file (module) does not actually exist on disk.
IsADirectoryError: If `module_str` points to a directory instead of a file.
"""
for graph_id, import_str in config["graphs"].items():
module_str, _, attr_str = import_str.partition(":")
if not module_str or not attr_str:
@@ -358,8 +477,12 @@ def _update_graph_paths(
'Import string "{import_str}" must be in format "<module>:<attribute>".'
)
raise ValueError(message.format(import_str=import_str))
if "/" in module_str:
resolved = config_path.parent / module_str
# Check for either forward slash or backslash in the module string
# to determine if it's a file path.
if "/" in module_str or "\\" in module_str:
# Resolve the local path properly on the current OS
resolved = (config_path.parent / module_str).resolve()
if not resolved.exists():
raise FileNotFoundError(f"Could not find local module: {resolved}")
elif not resolved.is_file():
@@ -367,12 +490,19 @@ def _update_graph_paths(
else:
for path in local_deps.real_pkgs:
if resolved.is_relative_to(path):
module_str = f"/deps/{path.name}/{resolved.relative_to(path)}"
container_path = (
pathlib.Path("/deps")
/ path.name
/ resolved.relative_to(path)
)
module_str = container_path.as_posix()
break
else:
for faux_pkg, (_, destpath) in local_deps.faux_pkgs.items():
if resolved.is_relative_to(faux_pkg):
module_str = f"{destpath}/{resolved.relative_to(faux_pkg)}"
container_subpath = resolved.relative_to(faux_pkg)
# Construct the final path, ensuring POSIX style
module_str = f"{destpath}/{container_subpath.as_posix()}"
break
else:
raise ValueError(
@@ -424,7 +554,10 @@ def _update_auth_path(
)
def python_config_to_docker(config_path: pathlib.Path, config: Config, base_image: str):
def python_config_to_docker(
config_path: pathlib.Path, config: Config, base_image: str
) -> str:
"""Generate a Dockerfile from the configuration."""
# configure pip
pip_install = (
"PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt"
@@ -440,8 +573,9 @@ def python_config_to_docker(config_path: pathlib.Path, config: Config, base_imag
# collect dependencies
pypi_deps = [dep for dep in config["dependencies"] if not dep.startswith(".")]
local_deps = _assemble_local_deps(config_path, config)
# rewrite graph paths
# Rewrite graph paths, so they point to the correct location in the Docker container
_update_graph_paths(config_path, config, local_deps)
# Rewrite auth path, so it points to the correct location in the Docker container
_update_auth_path(config_path, config, local_deps)
pip_pkgs_str = f"RUN {pip_install} {' '.join(pypi_deps)}" if pypi_deps else ""
@@ -485,29 +619,31 @@ RUN set -ex && \\
],
)
)
store_config = config.get("store")
env_additional_config = (
""
if not store_config
else f"""
ENV LANGGRAPH_STORE='{json.dumps(store_config)}'
"""
)
env_vars = []
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)}'
"""
return f"""FROM {base_image}:{config['python_version']}
env_vars.append(f"ENV LANGGRAPH_AUTH='{json.dumps(auth_config)}'")
{os.linesep.join(config["dockerfile_lines"])}
graphs = config["graphs"]
env_vars.append(f"ENV LANGSERVE_GRAPHS='{json.dumps(graphs)}'")
{installs}
RUN {pip_install} -e /deps/*
{env_additional_config}
ENV LANGSERVE_GRAPHS='{json.dumps(config["graphs"])}'
{f"WORKDIR {local_deps.working_dir}" if local_deps.working_dir else ""}"""
docker_file_contents = [
f"FROM {base_image}:{config['python_version']}",
"",
os.linesep.join(config["dockerfile_lines"]),
"",
installs,
"",
f"RUN {pip_install} -e /deps/*",
os.linesep.join(env_vars),
"",
f"WORKDIR {local_deps.working_dir}" if local_deps.working_dir else "",
]
return os.linesep.join(docker_file_contents)
def node_config_to_docker(config_path: pathlib.Path, config: Config, base_image: str):
@@ -549,6 +685,7 @@ ENV LANGGRAPH_STORE='{json.dumps(store_config)}'
env_additional_config += f"""
ENV LANGGRAPH_AUTH='{json.dumps(auth_config)}'
"""
return f"""FROM {base_image}:{config['node_version']}
{os.linesep.join(config["dockerfile_lines"])}
@@ -576,7 +713,7 @@ def config_to_compose(
config: Config,
base_image: str,
watch: bool = False,
):
) -> str:
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 = (