diff --git a/.github/workflows/_integration_test.yml b/.github/workflows/_integration_test.yml index 77486f25f..9fb664d10 100644 --- a/.github/workflows/_integration_test.yml +++ b/.github/workflows/_integration_test.yml @@ -121,8 +121,8 @@ jobs: exit 1 fi LANGCHAIN_OPENAI_VERSION=$(docker run --rm --entrypoint "" langgraph-test-h python -c "import sys; from importlib.metadata import version; v = version('langchain-openai'); print(v);") - if [ "$LANGCHAIN_OPENAI_VERSION" != "1.0.1" ]; then - echo "LANGCHAIN_OPENAI_VERSION != 1.0.1; $LANGCHAIN_OPENAI_VERSION" + if [ "$LANGCHAIN_OPENAI_VERSION" != "1.1.14" ]; then + echo "LANGCHAIN_OPENAI_VERSION != 1.1.14; $LANGCHAIN_OPENAI_VERSION" exit 1 fi LANGCHAIN_ANTHROPIC_VERSION=$(docker run --rm --entrypoint "" langgraph-test-h python -c "import sys; from importlib.metadata import version; v = version('langchain-anthropic'); print(v);") diff --git a/libs/cli/langgraph_cli/__init__.py b/libs/cli/langgraph_cli/__init__.py index c6dd1e2c0..9e6207df0 100644 --- a/libs/cli/langgraph_cli/__init__.py +++ b/libs/cli/langgraph_cli/__init__.py @@ -1 +1 @@ -__version__ = "0.4.23" +__version__ = "0.4.24" diff --git a/libs/cli/langgraph_cli/_ignore.py b/libs/cli/langgraph_cli/_ignore.py new file mode 100644 index 000000000..5c0989516 --- /dev/null +++ b/libs/cli/langgraph_cli/_ignore.py @@ -0,0 +1,124 @@ +"""Shared ignore-file handling for local source filtering.""" + +import pathlib +from dataclasses import dataclass + +import pathspec + +_ALWAYS_EXCLUDE = [ + "__pycache__/", + ".git/", + ".venv/", + "venv/", + "node_modules/", + ".tox/", + ".mypy_cache/", +] +_ALWAYS_EXCLUDE_NAMES = frozenset( + pattern.rstrip("/").split("/")[-1] for pattern in _ALWAYS_EXCLUDE +) +_GLOB_CHARS = frozenset("*?[") + + +@dataclass(frozen=True, slots=True) +class _NegatedDockerignoreHints: + exact_dirs: frozenset[pathlib.PurePosixPath] = frozenset() + wildcard_prefixes: frozenset[pathlib.PurePosixPath] = frozenset() + recurse_all: bool = False + + def requires_dir_walk(self, path: pathlib.PurePosixPath) -> bool: + if self.recurse_all or path in self.exact_dirs: + return True + return any( + path == prefix or path in prefix.parents or prefix in path.parents + for prefix in self.wildcard_prefixes + ) + + +def _build_ignore_spec( + directory: pathlib.Path, *, include_gitignore: bool = True +) -> pathspec.PathSpec: + """Build a PathSpec combining built-in exclusions with ignore files. + + Always excludes common non-source directories (`_ALWAYS_EXCLUDE`). On top + of that, patterns from `.dockerignore` are merged in. `.gitignore` patterns + are optional because some callers need Docker build-context semantics, + while archive creation wants both files. + """ + lines: list[str] = list(_ALWAYS_EXCLUDE) + ignore_files = [".dockerignore"] + if include_gitignore: + ignore_files.append(".gitignore") + for name in ignore_files: + ignore_file = directory / name + if ignore_file.is_file(): + lines.extend(ignore_file.read_text(encoding="utf-8").splitlines()) + return pathspec.PathSpec.from_lines("gitwildmatch", lines) + + +def _is_always_excluded(path: pathlib.PurePosixPath, *, is_dir: bool) -> bool: + """Whether `path` lives inside a built-in excluded directory.""" + parent_parts = path.parts if is_dir else path.parts[:-1] + return any(part in _ALWAYS_EXCLUDE_NAMES for part in parent_parts) + + +def _build_dockerignore_negation_hints( + directory: pathlib.Path, +) -> _NegatedDockerignoreHints: + """Summarize which ignored directories must still be traversed. + + Most negations only require walking a small, concrete chain of parent + directories (for example `!assets/keep.txt` requires entering `assets/`). + Broader glob negations may force a wider walk. + """ + ignore_file = directory / ".dockerignore" + if not ignore_file.is_file(): + return _NegatedDockerignoreHints() + + exact_dirs: set[pathlib.PurePosixPath] = set() + wildcard_prefixes: set[pathlib.PurePosixPath] = set() + recurse_all = False + + for raw_line in ignore_file.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or line.startswith("\\!"): + continue + if line.startswith("\\#"): + line = line[1:] + if not line.startswith("!"): + continue + + pattern = line[1:].lstrip("/") + while pattern.startswith("./"): + pattern = pattern[2:] + pattern = pattern.rstrip("/") + parts = [part for part in pattern.split("/") if part and part != "."] + if not parts: + recurse_all = True + continue + + wildcard_index = next( + ( + idx + for idx, part in enumerate(parts) + if any(char in part for char in _GLOB_CHARS) + ), + None, + ) + if wildcard_index is not None: + literal_parts = parts[:wildcard_index] + if not literal_parts: + recurse_all = True + continue + wildcard_prefixes.add(pathlib.PurePosixPath(*literal_parts)) + continue + + parent_parts = parts[:-1] + for idx in range(1, len(parent_parts) + 1): + exact_dirs.add(pathlib.PurePosixPath(*parent_parts[:idx])) + + return _NegatedDockerignoreHints( + exact_dirs=frozenset(exact_dirs), + wildcard_prefixes=frozenset(wildcard_prefixes), + recurse_all=recurse_all, + ) diff --git a/libs/cli/langgraph_cli/archive.py b/libs/cli/langgraph_cli/archive.py index e481ae2d3..81f96315a 100644 --- a/libs/cli/langgraph_cli/archive.py +++ b/libs/cli/langgraph_cli/archive.py @@ -9,35 +9,12 @@ from contextlib import contextmanager import click import pathspec +from langgraph_cli._ignore import _build_ignore_spec from langgraph_cli.config import Config, _assemble_local_deps _WARN_SIZE = 50 * 1024 * 1024 # 50 MB _MAX_SIZE = 200 * 1024 * 1024 # 200 MB -_ALWAYS_EXCLUDE = [ - "__pycache__/", - ".git/", - ".venv/", - "venv/", - "node_modules/", - ".tox/", - ".mypy_cache/", -] - - -def _build_ignore_spec(directory: pathlib.Path) -> pathspec.PathSpec: - """Build a PathSpec combining built-in exclusions with .dockerignore and .gitignore. - - Always excludes common non-source directories (_ALWAYS_EXCLUDE). On top of - that, patterns from .dockerignore and .gitignore (if present) are merged in. - """ - lines: list[str] = list(_ALWAYS_EXCLUDE) - for name in (".dockerignore", ".gitignore"): - ignore_file = directory / name - if ignore_file.is_file(): - lines.extend(ignore_file.read_text(encoding="utf-8").splitlines()) - return pathspec.PathSpec.from_lines("gitwildmatch", lines) - def _tar_filter(tarinfo: tarfile.TarInfo) -> tarfile.TarInfo | None: """Strip symlinks, hardlinks, and traversal paths from archive.""" diff --git a/libs/cli/langgraph_cli/uv_lock.py b/libs/cli/langgraph_cli/uv_lock.py index 35bba5a8a..1e33d16d7 100644 --- a/libs/cli/langgraph_cli/uv_lock.py +++ b/libs/cli/langgraph_cli/uv_lock.py @@ -10,7 +10,13 @@ except ModuleNotFoundError: # pragma: no cover - exercised on Python 3.10. import tomli as tomllib import click +import pathspec +from langgraph_cli._ignore import ( + _build_dockerignore_negation_hints, + _build_ignore_spec, + _is_always_excluded, +) from langgraph_cli.schemas import Config @@ -440,16 +446,32 @@ def _container_root_for_uv_lock_package( def _uv_lock_package_copy_items( - package: UvLockPackage, plan: UvLockPlan + package: UvLockPackage, + plan: UvLockPlan, + ignore_spec: pathspec.PathSpec, ) -> tuple[tuple[pathlib.PurePosixPath, pathlib.PurePosixPath], ...]: + # Skip entries that .dockerignore / built-in exclusions would strip from + # the build context. Emitting `ADD ` for a file that Docker has + # filtered out causes the build to fail with + # "failed to compute cache key: not found". if package.root != plan.project_root: relative_root = pathlib.PurePosixPath( *package.root.relative_to(plan.project_root).parts ) + if _is_always_excluded(relative_root, is_dir=True) or ignore_spec.match_file( + f"{relative_root.as_posix()}/" + ): + raise click.UsageError( + f"Workspace member '{package.name}' at {relative_root} is " + "excluded from the Docker build context, but uv.lock requires " + "it to be copied into the build context. Remove the matching " + "pattern or drop the member from [tool.uv.workspace].members." + ) return ((relative_root, plan.container_roots[package.root]),) root_container = plan.container_roots[package.root] workspace_member_roots = plan.all_workspace_roots - {plan.project_root} + negated_dockerignore_hints = _build_dockerignore_negation_hints(plan.project_root) def iter_entries( current_dir: pathlib.Path, @@ -461,18 +483,32 @@ def _uv_lock_package_copy_items( # and excluded entirely otherwise. continue - descendant_member_roots = [ - ws_root - for ws_root in workspace_member_roots - if child in ws_root.parents - ] - if child.is_dir() and descendant_member_roots: - entries.extend(iter_entries(child)) - continue - relative_child = pathlib.PurePosixPath( *child.relative_to(plan.project_root).parts ) + is_dir = child.is_dir() + if _is_always_excluded(relative_child, is_dir=is_dir): + continue + ignored = ignore_spec.match_file( + f"{relative_child.as_posix()}/" if is_dir else relative_child.as_posix() + ) + is_workspace_parent = is_dir and any( + child in ws_root.parents for ws_root in workspace_member_roots + ) + + if is_workspace_parent: + entries.extend(iter_entries(child)) + continue + if ( + is_dir + and ignored + and negated_dockerignore_hints.requires_dir_walk(relative_child) + ): + entries.extend(iter_entries(child)) + continue + if ignored: + continue + entries.append( (relative_child, root_container.joinpath(*relative_child.parts)) ) @@ -956,10 +992,13 @@ def python_config_to_docker_uv_lock( docker_plan.add_raw("# -- End of uv.lock dependencies install --") docker_plan.add_blank() + ignore_spec = _build_ignore_spec(plan.project_root, include_gitignore=False) for package in plan.install_order: package_label = package.root.relative_to(plan.project_root).as_posix() or "." docker_plan.add_raw(f"# -- Adding workspace package {package_label} --") - for source, destination in _uv_lock_package_copy_items(package, plan): + for source, destination in _uv_lock_package_copy_items( + package, plan, ignore_spec + ): docker_plan.add_raw(copy_from_project_root(source, destination.as_posix())) docker_plan.add_instruction( "WORKDIR", plan.container_roots[package.root].as_posix() diff --git a/libs/cli/tests/unit_tests/test_archive.py b/libs/cli/tests/unit_tests/test_archive.py index ce99c96ac..2582cf0c9 100644 --- a/libs/cli/tests/unit_tests/test_archive.py +++ b/libs/cli/tests/unit_tests/test_archive.py @@ -99,6 +99,13 @@ class TestBuildIgnoreSpec: assert spec.match_file("app.log") assert spec.match_file("mod.pyc") + def test_can_skip_gitignore(self, tmp_path): + (tmp_path / ".dockerignore").write_text("*.log\n") + (tmp_path / ".gitignore").write_text("*.pyc\n") + spec = _build_ignore_spec(tmp_path, include_gitignore=False) + assert spec.match_file("app.log") + assert not spec.match_file("mod.pyc") + def test_no_ignore_files_only_builtins(self, tmp_path): spec = _build_ignore_spec(tmp_path) assert spec.match_file("__pycache__/") diff --git a/libs/cli/tests/unit_tests/test_config.py b/libs/cli/tests/unit_tests/test_config.py index 50fac53d8..c68edf382 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 @@ -1855,6 +1856,364 @@ def test_config_to_docker_uv_lock_supports_single_uv_project_root(): assert additional_contexts == {} +def test_config_to_docker_uv_lock_skips_dockerignore_entries(): + """Entries filtered by .dockerignore / built-in excludes must not appear + as ADD lines. Docker fails to compute the cache key for paths that the + build context has stripped.""" + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir_path = pathlib.Path(tmpdir) + project_root = tmpdir_path / "single" + project_root.mkdir() + (project_root / "uv.lock").write_text("# uv lock file\n") + (project_root / "pyproject.toml").write_text( + textwrap.dedent( + """ + [project] + name = "single-app" + version = "0.1.0" + dependencies = ["httpx>=0.28"] + + [build-system] + requires = ["setuptools>=61"] + build-backend = "setuptools.build_meta" + """ + ).strip() + + "\n" + ) + (project_root / "langgraph.json").write_text("{}\n") + (project_root / "src").mkdir() + (project_root / "src" / "agent.py").write_text("graph = object()\n") + (project_root / "README.md").write_text("# hi\n") + + # Built-in exclusions — must never appear as ADD lines. + (project_root / ".git").mkdir() + (project_root / ".git" / "HEAD").write_text("ref: refs/heads/main\n") + (project_root / ".venv").mkdir() + (project_root / ".venv" / "pyvenv.cfg").write_text("home = /usr\n") + (project_root / "__pycache__").mkdir() + (project_root / "__pycache__" / "x.cpython-311.pyc").write_bytes(b"\x00") + + # .dockerignore excludes .gitignore and a custom path. + (project_root / ".dockerignore").write_text(".gitignore\nsecrets.env\n") + (project_root / ".gitignore").write_text("*.pyc\n") + (project_root / "secrets.env").write_text("TOKEN=abc\n") + + config = validate_config( + { + "python_version": "3.11", + "graphs": {"agent": "./src/agent.py:graph"}, + "source": {"kind": "uv"}, + } + ) + docker, _ = config_to_docker( + project_root / "langgraph.json", + config, + base_image="langchain/langgraph-api:0.2.47", + ) + + for excluded in ( + "ADD .git ", + "ADD .gitignore ", + "ADD .venv ", + "ADD __pycache__ ", + "ADD secrets.env ", + ): + assert excluded not in docker, ( + f"{excluded!r} should be filtered out of Dockerfile:\n{docker}" + ) + + # The .dockerignore itself is still part of the context and should be + # ADDed (Docker needs it at build time, and archive.py includes it). + assert "ADD .dockerignore /deps/workspace/.dockerignore" in docker + assert "ADD src /deps/workspace/src" in docker + assert "ADD README.md /deps/workspace/README.md" in docker + + +def test_config_to_docker_uv_lock_does_not_apply_gitignore(): + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir_path = pathlib.Path(tmpdir) + project_root = tmpdir_path / "single" + project_root.mkdir() + (project_root / "uv.lock").write_text("# uv lock file\n") + (project_root / "pyproject.toml").write_text( + textwrap.dedent( + """ + [project] + name = "single-app" + version = "0.1.0" + dependencies = ["httpx>=0.28"] + + [build-system] + requires = ["setuptools>=61"] + build-backend = "setuptools.build_meta" + """ + ).strip() + + "\n" + ) + (project_root / "langgraph.json").write_text("{}\n") + (project_root / "src").mkdir() + (project_root / "src" / "agent.py").write_text("graph = object()\n") + (project_root / "README.md").write_text("# hi\n") + (project_root / ".gitignore").write_text("README.md\n") + + config = validate_config( + { + "python_version": "3.11", + "graphs": {"agent": "./src/agent.py:graph"}, + "source": {"kind": "uv"}, + } + ) + docker, _ = config_to_docker( + project_root / "langgraph.json", + config, + base_image="langchain/langgraph-api:0.2.47", + ) + + assert "ADD README.md /deps/workspace/README.md" in docker + + +def test_config_to_docker_uv_lock_skips_dockerignore_entries_in_workspace(): + """Multi-member workspace: ignore patterns must filter root-level entries + AND entries encountered while recursing into directories that contain + workspace members (the `descendant_member_roots` branch).""" + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir_path = pathlib.Path(tmpdir) + project_root, config_path = _write_uv_lock_workspace( + tmpdir_path, + agent_dependencies=["workspace-root", "shared", "httpx>=0.28"], + root_sources="[tool.uv.sources]\nshared = { workspace = true }\nworkspace-root = { workspace = true }", + agent_sources="[tool.uv.sources]\nshared = { workspace = true }\nworkspace-root = { workspace = true }", + ) + root_src = project_root / "src" / "workspace_root" + root_src.mkdir(parents=True) + (root_src / "__init__.py").write_text("__all__ = []\n") + (project_root / "README.md").write_text("workspace root package\n") + + # A non-member sibling of the `apps/agent` member that should be + # filtered out via .dockerignore. This exercises the recursion into + # `apps/` where `apps/agent` is kept (it's a member) but its sibling is + # filtered. + (project_root / "apps" / "scratch.txt").write_text("scratch\n") + # A root-level path that .dockerignore excludes. + (project_root / "secrets.env").write_text("TOKEN=abc\n") + (project_root / ".dockerignore").write_text("secrets.env\napps/scratch.txt\n") + + config = validate_config( + { + "python_version": "3.11", + "graphs": { + "agent": "../../apps/agent/src/agent/graph.py:graph", + }, + "source": {"kind": "uv", "root": "../..", "package": "agent"}, + } + ) + docker, _ = config_to_docker( + config_path, config, base_image="langchain/langgraph-api:0.2.47" + ) + + assert "COPY --from=uv-workspace-root src /deps/workspace/src" in docker + assert ( + "COPY --from=uv-workspace-root README.md /deps/workspace/README.md" + in docker + ) + assert ( + "COPY --from=uv-workspace-root .dockerignore /deps/workspace/.dockerignore" + in docker + ) + assert "secrets.env" not in docker + assert "apps/scratch.txt" not in docker + # Workspace members themselves are still copied via their own per-member + # COPY line — the sibling filter must not disturb this. + assert ( + "COPY --from=uv-workspace-root apps/agent /deps/workspace/apps/agent" + in docker + ) + + +def test_config_to_docker_uv_lock_preserves_negated_dockerignore_descendants(): + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir_path = pathlib.Path(tmpdir) + project_root = tmpdir_path / "single" + project_root.mkdir() + (project_root / "uv.lock").write_text("# uv lock file\n") + (project_root / "pyproject.toml").write_text( + textwrap.dedent( + """ + [project] + name = "single-app" + version = "0.1.0" + dependencies = ["httpx>=0.28"] + + [build-system] + requires = ["setuptools>=61"] + build-backend = "setuptools.build_meta" + """ + ).strip() + + "\n" + ) + (project_root / "langgraph.json").write_text("{}\n") + (project_root / "src").mkdir() + (project_root / "src" / "agent.py").write_text("graph = object()\n") + (project_root / "assets").mkdir() + (project_root / "assets" / "keep.txt").write_text("keep\n") + (project_root / "assets" / "drop.txt").write_text("drop\n") + (project_root / ".dockerignore").write_text("assets/\n!assets/keep.txt\n") + + config = validate_config( + { + "python_version": "3.11", + "graphs": {"agent": "./src/agent.py:graph"}, + "source": {"kind": "uv"}, + } + ) + docker, _ = config_to_docker( + project_root / "langgraph.json", + config, + base_image="langchain/langgraph-api:0.2.47", + ) + + assert "ADD assets /deps/workspace/assets" not in docker + assert "ADD assets/keep.txt /deps/workspace/assets/keep.txt" in docker + assert "assets/drop.txt" not in docker + + +def test_config_to_docker_uv_lock_prunes_unrelated_ignored_subtrees(): + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir_path = pathlib.Path(tmpdir) + project_root = tmpdir_path / "single" + project_root.mkdir() + (project_root / "uv.lock").write_text("# uv lock file\n") + (project_root / "pyproject.toml").write_text( + textwrap.dedent( + """ + [project] + name = "single-app" + version = "0.1.0" + dependencies = ["httpx>=0.28"] + + [build-system] + requires = ["setuptools>=61"] + build-backend = "setuptools.build_meta" + """ + ).strip() + + "\n" + ) + (project_root / "langgraph.json").write_text("{}\n") + (project_root / "src").mkdir() + (project_root / "src" / "agent.py").write_text("graph = object()\n") + (project_root / "assets").mkdir() + (project_root / "assets" / "keep.txt").write_text("keep\n") + (project_root / "vendor").mkdir() + (project_root / "vendor" / "huge.txt").write_text("large\n") + (project_root / ".dockerignore").write_text( + "vendor/\nassets/\n!assets/keep.txt\n" + ) + + config = validate_config( + { + "python_version": "3.11", + "graphs": {"agent": "./src/agent.py:graph"}, + "source": {"kind": "uv"}, + } + ) + + original_iterdir = pathlib.Path.iterdir + + def guarded_iterdir(self): + if self == project_root / "vendor": + raise AssertionError("should not walk unrelated ignored subtree") + return original_iterdir(self) + + with patch.object( + pathlib.Path, "iterdir", autospec=True, side_effect=guarded_iterdir + ): + docker, _ = config_to_docker( + project_root / "langgraph.json", + config, + base_image="langchain/langgraph-api:0.2.47", + ) + + assert "ADD assets/keep.txt /deps/workspace/assets/keep.txt" in docker + assert "vendor/huge.txt" not in docker + + +def test_config_to_docker_uv_lock_never_reincludes_always_excluded_subtrees(): + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir_path = pathlib.Path(tmpdir) + project_root = tmpdir_path / "single" + project_root.mkdir() + (project_root / "uv.lock").write_text("# uv lock file\n") + (project_root / "pyproject.toml").write_text( + textwrap.dedent( + """ + [project] + name = "single-app" + version = "0.1.0" + dependencies = ["httpx>=0.28"] + + [build-system] + requires = ["setuptools>=61"] + build-backend = "setuptools.build_meta" + """ + ).strip() + + "\n" + ) + (project_root / "langgraph.json").write_text("{}\n") + (project_root / "src").mkdir() + (project_root / "src" / "agent.py").write_text("graph = object()\n") + (project_root / ".venv" / "pkg").mkdir(parents=True) + (project_root / ".venv" / "pkg" / "keep.txt").write_text("keep\n") + (project_root / "node_modules" / "pkg").mkdir(parents=True) + (project_root / "node_modules" / "pkg" / "package.json").write_text("{}\n") + (project_root / ".dockerignore").write_text( + "!.venv/pkg/keep.txt\n!node_modules/pkg/package.json\n" + ) + + config = validate_config( + { + "python_version": "3.11", + "graphs": {"agent": "./src/agent.py:graph"}, + "source": {"kind": "uv"}, + } + ) + docker, _ = config_to_docker( + project_root / "langgraph.json", + config, + base_image="langchain/langgraph-api:0.2.47", + ) + + assert ".venv/pkg/keep.txt" not in docker + assert "node_modules/pkg/package.json" not in docker + assert "ADD src /deps/workspace/src" in docker + + +def test_config_to_docker_uv_lock_rejects_ignored_workspace_member(): + """A workspace member matched by .dockerignore cannot be copied into the + build context — uv.lock requires it, so fail loudly with a clear message.""" + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir_path = pathlib.Path(tmpdir) + project_root, config_path = _write_uv_lock_workspace( + tmpdir_path, + agent_sources="[tool.uv.sources]\nshared = { workspace = true }", + ) + (project_root / ".dockerignore").write_text("libs/shared\n") + + config = validate_config( + { + "python_version": "3.11", + "graphs": {"agent": "../../apps/agent/src/agent/graph.py:graph"}, + "source": {"kind": "uv", "root": "../..", "package": "agent"}, + "auth": {"path": "../../libs/shared/src/shared/auth.py:create_auth"}, + } + ) + with pytest.raises( + click.UsageError, match=r"Workspace member 'shared' at libs/shared" + ): + config_to_docker( + config_path, config, base_image="langchain/langgraph-api:0.2.47" + ) + + def test_config_to_docker_uv_lock_rejects_invalid_source_package_type(): with tempfile.TemporaryDirectory() as tmpdir: tmpdir_path = pathlib.Path(tmpdir)