a7d6d88f6f
CI / changes (push) Has been cancelled
CI / cd libs/checkpoint (push) Has been cancelled
CI / cd libs/checkpoint-conformance (push) Has been cancelled
CI / cd libs/checkpoint-postgres (push) Has been cancelled
CI / cd libs/checkpoint-sqlite (push) Has been cancelled
CI / cd libs/cli (push) Has been cancelled
CI / cd libs/prebuilt (push) Has been cancelled
CI / cd libs/sdk-py (push) Has been cancelled
CI / cd libs/langgraph (push) Has been cancelled
CI / Check SDK methods matching (push) Has been cancelled
CI / Check CLI schema hasn't changed #3.13 (push) Has been cancelled
CI / CLI integration test (push) Has been cancelled
CI / sdk-py integration test (push) Has been cancelled
CI / CI Success (push) Has been cancelled
baseline / benchmark (push) Has been cancelled
Deploy Redirects to GitHub Pages / deploy (push) Has been cancelled
1040 lines
37 KiB
Python
1040 lines
37 KiB
Python
import os
|
|
import pathlib
|
|
import re
|
|
import shlex
|
|
from dataclasses import dataclass
|
|
|
|
try:
|
|
import tomllib
|
|
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
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class UvLockSourceEntry:
|
|
name: str
|
|
value: object
|
|
declared_root: pathlib.Path
|
|
pyproject_path: pathlib.Path
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class UvLockPackage:
|
|
name: str
|
|
normalized_name: str
|
|
root: pathlib.Path
|
|
pyproject_path: pathlib.Path
|
|
raw_dependency_specs: object
|
|
raw_uv_tool: object
|
|
# Set after validation:
|
|
package_enabled: bool = False
|
|
dependency_names: tuple[str, ...] = ()
|
|
workspace_dependencies: tuple[str, ...] = ()
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class UvLockWorkspace:
|
|
raw_root_source_entries: object
|
|
packages_by_name: dict[str, UvLockPackage]
|
|
packages_by_root: dict[pathlib.Path, UvLockPackage]
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class UvLockPlan:
|
|
project_root: pathlib.Path
|
|
pyproject_path: pathlib.Path
|
|
uv_lock_path: pathlib.Path
|
|
target: UvLockPackage
|
|
target_root: pathlib.Path
|
|
install_order: tuple[UvLockPackage, ...]
|
|
container_roots: dict[pathlib.Path, pathlib.PurePosixPath]
|
|
working_dir: str
|
|
all_workspace_roots: frozenset[pathlib.Path] = frozenset()
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class DockerBuildPlan:
|
|
lines: list[str]
|
|
|
|
def add_blank(self) -> None:
|
|
self.lines.append("")
|
|
|
|
def add_raw(self, line: str) -> None:
|
|
self.lines.append(line)
|
|
|
|
def add_instruction(self, opcode: str, value: str | None = None) -> None:
|
|
if value:
|
|
self.lines.append(f"{opcode} {value}")
|
|
else:
|
|
self.lines.append(opcode)
|
|
|
|
def extend_nonempty(self, items: list[str]) -> None:
|
|
self.lines.extend(item for item in items if item)
|
|
|
|
def render(self) -> str:
|
|
return os.linesep.join(self.lines)
|
|
|
|
|
|
def _normalize_package_name(name: str) -> str:
|
|
return re.sub(r"[-_.]+", "-", name).lower()
|
|
|
|
|
|
def _parse_dependency_name(
|
|
dependency: str, *, package_name: str, pyproject_path: pathlib.Path
|
|
) -> str:
|
|
match = re.match(r"^\s*([A-Za-z0-9][A-Za-z0-9._-]*)", dependency)
|
|
if not match:
|
|
raise click.UsageError(
|
|
"source.kind 'uv' only supports PEP 508 dependency strings "
|
|
f"with an explicit package name. Could not parse dependency "
|
|
f"{dependency!r} in {pyproject_path} for package '{package_name}'."
|
|
)
|
|
return _normalize_package_name(match.group(1))
|
|
|
|
|
|
def _load_pyproject(pyproject_path: pathlib.Path) -> dict:
|
|
with pyproject_path.open("rb") as pyproject_file:
|
|
return tomllib.load(pyproject_file)
|
|
|
|
|
|
def _get_dependency_names(
|
|
dependency_specs: object, *, package_name: str, pyproject_path: pathlib.Path
|
|
) -> tuple[str, ...]:
|
|
if dependency_specs is None:
|
|
dependency_specs = []
|
|
if not isinstance(dependency_specs, list) or not all(
|
|
isinstance(spec, str) for spec in dependency_specs
|
|
):
|
|
raise click.UsageError(
|
|
"source.kind 'uv' requires [project].dependencies to be a "
|
|
f"list of strings in {pyproject_path}."
|
|
)
|
|
return tuple(
|
|
_parse_dependency_name(
|
|
spec,
|
|
package_name=package_name,
|
|
pyproject_path=pyproject_path,
|
|
)
|
|
for spec in dependency_specs
|
|
)
|
|
|
|
|
|
def _validate_uv_lock_source_entry(
|
|
*,
|
|
source_name: str,
|
|
source_value: object,
|
|
declared_root: pathlib.Path,
|
|
pyproject_path: pathlib.Path,
|
|
project_root: pathlib.Path,
|
|
packages_by_name: dict[str, UvLockPackage],
|
|
packages_by_root: dict[pathlib.Path, UvLockPackage],
|
|
) -> set[str]:
|
|
workspace_dependencies: set[str] = set()
|
|
if isinstance(source_value, list):
|
|
for item in source_value:
|
|
workspace_dependencies.update(
|
|
_validate_uv_lock_source_entry(
|
|
source_name=source_name,
|
|
source_value=item,
|
|
declared_root=declared_root,
|
|
pyproject_path=pyproject_path,
|
|
project_root=project_root,
|
|
packages_by_name=packages_by_name,
|
|
packages_by_root=packages_by_root,
|
|
)
|
|
)
|
|
return workspace_dependencies
|
|
|
|
if not isinstance(source_value, dict):
|
|
return workspace_dependencies
|
|
|
|
normalized_source_name = _normalize_package_name(source_name)
|
|
package: UvLockPackage | None = None
|
|
|
|
if source_value.get("workspace") is True:
|
|
package = packages_by_name.get(normalized_source_name)
|
|
if package is None:
|
|
raise click.UsageError(
|
|
f"'{source_name}' in {pyproject_path} is marked as "
|
|
"`{{ workspace = true }}` but no matching workspace package was "
|
|
f"found under project_root ({project_root}). Check that "
|
|
f"'{source_name}' appears in [tool.uv.workspace].members."
|
|
)
|
|
if not _get_uv_lock_package_enabled(package):
|
|
raise click.UsageError(
|
|
f"'{source_name}' in {pyproject_path} is a workspace dependency "
|
|
f"but sets `tool.uv.package = false` in {package.pyproject_path}. "
|
|
"Workspace dependencies must be buildable packages."
|
|
)
|
|
workspace_dependencies.add(package.normalized_name)
|
|
|
|
path_ref = source_value.get("path")
|
|
if isinstance(path_ref, str):
|
|
if (
|
|
pathlib.Path(path_ref).is_absolute()
|
|
or pathlib.PurePosixPath(path_ref).is_absolute()
|
|
):
|
|
raise click.UsageError(
|
|
f"'{source_name}' in {pyproject_path} uses an absolute path "
|
|
f"({path_ref}), which is not supported. Use a relative path or "
|
|
"`{{ workspace = true }}` instead."
|
|
)
|
|
|
|
resolved = (declared_root / path_ref).resolve()
|
|
if project_root != resolved and project_root not in resolved.parents:
|
|
raise click.UsageError(
|
|
f"'{source_name}' in {pyproject_path} uses a path source that "
|
|
f"resolves to {resolved}, which is outside project_root "
|
|
f"({project_root})."
|
|
)
|
|
|
|
package = packages_by_root.get(resolved)
|
|
if package is None:
|
|
raise click.UsageError(
|
|
f"'{source_name}' in {pyproject_path} uses a path source that "
|
|
f"resolves to {resolved}, which is not a workspace package under "
|
|
f"project_root ({project_root})."
|
|
)
|
|
if package.normalized_name != normalized_source_name:
|
|
raise click.UsageError(
|
|
f"'{source_name}' in {pyproject_path} points to {resolved}, which "
|
|
f"defines package '{package.name}'. The dependency name and the "
|
|
"workspace package name must match."
|
|
)
|
|
if not _get_uv_lock_package_enabled(package):
|
|
raise click.UsageError(
|
|
f"'{source_name}' in {pyproject_path} resolves to workspace package "
|
|
f"'{package.name}', which sets `tool.uv.package = false` in "
|
|
f"{package.pyproject_path}. Workspace dependencies must be "
|
|
"buildable packages."
|
|
)
|
|
workspace_dependencies.add(package.normalized_name)
|
|
|
|
for key, nested_value in source_value.items():
|
|
if key in {"workspace", "path"}:
|
|
continue
|
|
workspace_dependencies.update(
|
|
_validate_uv_lock_source_entry(
|
|
source_name=source_name,
|
|
source_value=nested_value,
|
|
declared_root=declared_root,
|
|
pyproject_path=pyproject_path,
|
|
project_root=project_root,
|
|
packages_by_name=packages_by_name,
|
|
packages_by_root=packages_by_root,
|
|
)
|
|
)
|
|
return workspace_dependencies
|
|
|
|
|
|
def _get_uv_lock_package_enabled(package: UvLockPackage) -> bool:
|
|
uv_tool = package.raw_uv_tool
|
|
if uv_tool and not isinstance(uv_tool, dict):
|
|
raise click.UsageError(
|
|
"source.kind 'uv' requires [tool.uv] to be a table "
|
|
f"in {package.pyproject_path}."
|
|
)
|
|
package_enabled = (
|
|
uv_tool.get("package", True) if isinstance(uv_tool, dict) else True
|
|
)
|
|
if not isinstance(package_enabled, bool):
|
|
raise click.UsageError(
|
|
"source.kind 'uv' requires [tool.uv].package to be a boolean "
|
|
f"in {package.pyproject_path}."
|
|
)
|
|
return package_enabled
|
|
|
|
|
|
def _get_uv_lock_source_entries(
|
|
package: UvLockPackage,
|
|
*,
|
|
project_root: pathlib.Path,
|
|
root_pyproject_path: pathlib.Path,
|
|
raw_root_source_entries: object,
|
|
) -> tuple[UvLockSourceEntry, ...]:
|
|
if not isinstance(raw_root_source_entries, dict):
|
|
raise click.UsageError(
|
|
"source.kind 'uv' requires [tool.uv.sources] to be a table "
|
|
f"in {root_pyproject_path}."
|
|
)
|
|
|
|
uv_tool = package.raw_uv_tool
|
|
package_source_entries = (
|
|
uv_tool.get("sources", {}) if isinstance(uv_tool, dict) else {}
|
|
)
|
|
if not isinstance(package_source_entries, dict):
|
|
raise click.UsageError(
|
|
"source.kind 'uv' requires [tool.uv.sources] to be a table "
|
|
f"in {package.pyproject_path}."
|
|
)
|
|
|
|
source_entries: dict[str, UvLockSourceEntry] = {
|
|
source_name: UvLockSourceEntry(
|
|
name=source_name,
|
|
value=source_value,
|
|
declared_root=project_root,
|
|
pyproject_path=root_pyproject_path,
|
|
)
|
|
for source_name, source_value in raw_root_source_entries.items()
|
|
}
|
|
source_entries.update(
|
|
{
|
|
source_name: UvLockSourceEntry(
|
|
name=source_name,
|
|
value=source_value,
|
|
declared_root=package.root,
|
|
pyproject_path=package.pyproject_path,
|
|
)
|
|
for source_name, source_value in package_source_entries.items()
|
|
}
|
|
)
|
|
return tuple(source_entries.values())
|
|
|
|
|
|
def _validate_uv_lock_package(
|
|
package: UvLockPackage,
|
|
*,
|
|
project_root: pathlib.Path,
|
|
root_pyproject_path: pathlib.Path,
|
|
raw_root_source_entries: object,
|
|
packages_by_name: dict[str, UvLockPackage],
|
|
packages_by_root: dict[pathlib.Path, UvLockPackage],
|
|
) -> None:
|
|
dependency_names = _get_dependency_names(
|
|
package.raw_dependency_specs,
|
|
package_name=package.name,
|
|
pyproject_path=package.pyproject_path,
|
|
)
|
|
dependency_name_set = set(dependency_names)
|
|
|
|
workspace_dependency_names: set[str] = set()
|
|
for source_entry in _get_uv_lock_source_entries(
|
|
package,
|
|
project_root=project_root,
|
|
root_pyproject_path=root_pyproject_path,
|
|
raw_root_source_entries=raw_root_source_entries,
|
|
):
|
|
if _normalize_package_name(source_entry.name) not in dependency_name_set:
|
|
continue
|
|
workspace_dependency_names.update(
|
|
_validate_uv_lock_source_entry(
|
|
source_name=source_entry.name,
|
|
source_value=source_entry.value,
|
|
declared_root=source_entry.declared_root,
|
|
pyproject_path=source_entry.pyproject_path,
|
|
project_root=project_root,
|
|
packages_by_name=packages_by_name,
|
|
packages_by_root=packages_by_root,
|
|
)
|
|
)
|
|
|
|
package.package_enabled = _get_uv_lock_package_enabled(package)
|
|
package.dependency_names = dependency_names
|
|
package.workspace_dependencies = tuple(
|
|
dependency_name
|
|
for dependency_name in dependency_names
|
|
if dependency_name in workspace_dependency_names
|
|
)
|
|
|
|
|
|
def _discover_uv_lock_workspace_packages(
|
|
project_root: pathlib.Path, pyproject_path: pathlib.Path
|
|
) -> UvLockWorkspace:
|
|
root_data = _load_pyproject(pyproject_path)
|
|
root_source_entries = root_data.get("tool", {}).get("uv", {}).get("sources", {})
|
|
|
|
candidate_roots: list[pathlib.Path] = []
|
|
root_project = root_data.get("project", {})
|
|
if isinstance(root_project, dict) and isinstance(root_project.get("name"), str):
|
|
candidate_roots.append(project_root)
|
|
|
|
workspace_members = (
|
|
root_data.get("tool", {}).get("uv", {}).get("workspace", {}).get("members", [])
|
|
)
|
|
if workspace_members and not isinstance(workspace_members, list):
|
|
raise click.UsageError(
|
|
"source.kind 'uv' requires [tool.uv.workspace].members to be a list."
|
|
)
|
|
|
|
for pattern in workspace_members:
|
|
if not isinstance(pattern, str):
|
|
raise click.UsageError(
|
|
"source.kind 'uv' requires every [tool.uv.workspace].members "
|
|
"entry to be a string."
|
|
)
|
|
for match in sorted(project_root.glob(pattern)):
|
|
package_root = match if match.is_dir() else match.parent
|
|
package_root = package_root.resolve()
|
|
if (package_root / "pyproject.toml").is_file():
|
|
candidate_roots.append(package_root)
|
|
|
|
unique_roots: list[pathlib.Path] = []
|
|
seen_roots: set[pathlib.Path] = set()
|
|
for root in candidate_roots:
|
|
if root not in seen_roots:
|
|
unique_roots.append(root)
|
|
seen_roots.add(root)
|
|
|
|
packages: list[UvLockPackage] = []
|
|
for package_root in unique_roots:
|
|
member_pyproject_path = package_root / "pyproject.toml"
|
|
pyproject_data = _load_pyproject(member_pyproject_path)
|
|
|
|
project_data = pyproject_data.get("project", {})
|
|
package_name = (
|
|
project_data.get("name") if isinstance(project_data, dict) else None
|
|
)
|
|
if not isinstance(package_name, str) or not package_name.strip():
|
|
raise click.UsageError(
|
|
"source.kind 'uv' requires every workspace package to define "
|
|
f"[project].name in {member_pyproject_path}."
|
|
)
|
|
|
|
packages.append(
|
|
UvLockPackage(
|
|
name=package_name,
|
|
normalized_name=_normalize_package_name(package_name),
|
|
root=package_root,
|
|
pyproject_path=member_pyproject_path,
|
|
raw_dependency_specs=project_data.get("dependencies", []),
|
|
raw_uv_tool=pyproject_data.get("tool", {}).get("uv", {}),
|
|
)
|
|
)
|
|
|
|
packages_by_name: dict[str, UvLockPackage] = {}
|
|
packages_by_root: dict[pathlib.Path, UvLockPackage] = {}
|
|
for package in packages:
|
|
existing = packages_by_name.get(package.normalized_name)
|
|
if existing is not None:
|
|
raise click.UsageError(
|
|
"source.kind 'uv' requires unique workspace package names, "
|
|
f"but both {existing.pyproject_path} and {package.pyproject_path} "
|
|
f"define '{package.name}'."
|
|
)
|
|
packages_by_name[package.normalized_name] = package
|
|
packages_by_root[package.root] = package
|
|
|
|
return UvLockWorkspace(
|
|
raw_root_source_entries=root_source_entries,
|
|
packages_by_name=packages_by_name,
|
|
packages_by_root=packages_by_root,
|
|
)
|
|
|
|
|
|
def _container_workspace_root() -> pathlib.PurePosixPath:
|
|
return pathlib.PurePosixPath("/deps/workspace")
|
|
|
|
|
|
def _container_root_for_uv_lock_package(
|
|
project_root: pathlib.Path, package_root: pathlib.Path
|
|
) -> pathlib.PurePosixPath:
|
|
container_root = _container_workspace_root()
|
|
relative_root = package_root.relative_to(project_root)
|
|
if relative_root == pathlib.Path("."):
|
|
return container_root
|
|
return container_root.joinpath(*relative_root.parts)
|
|
|
|
|
|
def _uv_lock_package_copy_items(
|
|
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 <path>` for a file that Docker has
|
|
# filtered out causes the build to fail with
|
|
# "failed to compute cache key: <path> 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,
|
|
) -> tuple[tuple[pathlib.PurePosixPath, pathlib.PurePosixPath], ...]:
|
|
entries: list[tuple[pathlib.PurePosixPath, pathlib.PurePosixPath]] = []
|
|
for child in sorted(current_dir.iterdir(), key=lambda path: path.name):
|
|
if child in workspace_member_roots:
|
|
# Workspace members are copied separately if they are in the closure,
|
|
# and excluded entirely otherwise.
|
|
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))
|
|
)
|
|
return tuple(entries)
|
|
|
|
return iter_entries(plan.project_root)
|
|
|
|
|
|
def _resolve_uv_lock_container_path(
|
|
host_path: pathlib.Path, plan: UvLockPlan
|
|
) -> pathlib.PurePosixPath | None:
|
|
for package_root in sorted(
|
|
plan.container_roots, key=lambda path: len(path.parts), reverse=True
|
|
):
|
|
if host_path == package_root or package_root in host_path.parents:
|
|
# Guard against the workspace root matching paths inside
|
|
# unrelated workspace members that are not in the closure.
|
|
if plan.all_workspace_roots and _path_in_unrelated_member(
|
|
host_path, package_root, plan
|
|
):
|
|
continue
|
|
relative_path = host_path.relative_to(package_root)
|
|
container_root = plan.container_roots[package_root]
|
|
if relative_path == pathlib.Path("."):
|
|
return container_root
|
|
return container_root.joinpath(*relative_path.parts)
|
|
return None
|
|
|
|
|
|
def _path_in_unrelated_member(
|
|
host_path: pathlib.Path,
|
|
matched_root: pathlib.Path,
|
|
plan: UvLockPlan,
|
|
) -> bool:
|
|
"""Return True if host_path is inside a workspace member NOT in the closure.
|
|
|
|
When matched_root is the project root (workspace root), it is a parent of
|
|
every file in the workspace. We need to reject paths that actually belong
|
|
to a sibling workspace member that was not selected for deployment.
|
|
"""
|
|
for ws_root in plan.all_workspace_roots:
|
|
if ws_root == matched_root:
|
|
continue
|
|
# ws_root is more specific than matched_root
|
|
if ws_root in matched_root.parents:
|
|
continue
|
|
if host_path == ws_root or ws_root in host_path.parents:
|
|
# host_path is inside this workspace member
|
|
if ws_root not in plan.container_roots:
|
|
return True
|
|
return False
|
|
|
|
|
|
def _infer_uv_lock_target_package(
|
|
*,
|
|
config_root: pathlib.Path,
|
|
project_root: pathlib.Path,
|
|
source: dict,
|
|
packages_by_name: dict[str, UvLockPackage],
|
|
packages_by_root: dict[pathlib.Path, UvLockPackage],
|
|
) -> UvLockPackage:
|
|
package_name = source.get("package")
|
|
if package_name is not None:
|
|
if not isinstance(package_name, str) or not package_name.strip():
|
|
raise click.UsageError("`source.package` must be a non-empty string.")
|
|
target = packages_by_name.get(_normalize_package_name(package_name))
|
|
if target is None:
|
|
available_packages = ", ".join(
|
|
sorted(package.name for package in packages_by_name.values())
|
|
)
|
|
raise click.UsageError(
|
|
"Could not find source.package "
|
|
f"'{package_name}' in the uv project at {project_root}. "
|
|
"It must match a [project].name from one of the discovered "
|
|
f"packages. Available packages: {available_packages or '(none)'}."
|
|
)
|
|
return target
|
|
|
|
containing_packages = sorted(
|
|
(
|
|
package
|
|
for package_root, package in packages_by_root.items()
|
|
if config_root == package_root or package_root in config_root.parents
|
|
),
|
|
key=lambda package: len(package.root.parts),
|
|
reverse=True,
|
|
)
|
|
if containing_packages:
|
|
target = containing_packages[0]
|
|
if (
|
|
target.root != project_root
|
|
or len(packages_by_name) == 1
|
|
or config_root == project_root
|
|
):
|
|
return target
|
|
|
|
if len(packages_by_name) == 1:
|
|
return next(iter(packages_by_name.values()))
|
|
|
|
available_packages = ", ".join(
|
|
sorted(package.name for package in packages_by_name.values())
|
|
)
|
|
raise click.UsageError(
|
|
"source.package is required because source.root resolves to a uv "
|
|
"workspace with multiple packages and no unique target package could be "
|
|
f"inferred from langgraph.json at {config_root}. Available packages: "
|
|
f"{available_packages or '(none)'}. "
|
|
"Move langgraph.json into the target package or set source.package."
|
|
)
|
|
|
|
|
|
def _plan_uv_lock_workspace(config_path: pathlib.Path, config: Config) -> UvLockPlan:
|
|
config_root = config_path.parent.resolve()
|
|
source = config["source"]
|
|
root = source.get("root", ".")
|
|
if not isinstance(root, str) or not root.strip():
|
|
raise click.UsageError('`source.root` must be a non-empty string. Use `"."`.')
|
|
project_root = (config_root / root).resolve()
|
|
pyproject_path = project_root / "pyproject.toml"
|
|
uv_lock_path = project_root / "uv.lock"
|
|
|
|
if not uv_lock_path.exists():
|
|
raise click.UsageError(
|
|
f"No uv.lock found at {uv_lock_path}. Your langgraph.json sets "
|
|
f"source.root={root!r}, which resolves to "
|
|
f"{project_root}. Make sure this is the directory where you run "
|
|
"`uv lock` (it should contain both pyproject.toml and uv.lock)."
|
|
)
|
|
if not pyproject_path.exists():
|
|
raise click.UsageError(
|
|
f"No pyproject.toml found at {pyproject_path}. Your langgraph.json "
|
|
f"sets source.root={root!r}, which resolves to "
|
|
f"{project_root}. This should be your uv workspace root."
|
|
)
|
|
|
|
workspace = _discover_uv_lock_workspace_packages(project_root, pyproject_path)
|
|
packages_by_name = workspace.packages_by_name
|
|
target = _infer_uv_lock_target_package(
|
|
config_root=config_root,
|
|
project_root=project_root,
|
|
source=source,
|
|
packages_by_name=packages_by_name,
|
|
packages_by_root=workspace.packages_by_root,
|
|
)
|
|
_validate_uv_lock_package(
|
|
target,
|
|
project_root=project_root,
|
|
root_pyproject_path=pyproject_path,
|
|
raw_root_source_entries=workspace.raw_root_source_entries,
|
|
packages_by_name=workspace.packages_by_name,
|
|
packages_by_root=workspace.packages_by_root,
|
|
)
|
|
if not target.package_enabled:
|
|
raise click.UsageError(
|
|
f"'{target.name}' has `tool.uv.package = false` in "
|
|
f"{target.pyproject_path}, so it cannot be deployed. Either remove "
|
|
"that setting or point `source.package` at a different workspace "
|
|
"member."
|
|
)
|
|
|
|
install_order: list[UvLockPackage] = []
|
|
visited: set[str] = set()
|
|
validated: set[str] = {target.normalized_name}
|
|
|
|
def visit(package: UvLockPackage) -> None:
|
|
if package.normalized_name in visited:
|
|
return
|
|
visited.add(package.normalized_name)
|
|
if package.normalized_name not in validated:
|
|
_validate_uv_lock_package(
|
|
package,
|
|
project_root=project_root,
|
|
root_pyproject_path=pyproject_path,
|
|
raw_root_source_entries=workspace.raw_root_source_entries,
|
|
packages_by_name=workspace.packages_by_name,
|
|
packages_by_root=workspace.packages_by_root,
|
|
)
|
|
validated.add(package.normalized_name)
|
|
for dependency_name in package.workspace_dependencies:
|
|
dependency = packages_by_name.get(dependency_name)
|
|
if dependency is not None:
|
|
visit(dependency)
|
|
install_order.append(package)
|
|
|
|
visit(target)
|
|
|
|
container_roots = {
|
|
package.root: _container_root_for_uv_lock_package(project_root, package.root)
|
|
for package in install_order
|
|
}
|
|
|
|
all_workspace_roots = frozenset(
|
|
package.root for package in packages_by_name.values()
|
|
)
|
|
|
|
working_dir = _resolve_uv_lock_container_path(
|
|
config_root,
|
|
UvLockPlan(
|
|
project_root=project_root,
|
|
pyproject_path=pyproject_path,
|
|
uv_lock_path=uv_lock_path,
|
|
target=target,
|
|
target_root=target.root,
|
|
install_order=tuple(install_order),
|
|
container_roots=container_roots,
|
|
working_dir=str(
|
|
_container_root_for_uv_lock_package(project_root, target.root)
|
|
),
|
|
all_workspace_roots=all_workspace_roots,
|
|
),
|
|
)
|
|
if working_dir is None:
|
|
working_dir = container_roots[target.root]
|
|
|
|
return UvLockPlan(
|
|
project_root=project_root,
|
|
pyproject_path=pyproject_path,
|
|
uv_lock_path=uv_lock_path,
|
|
target=target,
|
|
target_root=target.root,
|
|
install_order=tuple(install_order),
|
|
container_roots=container_roots,
|
|
working_dir=str(working_dir),
|
|
all_workspace_roots=all_workspace_roots,
|
|
)
|
|
|
|
|
|
def _rewrite_uv_lock_import_path(
|
|
config_path: pathlib.Path,
|
|
import_str: str,
|
|
plan: UvLockPlan,
|
|
*,
|
|
label: str,
|
|
) -> str:
|
|
module_str, _, attr_str = import_str.partition(":")
|
|
if not module_str or not attr_str:
|
|
raise ValueError(
|
|
f'Import string "{import_str}" must be in format "<module>:<attribute>".'
|
|
)
|
|
|
|
if "/" not in module_str and "\\" not in module_str:
|
|
return import_str
|
|
|
|
resolved = (config_path.parent / module_str).resolve()
|
|
if not resolved.exists():
|
|
raise FileNotFoundError(f"Could not find {label}: {resolved}")
|
|
if not resolved.is_file():
|
|
raise IsADirectoryError(f"{label.capitalize()} must be a file: {resolved}")
|
|
|
|
container_path = _resolve_uv_lock_container_path(resolved, plan)
|
|
if container_path is None:
|
|
copied_dirs = ", ".join(
|
|
package.root.relative_to(plan.project_root).as_posix() or "."
|
|
for package in plan.install_order
|
|
)
|
|
raise click.UsageError(
|
|
f"{label.capitalize()} '{import_str}' resolves to {resolved}, which is "
|
|
f"not inside the target package '{plan.target.name}' or any of its "
|
|
f"workspace dependencies. Only these directories are copied into the "
|
|
f"container: {copied_dirs}. If this file lives in another workspace "
|
|
f"package, add it as a dependency of '{plan.target.name}' with "
|
|
"`{ workspace = true }` in [tool.uv.sources]."
|
|
)
|
|
|
|
return f"{container_path.as_posix()}:{attr_str}"
|
|
|
|
|
|
def _update_uv_lock_graph_paths(
|
|
config_path: pathlib.Path, config: Config, plan: UvLockPlan
|
|
) -> None:
|
|
for graph_id, data in config["graphs"].items():
|
|
if isinstance(data, dict):
|
|
if "path" not in data:
|
|
raise ValueError(
|
|
f"Graph '{graph_id}' must contain a 'path' key if it is a dictionary."
|
|
)
|
|
config["graphs"][graph_id]["path"] = _rewrite_uv_lock_import_path(
|
|
config_path,
|
|
data["path"],
|
|
plan,
|
|
label=f"graph '{graph_id}'",
|
|
)
|
|
elif isinstance(data, str):
|
|
config["graphs"][graph_id] = _rewrite_uv_lock_import_path(
|
|
config_path,
|
|
data,
|
|
plan,
|
|
label=f"graph '{graph_id}'",
|
|
)
|
|
else:
|
|
raise ValueError(
|
|
f"Graph '{graph_id}' must be a string or a dictionary with a 'path' key."
|
|
)
|
|
|
|
|
|
def _update_uv_lock_component_path(
|
|
config_path: pathlib.Path,
|
|
config: Config,
|
|
plan: UvLockPlan,
|
|
*,
|
|
section: str,
|
|
key: str,
|
|
label: str,
|
|
) -> None:
|
|
section_config = config.get(section)
|
|
if not isinstance(section_config, dict):
|
|
return
|
|
|
|
path_str = section_config.get(key)
|
|
if not isinstance(path_str, str):
|
|
return
|
|
|
|
section_config[key] = _rewrite_uv_lock_import_path(
|
|
config_path,
|
|
path_str,
|
|
plan,
|
|
label=label,
|
|
)
|
|
|
|
|
|
def _update_uv_lock_ui_paths(
|
|
config_path: pathlib.Path, config: Config, plan: UvLockPlan
|
|
) -> None:
|
|
ui = config.get("ui")
|
|
if not isinstance(ui, dict):
|
|
return
|
|
|
|
for ui_name, path_str in ui.items():
|
|
if not isinstance(path_str, str):
|
|
continue
|
|
|
|
resolved = (config_path.parent / path_str).resolve()
|
|
if not resolved.exists():
|
|
raise FileNotFoundError(f"Could not find ui '{ui_name}': {resolved}")
|
|
if not resolved.is_file():
|
|
raise IsADirectoryError(f"Ui '{ui_name}' must be a file: {resolved}")
|
|
|
|
container_path = _resolve_uv_lock_container_path(resolved, plan)
|
|
if container_path is None:
|
|
copied_dirs = ", ".join(
|
|
package.root.relative_to(plan.project_root).as_posix() or "."
|
|
for package in plan.install_order
|
|
)
|
|
raise click.UsageError(
|
|
f"Ui '{ui_name}' resolves to {resolved}, which is not inside the "
|
|
f"target package '{plan.target.name}' or any of its workspace "
|
|
f"dependencies. Only these directories are copied into the "
|
|
f"container: {copied_dirs}. If this file lives in another "
|
|
f"workspace package, add it as a dependency of "
|
|
f"'{plan.target.name}' with `{{ workspace = true }}` in "
|
|
"[tool.uv.sources]."
|
|
)
|
|
|
|
ui[ui_name] = container_path.as_posix()
|
|
|
|
|
|
def python_config_to_docker_uv_lock(
|
|
config_path: pathlib.Path,
|
|
config: Config,
|
|
base_image: str,
|
|
api_version: str | None = None,
|
|
*,
|
|
build_tools_to_uninstall: tuple[str, ...] | None,
|
|
) -> tuple[str, dict[str, str]]:
|
|
from langgraph_cli.config import (
|
|
DEFAULT_NODE_VERSION,
|
|
_build_python_install_commands,
|
|
_build_runtime_env_vars,
|
|
_get_node_pm_install_cmd,
|
|
_get_pip_cleanup_lines,
|
|
_image_supports_uv,
|
|
docker_tag,
|
|
)
|
|
|
|
if not _image_supports_uv(base_image):
|
|
raise ValueError(
|
|
"source.kind 'uv' requires a base image with uv support "
|
|
"(langchain/langgraph-api >= 0.2.47)"
|
|
)
|
|
|
|
config_root = config_path.parent.resolve()
|
|
install_cmd = "uv pip install --system"
|
|
_, global_reqs_pip_install, pip_config_file_str = _build_python_install_commands(
|
|
config, install_cmd
|
|
)
|
|
plan = _plan_uv_lock_workspace(config_path, config)
|
|
|
|
_update_uv_lock_graph_paths(config_path, config, plan)
|
|
for section, key in [
|
|
("auth", "path"),
|
|
("encryption", "path"),
|
|
("checkpointer", "path"),
|
|
("http", "app"),
|
|
]:
|
|
_update_uv_lock_component_path(
|
|
config_path,
|
|
config,
|
|
plan,
|
|
section=section,
|
|
key=key,
|
|
label=f"{section}.{key}",
|
|
)
|
|
_update_uv_lock_ui_paths(config_path, config, plan)
|
|
|
|
additional_contexts: dict[str, str] = {}
|
|
workspace_context_name: str | None = None
|
|
if (
|
|
plan.project_root != config_root
|
|
and config_root not in plan.project_root.parents
|
|
):
|
|
workspace_context_name = "uv-workspace-root"
|
|
additional_contexts[workspace_context_name] = str(plan.project_root)
|
|
|
|
def copy_from_project_root(
|
|
relative_path: pathlib.PurePosixPath, destination: str
|
|
) -> str:
|
|
if workspace_context_name is not None:
|
|
source = relative_path.as_posix() or "."
|
|
return f"COPY --from={workspace_context_name} {source} {destination}"
|
|
|
|
source_path = plan.project_root / pathlib.Path(relative_path)
|
|
relative_source = source_path.relative_to(config_root).as_posix()
|
|
return f"ADD {relative_source} {destination}"
|
|
|
|
uv_export_project_dir = "/tmp/uv_export/project"
|
|
env_vars = _build_runtime_env_vars(config)
|
|
|
|
image_str = docker_tag(config, base_image, api_version)
|
|
docker_plan = DockerBuildPlan(lines=[])
|
|
|
|
if additional_contexts:
|
|
docker_plan.add_raw("# syntax=docker/dockerfile:1.4")
|
|
docker_plan.add_blank()
|
|
|
|
docker_plan.add_instruction("FROM", image_str)
|
|
docker_plan.add_blank()
|
|
docker_plan.extend_nonempty(config["dockerfile_lines"])
|
|
if config["dockerfile_lines"]:
|
|
docker_plan.add_blank()
|
|
|
|
if (config.get("ui") or config.get("node_version")) and plan.working_dir:
|
|
docker_plan.add_instruction("RUN", "/storage/install-node.sh")
|
|
docker_plan.add_blank()
|
|
|
|
if pip_config_file_str:
|
|
docker_plan.add_raw(pip_config_file_str)
|
|
docker_plan.add_blank()
|
|
|
|
docker_plan.add_raw("# -- Installing dependencies from uv.lock --")
|
|
docker_plan.add_raw(
|
|
copy_from_project_root(
|
|
pathlib.PurePosixPath("pyproject.toml"),
|
|
f"{uv_export_project_dir}/pyproject.toml",
|
|
)
|
|
)
|
|
docker_plan.add_raw(
|
|
copy_from_project_root(
|
|
pathlib.PurePosixPath("uv.lock"),
|
|
f"{uv_export_project_dir}/uv.lock",
|
|
)
|
|
)
|
|
docker_plan.add_instruction("WORKDIR", uv_export_project_dir)
|
|
docker_plan.add_instruction(
|
|
"RUN",
|
|
" ".join(
|
|
[
|
|
"uv export",
|
|
f"--package {shlex.quote(plan.target.name)}",
|
|
"--frozen",
|
|
"--no-hashes",
|
|
"--no-emit-project",
|
|
"--no-emit-workspace",
|
|
"-o uv_requirements.txt",
|
|
]
|
|
),
|
|
)
|
|
docker_plan.add_instruction(
|
|
"RUN", f"{global_reqs_pip_install} -r uv_requirements.txt"
|
|
)
|
|
docker_plan.add_instruction("RUN", "rm -rf /tmp/uv_export")
|
|
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, 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()
|
|
)
|
|
docker_plan.add_instruction("RUN", f"{global_reqs_pip_install} --no-deps -e .")
|
|
docker_plan.add_raw(f"# -- End of workspace package {package_label} --")
|
|
docker_plan.add_blank()
|
|
|
|
docker_plan.extend_nonempty(env_vars)
|
|
if env_vars:
|
|
docker_plan.add_blank()
|
|
|
|
if (config.get("ui") or config.get("node_version")) and plan.working_dir:
|
|
docker_plan.add_raw("# -- Installing JS dependencies --")
|
|
docker_plan.add_instruction(
|
|
"ENV", f"NODE_VERSION={config.get('node_version') or DEFAULT_NODE_VERSION}"
|
|
)
|
|
docker_plan.add_instruction("WORKDIR", plan.working_dir)
|
|
docker_plan.add_instruction(
|
|
"RUN",
|
|
f"{_get_node_pm_install_cmd(plan.target_root)} && "
|
|
"tsx /api/langgraph_api/js/build.mts",
|
|
)
|
|
docker_plan.add_raw("# -- End of JS dependencies install --")
|
|
docker_plan.add_blank()
|
|
|
|
docker_plan.add_raw(
|
|
_get_pip_cleanup_lines(
|
|
install_cmd=install_cmd,
|
|
to_uninstall=build_tools_to_uninstall,
|
|
pip_installer="uv",
|
|
)
|
|
)
|
|
docker_plan.add_blank()
|
|
if plan.working_dir:
|
|
docker_plan.add_instruction("WORKDIR", plan.working_dir)
|
|
|
|
return docker_plan.render(), additional_contexts
|