chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1 @@
|
||||
__version__ = "0.4.31"
|
||||
@@ -0,0 +1,4 @@
|
||||
from .cli import cli
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli()
|
||||
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1,105 @@
|
||||
import functools
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import platform
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any, TypedDict
|
||||
|
||||
from langgraph_cli.constants import (
|
||||
DEFAULT_CONFIG,
|
||||
DEFAULT_PORT,
|
||||
SUPABASE_PUBLIC_API_KEY,
|
||||
SUPABASE_URL,
|
||||
)
|
||||
from langgraph_cli.version import __version__
|
||||
|
||||
|
||||
class LogData(TypedDict):
|
||||
os: str
|
||||
os_version: str
|
||||
python_version: str
|
||||
cli_version: str
|
||||
cli_command: str
|
||||
params: dict[str, Any]
|
||||
|
||||
|
||||
def get_anonymized_params(
|
||||
kwargs: dict[str, Any], *, cli_command: str
|
||||
) -> dict[str, bool | str]:
|
||||
params: dict[str, bool | str] = {}
|
||||
|
||||
if cli_command == "deploy" and (
|
||||
analytics_source := os.getenv("LANGGRAPH_CLI_ANALYTICS_SOURCE")
|
||||
):
|
||||
params["source"] = analytics_source
|
||||
|
||||
# anonymize params with values
|
||||
if config := kwargs.get("config"):
|
||||
if config != pathlib.Path(DEFAULT_CONFIG).resolve():
|
||||
params["config"] = True
|
||||
|
||||
if port := kwargs.get("port"):
|
||||
if port != DEFAULT_PORT:
|
||||
params["port"] = True
|
||||
|
||||
if kwargs.get("docker_compose"):
|
||||
params["docker_compose"] = True
|
||||
|
||||
if kwargs.get("debugger_port"):
|
||||
params["debugger_port"] = True
|
||||
|
||||
if kwargs.get("postgres_uri"):
|
||||
params["postgres_uri"] = True
|
||||
|
||||
# pick up exact values for boolean flags
|
||||
for boolean_param in ["recreate", "pull", "watch", "wait", "verbose"]:
|
||||
if kwargs.get(boolean_param):
|
||||
params[boolean_param] = kwargs[boolean_param]
|
||||
|
||||
return params
|
||||
|
||||
|
||||
def log_data(data: LogData) -> None:
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"apikey": SUPABASE_PUBLIC_API_KEY,
|
||||
"User-Agent": "Mozilla/5.0",
|
||||
}
|
||||
supabase_url = SUPABASE_URL
|
||||
|
||||
req = urllib.request.Request(
|
||||
f"{supabase_url}/rest/v1/logs",
|
||||
data=json.dumps(data).encode("utf-8"),
|
||||
headers=headers,
|
||||
method="POST",
|
||||
)
|
||||
|
||||
try:
|
||||
urllib.request.urlopen(req)
|
||||
except urllib.error.URLError:
|
||||
pass
|
||||
|
||||
|
||||
def log_command(func):
|
||||
@functools.wraps(func)
|
||||
def decorator(*args, **kwargs):
|
||||
if os.getenv("LANGGRAPH_CLI_NO_ANALYTICS") == "1":
|
||||
return func(*args, **kwargs)
|
||||
|
||||
data = {
|
||||
"os": platform.system(),
|
||||
"os_version": platform.version(),
|
||||
"python_version": platform.python_version(),
|
||||
"cli_version": __version__,
|
||||
"cli_command": func.__name__,
|
||||
"params": get_anonymized_params(kwargs, cli_command=func.__name__),
|
||||
}
|
||||
|
||||
background_thread = threading.Thread(target=log_data, args=(data,))
|
||||
background_thread.start()
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return decorator
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Create a tarball of project source for remote builds."""
|
||||
|
||||
import os
|
||||
import pathlib
|
||||
import tarfile
|
||||
import tempfile
|
||||
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
|
||||
|
||||
|
||||
def _tar_filter(tarinfo: tarfile.TarInfo) -> tarfile.TarInfo | None:
|
||||
"""Strip symlinks, hardlinks, and traversal paths from archive."""
|
||||
if tarinfo.issym() or tarinfo.islnk():
|
||||
return None
|
||||
if ".." in tarinfo.name.split("/"):
|
||||
return None
|
||||
return tarinfo
|
||||
|
||||
|
||||
def _add_directory(
|
||||
tar: tarfile.TarFile,
|
||||
source_dir: pathlib.Path,
|
||||
arcname_prefix: str | None,
|
||||
ignore_spec: pathspec.PathSpec,
|
||||
) -> None:
|
||||
"""Recursively add a directory to the tarball under the given prefix.
|
||||
|
||||
If arcname_prefix is None, files are added at the archive root.
|
||||
Paths matching ignore_spec are excluded.
|
||||
"""
|
||||
for root, dirs, files in os.walk(source_dir):
|
||||
rel_root = os.path.relpath(root, source_dir).replace(os.sep, "/")
|
||||
dirs[:] = [
|
||||
d
|
||||
for d in dirs
|
||||
if not ignore_spec.match_file(
|
||||
f"{rel_root}/{d}/" if rel_root != "." else f"{d}/"
|
||||
)
|
||||
]
|
||||
for f in files:
|
||||
full_path = os.path.join(root, f)
|
||||
rel = os.path.relpath(full_path, source_dir).replace(os.sep, "/")
|
||||
if ignore_spec.match_file(rel):
|
||||
continue
|
||||
arcname = f"{arcname_prefix}/{rel}" if arcname_prefix else rel
|
||||
info = tar.gettarinfo(full_path, arcname=arcname)
|
||||
filtered = _tar_filter(info)
|
||||
if filtered is None:
|
||||
continue
|
||||
with open(full_path, "rb") as fobj:
|
||||
tar.addfile(filtered, fobj)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def create_archive(
|
||||
config_path: pathlib.Path,
|
||||
config: Config,
|
||||
):
|
||||
"""Context manager that creates a .tar.gz archive of the project source.
|
||||
|
||||
Uses _assemble_local_deps to discover local dependencies referenced in
|
||||
langgraph.json, including those outside config.parent (monorepo case).
|
||||
|
||||
The archive preserves the real filesystem layout relative to the common
|
||||
ancestor of config.parent and all external dependency directories, so that
|
||||
relative references (e.g. `../shared-lib`) resolve correctly after
|
||||
extraction.
|
||||
|
||||
Yields (archive_path, file_size, config_relative_path). The temporary
|
||||
directory holding the archive is cleaned up automatically on exit.
|
||||
"""
|
||||
config_path = config_path.resolve()
|
||||
context_dir = config_path.parent
|
||||
|
||||
local_deps = _assemble_local_deps(config_path, config)
|
||||
extra_contexts = local_deps.additional_contexts or []
|
||||
|
||||
dirs_to_include = [context_dir] + list(extra_contexts)
|
||||
|
||||
common = context_dir
|
||||
for d in extra_contexts:
|
||||
common = pathlib.Path(os.path.commonpath([common, d]))
|
||||
|
||||
tmp_dir = tempfile.mkdtemp(prefix="langgraph-deploy-")
|
||||
try:
|
||||
archive_path = os.path.join(tmp_dir, "source.tar.gz")
|
||||
|
||||
added_dirs: set[str] = set()
|
||||
with tarfile.open(archive_path, "w:gz") as tar:
|
||||
for dir_path in dirs_to_include:
|
||||
rel = dir_path.relative_to(common)
|
||||
prefix = str(rel).replace(os.sep, "/") if str(rel) != "." else None
|
||||
key = prefix or ""
|
||||
if key in added_dirs:
|
||||
continue
|
||||
added_dirs.add(key)
|
||||
ignore_spec = _build_ignore_spec(dir_path)
|
||||
_add_directory(
|
||||
tar, dir_path, arcname_prefix=prefix, ignore_spec=ignore_spec
|
||||
)
|
||||
|
||||
file_size = os.path.getsize(archive_path)
|
||||
|
||||
config_rel = str(config_path.relative_to(common)).replace(os.sep, "/")
|
||||
|
||||
with tarfile.open(archive_path, "r:gz") as tar:
|
||||
names = tar.getnames()
|
||||
if config_rel not in names:
|
||||
raise click.ClickException(
|
||||
f"Archive validation failed: {config_rel} not found in archive"
|
||||
)
|
||||
|
||||
if file_size > _MAX_SIZE:
|
||||
raise click.ClickException(
|
||||
f"Source archive is {file_size / 1_048_576:.1f} MB, which exceeds the 200 MB limit. "
|
||||
"Add large files to .dockerignore or .gitignore (model weights, data sets, etc.)."
|
||||
)
|
||||
|
||||
if file_size > _WARN_SIZE:
|
||||
click.secho(
|
||||
f" Warning: source archive is {file_size / 1_048_576:.1f} MB. "
|
||||
"Consider adding large files to .dockerignore or .gitignore.",
|
||||
fg="yellow",
|
||||
)
|
||||
|
||||
yield archive_path, file_size, config_rel
|
||||
finally:
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
||||
DEFAULT_CONFIG = "langgraph.json"
|
||||
DEFAULT_PORT = 8123
|
||||
|
||||
# analytics
|
||||
SUPABASE_PUBLIC_API_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imt6cmxwcG9qaW5wY3l5YWlweG5iIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MTkyNTc1NzksImV4cCI6MjAzNDgzMzU3OX0.kkVOlLz3BxemA5nP-vat3K4qRtrDuO4SwZSR_htcX9c"
|
||||
SUPABASE_URL = "https://kzrlppojinpcyyaipxnb.supabase.co"
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Detection of tracked Python packages in a local LangGraph project.
|
||||
|
||||
Mirrors host-backend's `host.models.dependency_tracking` so that CLI-based
|
||||
deploys report the same `tracked_packages` revision metadata that
|
||||
GitHub-based deploys do. The host backend strictly validates each entry
|
||||
against `<package-name>:<version>` with package-name in `TRACKED_PACKAGES`,
|
||||
so the detection rules here must match exactly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
# Single source of truth for which packages the host backend cares about.
|
||||
# Keep in sync with host-backend/host/models/tracked_packages.py.
|
||||
TRACKED_PACKAGES: tuple[str, ...] = ("google-adk",)
|
||||
|
||||
_MAX_READ_BYTES = 5 * 1024 * 1024
|
||||
|
||||
_PACKAGES_ALT = "|".join(re.escape(p) for p in TRACKED_PACKAGES)
|
||||
|
||||
_DEPS_RE = re.compile(
|
||||
rf"(?<![a-zA-Z0-9_-])({_PACKAGES_ALT})"
|
||||
r"(?:\[[^\]]*\])?"
|
||||
r"\s*((?:(?:==|>=|<=|~=|!=|>|<)\s*[\w.*]+\s*,?\s*)+)"
|
||||
)
|
||||
|
||||
_UV_LOCK_RE = re.compile(
|
||||
rf'name\s*=\s*"({_PACKAGES_ALT})"\s*\n\s*version\s*=\s*"([^"]+)"'
|
||||
)
|
||||
|
||||
_BARE_RE = re.compile(rf'(?<![a-zA-Z0-9_-])({_PACKAGES_ALT})(?:\[[^\]]*\])?\s*[,"\'\n]')
|
||||
|
||||
_EXTRAS_BRACKET_RE = re.compile(r"\[([a-zA-Z0-9_.\- ,\t]+)\]")
|
||||
|
||||
|
||||
def _appears_in_extras(content: str, pkg: str) -> bool:
|
||||
for m in _EXTRAS_BRACKET_RE.finditer(content):
|
||||
for token in m.group(1).split(","):
|
||||
if token.strip() == pkg:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _read_text(path: pathlib.Path) -> str | None:
|
||||
try:
|
||||
if not path.is_file():
|
||||
return None
|
||||
with open(path, "rb") as f:
|
||||
data = f.read(_MAX_READ_BYTES + 1)
|
||||
except OSError:
|
||||
return None
|
||||
if len(data) > _MAX_READ_BYTES:
|
||||
data = data[:_MAX_READ_BYTES]
|
||||
return data.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def _find_version_for(
|
||||
pkg: str,
|
||||
lock_content: str | None,
|
||||
pyproject_content: str | None,
|
||||
requirements_content: str | None,
|
||||
) -> str | None:
|
||||
if lock_content is not None:
|
||||
for m in _UV_LOCK_RE.finditer(lock_content):
|
||||
if m.group(1) == pkg:
|
||||
return m.group(2)
|
||||
for content in (pyproject_content, requirements_content):
|
||||
if content is None:
|
||||
continue
|
||||
for m in _DEPS_RE.finditer(content):
|
||||
if m.group(1) == pkg:
|
||||
return m.group(2).strip().rstrip(",")
|
||||
for m in _BARE_RE.finditer(content):
|
||||
if m.group(1) == pkg:
|
||||
return "unknown"
|
||||
if _appears_in_extras(content, pkg):
|
||||
return "unknown"
|
||||
return None
|
||||
|
||||
|
||||
def _resolved_dep_base(
|
||||
project_root: pathlib.Path, dep_path: str
|
||||
) -> pathlib.Path | None:
|
||||
"""Return the resolved dep directory if it stays inside the project root."""
|
||||
try:
|
||||
candidate = (project_root / dep_path).resolve()
|
||||
except (OSError, RuntimeError):
|
||||
return None
|
||||
try:
|
||||
candidate.relative_to(project_root)
|
||||
except ValueError:
|
||||
return None
|
||||
return candidate
|
||||
|
||||
|
||||
def find_tracked_packages(
|
||||
config: pathlib.Path,
|
||||
config_json: dict,
|
||||
) -> list[str]:
|
||||
"""Return every tracked package found in deps as `<name>:<version>` entries.
|
||||
|
||||
`config` is the absolute path to `langgraph.json`; dep paths in
|
||||
`config_json["dependencies"]` are resolved relative to its parent.
|
||||
Detection precedence per package: uv.lock resolved > pyproject.toml /
|
||||
requirements.txt specifier > bare reference > extras bracket (last
|
||||
two recorded as "unknown"). Output is ordered by `TRACKED_PACKAGES`.
|
||||
"""
|
||||
try:
|
||||
project_root = config.parent.resolve()
|
||||
except (OSError, RuntimeError):
|
||||
return []
|
||||
|
||||
dep_paths = config_json.get("dependencies") or ["."]
|
||||
|
||||
found: dict[str, str] = {}
|
||||
|
||||
for dep_path in dep_paths:
|
||||
if all(pkg in found for pkg in TRACKED_PACKAGES):
|
||||
break
|
||||
if not isinstance(dep_path, str):
|
||||
continue
|
||||
base = _resolved_dep_base(project_root, dep_path)
|
||||
if base is None or not base.is_dir():
|
||||
continue
|
||||
|
||||
lock_content = _read_text(base / "uv.lock")
|
||||
pyproject_content = _read_text(base / "pyproject.toml")
|
||||
requirements_content = _read_text(base / "requirements.txt")
|
||||
|
||||
for pkg in TRACKED_PACKAGES:
|
||||
if pkg in found:
|
||||
continue
|
||||
version = _find_version_for(
|
||||
pkg, lock_content, pyproject_content, requirements_content
|
||||
)
|
||||
if version is not None:
|
||||
found[pkg] = version
|
||||
|
||||
return [f"{pkg}:{found[pkg]}" for pkg in TRACKED_PACKAGES if pkg in found]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,406 @@
|
||||
import copy
|
||||
import json
|
||||
import pathlib
|
||||
import platform
|
||||
import shutil
|
||||
from collections.abc import Callable, Sequence
|
||||
from typing import Literal, NamedTuple
|
||||
|
||||
import click.exceptions
|
||||
|
||||
import langgraph_cli.config
|
||||
from langgraph_cli.exec import subp_exec
|
||||
|
||||
ROOT = pathlib.Path(__file__).parent.resolve()
|
||||
DEFAULT_POSTGRES_URI = (
|
||||
"postgres://postgres:postgres@langgraph-postgres:5432/postgres?sslmode=disable"
|
||||
)
|
||||
|
||||
|
||||
class Version(NamedTuple):
|
||||
major: int
|
||||
minor: int
|
||||
patch: int
|
||||
|
||||
|
||||
DockerComposeType = Literal["plugin", "standalone"]
|
||||
|
||||
|
||||
class DockerCapabilities(NamedTuple):
|
||||
version_docker: Version
|
||||
version_compose: Version
|
||||
healthcheck_start_interval: bool
|
||||
compose_type: DockerComposeType = "plugin"
|
||||
|
||||
|
||||
def _parse_version(version: str) -> Version:
|
||||
parts = version.split(".", 2)
|
||||
if len(parts) == 1:
|
||||
major = parts[0]
|
||||
minor = "0"
|
||||
patch = "0"
|
||||
elif len(parts) == 2:
|
||||
major, minor = parts
|
||||
patch = "0"
|
||||
else:
|
||||
major, minor, patch = parts
|
||||
return Version(
|
||||
int(major.lstrip("v")), int(minor), int(patch.split("-")[0].split("+")[0])
|
||||
)
|
||||
|
||||
|
||||
def can_build_locally() -> tuple[bool, str | None]:
|
||||
"""Return whether local deployment builds can run on this machine.
|
||||
|
||||
Checks:
|
||||
- Docker binary is installed
|
||||
- Docker daemon is running
|
||||
- Buildx is available when cross-compilation is required (non-x86_64)
|
||||
"""
|
||||
if shutil.which("docker") is None:
|
||||
return (
|
||||
False,
|
||||
"Docker is required but not installed.\n"
|
||||
"Install Docker Desktop: https://docs.docker.com/get-docker/",
|
||||
)
|
||||
try:
|
||||
import subprocess
|
||||
|
||||
docker_info = subprocess.run(
|
||||
["docker", "info"],
|
||||
capture_output=True,
|
||||
timeout=10,
|
||||
)
|
||||
if docker_info.returncode != 0:
|
||||
return (
|
||||
False,
|
||||
"Docker is installed but not running.\nStart Docker and try again.",
|
||||
)
|
||||
|
||||
if platform.machine() != "x86_64":
|
||||
buildx = subprocess.run(
|
||||
["docker", "buildx", "version"],
|
||||
capture_output=True,
|
||||
timeout=10,
|
||||
)
|
||||
if buildx.returncode != 0:
|
||||
return (
|
||||
False,
|
||||
"Docker Buildx is required but not installed.\n"
|
||||
"Your machine architecture ("
|
||||
+ platform.machine()
|
||||
+ ") requires Buildx to cross-compile images for linux/amd64.\n"
|
||||
"Install Buildx: https://docs.docker.com/build/install-buildx/",
|
||||
)
|
||||
return True, None
|
||||
except Exception:
|
||||
return False, "Unable to verify local Docker build support."
|
||||
|
||||
|
||||
def check_capabilities(runner) -> DockerCapabilities:
|
||||
# check docker available
|
||||
if shutil.which("docker") is None:
|
||||
raise click.UsageError("Docker not installed") from None
|
||||
|
||||
try:
|
||||
stdout, _ = runner.run(
|
||||
subp_exec("docker", "info", "-f", "{{json .}}", collect=True)
|
||||
)
|
||||
info = json.loads(stdout)
|
||||
except (click.exceptions.Exit, json.JSONDecodeError):
|
||||
raise click.UsageError("Docker not installed or not running") from None
|
||||
|
||||
if not info["ServerVersion"]:
|
||||
raise click.UsageError("Docker not running") from None
|
||||
|
||||
compose_type: DockerComposeType
|
||||
try:
|
||||
compose = next(
|
||||
p for p in info["ClientInfo"]["Plugins"] if p["Name"] == "compose"
|
||||
)
|
||||
compose_version_str = compose["Version"]
|
||||
compose_type = "plugin"
|
||||
except (KeyError, StopIteration):
|
||||
if shutil.which("docker-compose") is None:
|
||||
raise click.UsageError("Docker Compose not installed") from None
|
||||
|
||||
compose_version_str, _ = runner.run(
|
||||
subp_exec("docker-compose", "--version", "--short", collect=True)
|
||||
)
|
||||
compose_type = "standalone"
|
||||
|
||||
# parse versions
|
||||
docker_version = _parse_version(info["ServerVersion"])
|
||||
compose_version = _parse_version(compose_version_str)
|
||||
|
||||
# check capabilities
|
||||
return DockerCapabilities(
|
||||
version_docker=docker_version,
|
||||
version_compose=compose_version,
|
||||
healthcheck_start_interval=docker_version >= Version(25, 0, 0),
|
||||
compose_type=compose_type,
|
||||
)
|
||||
|
||||
|
||||
def debugger_compose(*, port: int | None = None, base_url: str | None = None) -> dict:
|
||||
if port is None:
|
||||
return ""
|
||||
|
||||
config = {
|
||||
"langgraph-debugger": {
|
||||
"image": "langchain/langgraph-debugger",
|
||||
"restart": "on-failure",
|
||||
"depends_on": {
|
||||
"langgraph-postgres": {"condition": "service_healthy"},
|
||||
},
|
||||
"ports": [f'"{port}:3968"'],
|
||||
}
|
||||
}
|
||||
|
||||
if base_url:
|
||||
config["langgraph-debugger"]["environment"] = {
|
||||
"VITE_STUDIO_LOCAL_GRAPH_URL": base_url
|
||||
}
|
||||
|
||||
return config
|
||||
|
||||
|
||||
# Function to convert dictionary to YAML
|
||||
def dict_to_yaml(d: dict, *, indent: int = 0) -> str:
|
||||
"""Convert a dictionary to a YAML string."""
|
||||
yaml_str = ""
|
||||
|
||||
for idx, (key, value) in enumerate(d.items()):
|
||||
# Format things in a visually appealing way
|
||||
# Use an extra newline for top-level keys only
|
||||
if idx >= 1 and indent < 2:
|
||||
yaml_str += "\n"
|
||||
space = " " * indent
|
||||
if isinstance(value, dict):
|
||||
yaml_str += f"{space}{key}:\n" + dict_to_yaml(value, indent=indent + 1)
|
||||
elif isinstance(value, list):
|
||||
yaml_str += f"{space}{key}:\n"
|
||||
for item in value:
|
||||
yaml_str += f"{space} - {item}\n"
|
||||
else:
|
||||
yaml_str += f"{space}{key}: {value}\n"
|
||||
return yaml_str
|
||||
|
||||
|
||||
def compose_as_dict(
|
||||
capabilities: DockerCapabilities,
|
||||
*,
|
||||
port: int,
|
||||
debugger_port: int | None = None,
|
||||
debugger_base_url: str | None = None,
|
||||
# postgres://user:password@host:port/database?option=value
|
||||
postgres_uri: str | None = None,
|
||||
# If you are running against an already-built image, you can pass it here
|
||||
image: str | None = None,
|
||||
# Base image to use for the LangGraph API server
|
||||
base_image: str | None = None,
|
||||
# API version of the base image
|
||||
api_version: str | None = None,
|
||||
engine_runtime_mode: str = "combined_queue_worker",
|
||||
) -> dict:
|
||||
"""Create a docker compose file as a dictionary in YML style."""
|
||||
if postgres_uri is None:
|
||||
include_db = True
|
||||
postgres_uri = DEFAULT_POSTGRES_URI
|
||||
else:
|
||||
include_db = False
|
||||
|
||||
# The services below are defined in a non-intuitive order to match
|
||||
# the existing unit tests for this function.
|
||||
# It's fine to re-order just requires updating the unit tests, so it should
|
||||
# be done with caution.
|
||||
|
||||
# Define the Redis service first as per the test order
|
||||
services = {
|
||||
"langgraph-redis": {
|
||||
"image": "redis:6",
|
||||
"healthcheck": {
|
||||
"test": "redis-cli ping",
|
||||
"interval": "5s",
|
||||
"timeout": "1s",
|
||||
"retries": 5,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
# Add Postgres service before langgraph-api if it is needed
|
||||
if include_db:
|
||||
services["langgraph-postgres"] = {
|
||||
"image": "pgvector/pgvector:pg16",
|
||||
"ports": ['"5433:5432"'],
|
||||
"environment": {
|
||||
"POSTGRES_DB": "postgres",
|
||||
"POSTGRES_USER": "postgres",
|
||||
"POSTGRES_PASSWORD": "postgres",
|
||||
},
|
||||
"command": ["postgres", "-c", "shared_preload_libraries=vector"],
|
||||
"volumes": ["langgraph-data:/var/lib/postgresql/data"],
|
||||
"healthcheck": {
|
||||
"test": "pg_isready -U postgres",
|
||||
"start_period": "10s",
|
||||
"timeout": "1s",
|
||||
"retries": 5,
|
||||
},
|
||||
}
|
||||
if capabilities.healthcheck_start_interval:
|
||||
services["langgraph-postgres"]["healthcheck"]["interval"] = "60s"
|
||||
services["langgraph-postgres"]["healthcheck"]["start_interval"] = "1s"
|
||||
else:
|
||||
services["langgraph-postgres"]["healthcheck"]["interval"] = "5s"
|
||||
|
||||
# Add optional debugger service if debugger_port is specified
|
||||
if debugger_port:
|
||||
services["langgraph-debugger"] = debugger_compose(
|
||||
port=debugger_port, base_url=debugger_base_url
|
||||
)["langgraph-debugger"]
|
||||
|
||||
# Add langgraph-api service
|
||||
api_environment = {
|
||||
"REDIS_URI": "redis://langgraph-redis:6379",
|
||||
"POSTGRES_URI": postgres_uri,
|
||||
}
|
||||
if engine_runtime_mode == "distributed":
|
||||
api_environment["N_JOBS_PER_WORKER"] = '"0"'
|
||||
|
||||
services["langgraph-api"] = {
|
||||
"ports": [f'"{port}:8000"'],
|
||||
"depends_on": {
|
||||
"langgraph-redis": {"condition": "service_healthy"},
|
||||
},
|
||||
"environment": api_environment,
|
||||
}
|
||||
if image:
|
||||
services["langgraph-api"]["image"] = image
|
||||
|
||||
# If Postgres is included, add it to the dependencies of langgraph-api
|
||||
if include_db:
|
||||
services["langgraph-api"]["depends_on"]["langgraph-postgres"] = {
|
||||
"condition": "service_healthy"
|
||||
}
|
||||
|
||||
# Additional healthcheck for langgraph-api if required
|
||||
if capabilities.healthcheck_start_interval:
|
||||
services["langgraph-api"]["healthcheck"] = {
|
||||
"test": "python /api/healthcheck.py",
|
||||
"interval": "60s",
|
||||
"start_interval": "1s",
|
||||
"start_period": "10s",
|
||||
}
|
||||
|
||||
# Final compose dictionary with volumes included if needed
|
||||
compose_dict = {}
|
||||
if include_db:
|
||||
compose_dict["volumes"] = {"langgraph-data": {"driver": "local"}}
|
||||
compose_dict["services"] = services
|
||||
|
||||
return compose_dict
|
||||
|
||||
|
||||
def compose(
|
||||
capabilities: DockerCapabilities,
|
||||
*,
|
||||
port: int,
|
||||
debugger_port: int | None = None,
|
||||
debugger_base_url: str | None = None,
|
||||
# postgres://user:password@host:port/database?option=value
|
||||
postgres_uri: str | None = None,
|
||||
image: str | None = None,
|
||||
base_image: str | None = None,
|
||||
api_version: str | None = None,
|
||||
engine_runtime_mode: str = "combined_queue_worker",
|
||||
) -> str:
|
||||
"""Create a docker compose file as a string."""
|
||||
compose_content = compose_as_dict(
|
||||
capabilities,
|
||||
port=port,
|
||||
debugger_port=debugger_port,
|
||||
debugger_base_url=debugger_base_url,
|
||||
postgres_uri=postgres_uri,
|
||||
image=image,
|
||||
base_image=base_image,
|
||||
api_version=api_version,
|
||||
engine_runtime_mode=engine_runtime_mode,
|
||||
)
|
||||
compose_str = dict_to_yaml(compose_content)
|
||||
return compose_str
|
||||
|
||||
|
||||
def build_docker_image(
|
||||
runner,
|
||||
set: Callable[[str], None],
|
||||
config: pathlib.Path,
|
||||
config_json: dict,
|
||||
base_image: str | None,
|
||||
api_version: str | None,
|
||||
pull: bool,
|
||||
tag: str,
|
||||
passthrough: Sequence[str] = (),
|
||||
install_command: str | None = None,
|
||||
build_command: str | None = None,
|
||||
docker_command: Sequence[str] | None = None,
|
||||
extra_flags: Sequence[str] = (),
|
||||
verbose: bool = True,
|
||||
):
|
||||
"""Build a Docker image from a LangGraph config."""
|
||||
# pull latest images
|
||||
if pull:
|
||||
runner.run(
|
||||
subp_exec(
|
||||
"docker",
|
||||
"pull",
|
||||
langgraph_cli.config.docker_tag(config_json, base_image, api_version),
|
||||
verbose=verbose,
|
||||
)
|
||||
)
|
||||
set("Building...")
|
||||
# apply options
|
||||
args = [
|
||||
"-f",
|
||||
"-", # stdin
|
||||
"-t",
|
||||
tag,
|
||||
]
|
||||
# determine build context: use current directory for JS projects, config parent for Python
|
||||
is_js_project = config_json.get("node_version") and not config_json.get(
|
||||
"python_version"
|
||||
)
|
||||
# build/install commands only apply to JS projects for now
|
||||
# without install/build command, JS projects will follow the old behavior
|
||||
if is_js_project and (build_command or install_command):
|
||||
build_context = str(pathlib.Path.cwd())
|
||||
else:
|
||||
build_context = str(config.parent)
|
||||
|
||||
# Deep copy to avoid mutating the caller's config (config_to_docker
|
||||
# rewrites graph paths to container-internal paths in place).
|
||||
config_json = copy.deepcopy(config_json)
|
||||
stdin, additional_contexts = langgraph_cli.config.config_to_docker(
|
||||
config_path=config,
|
||||
config=config_json,
|
||||
base_image=base_image,
|
||||
api_version=api_version,
|
||||
install_command=install_command,
|
||||
build_command=build_command,
|
||||
build_context=build_context,
|
||||
)
|
||||
# add additional_contexts
|
||||
if additional_contexts:
|
||||
for k, v in additional_contexts.items():
|
||||
args.extend(["--build-context", f"{k}={v}"])
|
||||
cmd = tuple(docker_command) if docker_command else ("docker", "build")
|
||||
runner.run(
|
||||
subp_exec(
|
||||
*cmd,
|
||||
*args,
|
||||
*extra_flags,
|
||||
*passthrough,
|
||||
build_context,
|
||||
input=stdin,
|
||||
verbose=verbose,
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,174 @@
|
||||
import asyncio
|
||||
import signal
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
from contextlib import contextmanager
|
||||
from typing import cast
|
||||
|
||||
import click.exceptions
|
||||
|
||||
|
||||
@contextmanager
|
||||
def Runner():
|
||||
if hasattr(asyncio, "Runner"):
|
||||
with asyncio.Runner() as runner:
|
||||
yield runner
|
||||
else:
|
||||
|
||||
class _Runner:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
pass
|
||||
|
||||
def run(self, coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
yield _Runner()
|
||||
|
||||
|
||||
async def subp_exec(
|
||||
cmd: str,
|
||||
*args: str,
|
||||
input: str | None = None,
|
||||
wait: float | None = None,
|
||||
verbose: bool = False,
|
||||
collect: bool = False,
|
||||
on_stdout: Callable[[str], bool | None] | None = None,
|
||||
) -> tuple[str | None, str | None]:
|
||||
if verbose:
|
||||
cmd_str = f"+ {cmd} {' '.join(map(str, args))}"
|
||||
if input:
|
||||
print(cmd_str, " <\n", "\n".join(filter(None, input.splitlines())), sep="")
|
||||
else:
|
||||
print(cmd_str)
|
||||
if wait:
|
||||
await asyncio.sleep(wait)
|
||||
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
cmd,
|
||||
*args,
|
||||
stdin=asyncio.subprocess.PIPE if input else None,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
def signal_handler():
|
||||
# make sure process exists, then terminate it
|
||||
if proc.returncode is None:
|
||||
proc.terminate()
|
||||
|
||||
original_sigint_handler = signal.getsignal(signal.SIGINT)
|
||||
if sys.platform == "win32":
|
||||
|
||||
def handle_windows_signal(signum, frame):
|
||||
signal_handler()
|
||||
original_sigint_handler(signum, frame)
|
||||
|
||||
signal.signal(signal.SIGINT, handle_windows_signal)
|
||||
# NOTE: we're not adding a handler for SIGTERM since it's ignored on Windows
|
||||
else:
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.add_signal_handler(signal.SIGINT, signal_handler)
|
||||
loop.add_signal_handler(signal.SIGTERM, signal_handler)
|
||||
|
||||
empty_fut: asyncio.Future = asyncio.Future()
|
||||
empty_fut.set_result(None)
|
||||
stdout, stderr, _ = await asyncio.gather(
|
||||
monitor_stream(
|
||||
cast(asyncio.StreamReader, proc.stdout),
|
||||
collect=True,
|
||||
display=verbose,
|
||||
on_line=on_stdout,
|
||||
),
|
||||
monitor_stream(
|
||||
cast(asyncio.StreamReader, proc.stderr),
|
||||
collect=True,
|
||||
display=verbose,
|
||||
),
|
||||
proc._feed_stdin(input.encode()) if input else empty_fut, # type: ignore[attr-defined]
|
||||
)
|
||||
returncode = await proc.wait()
|
||||
if (
|
||||
returncode is not None
|
||||
and returncode != 0 # success
|
||||
and returncode != 130 # user interrupt
|
||||
):
|
||||
sys.stdout.write(stdout.decode() if stdout else "")
|
||||
sys.stderr.write(stderr.decode() if stderr else "")
|
||||
raise click.exceptions.Exit(returncode)
|
||||
if collect:
|
||||
return (
|
||||
stdout.decode() if stdout else None,
|
||||
stderr.decode() if stderr else None,
|
||||
)
|
||||
else:
|
||||
return None, None
|
||||
finally:
|
||||
try:
|
||||
if proc.returncode is None:
|
||||
try:
|
||||
proc.terminate()
|
||||
except (ProcessLookupError, KeyboardInterrupt):
|
||||
pass
|
||||
|
||||
if sys.platform == "win32":
|
||||
signal.signal(signal.SIGINT, original_sigint_handler)
|
||||
else:
|
||||
loop.remove_signal_handler(signal.SIGINT)
|
||||
loop.remove_signal_handler(signal.SIGTERM)
|
||||
except UnboundLocalError:
|
||||
pass
|
||||
|
||||
|
||||
async def monitor_stream(
|
||||
stream: asyncio.StreamReader,
|
||||
collect: bool = False,
|
||||
display: bool = False,
|
||||
on_line: Callable[[str], bool | None] | None = None,
|
||||
) -> bytearray | None:
|
||||
if collect:
|
||||
ba = bytearray()
|
||||
|
||||
def handle(line: bytes, overrun: bool):
|
||||
nonlocal on_line
|
||||
nonlocal display
|
||||
|
||||
if display:
|
||||
sys.stdout.buffer.write(line)
|
||||
if overrun:
|
||||
return
|
||||
if collect:
|
||||
ba.extend(line)
|
||||
if on_line:
|
||||
if on_line(line.decode()):
|
||||
on_line = None
|
||||
display = True
|
||||
|
||||
"""Adapted from asyncio.StreamReader.readline() to handle LimitOverrunError."""
|
||||
sep = b"\n"
|
||||
seplen = len(sep)
|
||||
while True:
|
||||
try:
|
||||
line = await stream.readuntil(sep)
|
||||
overrun = False
|
||||
except asyncio.IncompleteReadError as e:
|
||||
line = e.partial
|
||||
overrun = False
|
||||
except asyncio.LimitOverrunError as e:
|
||||
if stream._buffer.startswith(sep, e.consumed):
|
||||
line = stream._buffer[: e.consumed + seplen]
|
||||
else:
|
||||
line = stream._buffer.clear()
|
||||
overrun = True
|
||||
stream._maybe_resume_transport()
|
||||
await asyncio.to_thread(handle, line, overrun)
|
||||
if line == b"":
|
||||
break
|
||||
|
||||
if collect:
|
||||
return ba
|
||||
else:
|
||||
return None
|
||||
@@ -0,0 +1,205 @@
|
||||
"""HTTP client for LangGraph host backend deployments."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import click
|
||||
import httpx
|
||||
|
||||
|
||||
class HostBackendError(click.ClickException):
|
||||
"""Raised when the host backend returns an error response."""
|
||||
|
||||
def __init__(self, message: str, status_code: int | None = None):
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
class HostBackendClient:
|
||||
"""Minimal JSON HTTP client for the host backend deployment service."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
tenant_id: str | None = None,
|
||||
):
|
||||
if not base_url:
|
||||
raise click.UsageError("Host backend URL is required")
|
||||
transport = httpx.HTTPTransport(retries=3)
|
||||
headers: dict[str, str] = {
|
||||
"X-Api-Key": api_key,
|
||||
"Accept": "application/json",
|
||||
}
|
||||
if tenant_id:
|
||||
headers["X-Tenant-ID"] = tenant_id
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._client = httpx.Client(
|
||||
base_url=self._base_url,
|
||||
headers=headers,
|
||||
transport=transport,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
payload: dict[str, Any] | None = None,
|
||||
params: dict[str, Any] | None = None,
|
||||
) -> Any:
|
||||
try:
|
||||
resp = self._client.request(method, path, json=payload, params=params)
|
||||
resp.raise_for_status()
|
||||
except httpx.HTTPStatusError as err:
|
||||
detail = err.response.text or str(err.response.status_code)
|
||||
raise HostBackendError(
|
||||
f"{method} {path} failed with status {err.response.status_code}: {detail}",
|
||||
status_code=err.response.status_code,
|
||||
) from None
|
||||
except httpx.TransportError as err:
|
||||
raise HostBackendError(str(err)) from None
|
||||
|
||||
if not resp.content:
|
||||
return None
|
||||
try:
|
||||
return resp.json()
|
||||
except ValueError as err:
|
||||
raise HostBackendError(
|
||||
f"Failed to decode response from {path}: {err}"
|
||||
) from None
|
||||
|
||||
def create_deployment(
|
||||
self,
|
||||
name: str,
|
||||
deployment_type: str,
|
||||
source: str,
|
||||
config_path: str | None = None,
|
||||
secrets: list[dict[str, str]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a deployment."""
|
||||
payload: dict[str, Any] = {
|
||||
"name": name,
|
||||
"source": source,
|
||||
"source_config": {"deployment_type": deployment_type},
|
||||
"source_revision_config": {},
|
||||
}
|
||||
if source == "internal_source" and config_path:
|
||||
payload["source_revision_config"]["langgraph_config_path"] = config_path
|
||||
if secrets is not None:
|
||||
payload["secrets"] = secrets
|
||||
return self._request("POST", "/v2/deployments", payload)
|
||||
|
||||
def list_deployments(self, name_contains: str = "") -> dict[str, Any]:
|
||||
return self._request(
|
||||
"GET",
|
||||
"/v2/deployments",
|
||||
params={"name_contains": name_contains},
|
||||
)
|
||||
|
||||
def get_deployment(self, deployment_id: str) -> dict[str, Any]:
|
||||
return self._request("GET", f"/v2/deployments/{deployment_id}")
|
||||
|
||||
def delete_deployment(self, deployment_id: str) -> None:
|
||||
return self._request("DELETE", f"/v2/deployments/{deployment_id}")
|
||||
|
||||
def request_push_token(self, deployment_id: str) -> dict[str, Any]:
|
||||
return self._request(
|
||||
"POST",
|
||||
f"/v2/deployments/{deployment_id}/push-token",
|
||||
)
|
||||
|
||||
def request_upload_url(self, deployment_id: str) -> dict[str, Any]:
|
||||
"""Get a signed GCS URL for uploading the source tarball."""
|
||||
return self._request(
|
||||
"POST",
|
||||
f"/v2/deployments/{deployment_id}/upload-url",
|
||||
)
|
||||
|
||||
def update_deployment(
|
||||
self,
|
||||
deployment_id: str,
|
||||
image_uri: str,
|
||||
secrets: list[dict[str, str]] | None = None,
|
||||
tracked_packages: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {
|
||||
"revision_source": "internal_docker",
|
||||
"source_revision_config": {"image_uri": image_uri},
|
||||
}
|
||||
if tracked_packages:
|
||||
payload["tracked_packages"] = tracked_packages
|
||||
if secrets is not None:
|
||||
payload["secrets"] = secrets
|
||||
return self._request(
|
||||
"PATCH",
|
||||
f"/v2/deployments/{deployment_id}",
|
||||
payload,
|
||||
)
|
||||
|
||||
def update_deployment_internal_source(
|
||||
self,
|
||||
deployment_id: str,
|
||||
source_tarball_path: str,
|
||||
config_path: str,
|
||||
secrets: list[dict[str, str]] | None = None,
|
||||
install_command: str | None = None,
|
||||
build_command: str | None = None,
|
||||
tracked_packages: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Trigger a remote build revision with the uploaded tarball."""
|
||||
payload: dict[str, Any] = {
|
||||
"revision_source": "internal_source",
|
||||
"source_revision_config": {
|
||||
"source_tarball_path": source_tarball_path,
|
||||
"langgraph_config_path": config_path,
|
||||
},
|
||||
}
|
||||
if tracked_packages:
|
||||
payload["tracked_packages"] = tracked_packages
|
||||
|
||||
source_config: dict[str, Any] = {}
|
||||
if install_command is not None:
|
||||
source_config["install_command"] = install_command
|
||||
if build_command is not None:
|
||||
source_config["build_command"] = build_command
|
||||
if source_config:
|
||||
payload["source_config"] = source_config
|
||||
|
||||
if secrets is not None:
|
||||
payload["secrets"] = secrets
|
||||
return self._request("PATCH", f"/v2/deployments/{deployment_id}", payload)
|
||||
|
||||
def list_revisions(self, deployment_id: str, limit: int = 1) -> dict[str, Any]:
|
||||
return self._request(
|
||||
"GET",
|
||||
f"/v2/deployments/{deployment_id}/revisions?limit={limit}",
|
||||
)
|
||||
|
||||
def get_revision(self, deployment_id: str, revision_id: str) -> dict[str, Any]:
|
||||
return self._request(
|
||||
"GET",
|
||||
f"/v2/deployments/{deployment_id}/revisions/{revision_id}",
|
||||
)
|
||||
|
||||
def get_build_logs(
|
||||
self, project_id: str, revision_id: str, payload: dict[str, Any]
|
||||
) -> Any:
|
||||
return self._request(
|
||||
"POST",
|
||||
f"/v1/projects/{project_id}/revisions/{revision_id}/build_logs",
|
||||
payload,
|
||||
)
|
||||
|
||||
def get_deploy_logs(
|
||||
self,
|
||||
project_id: str,
|
||||
payload: dict[str, Any],
|
||||
revision_id: str | None = None,
|
||||
) -> Any:
|
||||
if revision_id:
|
||||
path = f"/v1/projects/{project_id}/revisions/{revision_id}/deploy_logs"
|
||||
else:
|
||||
path = f"/v1/projects/{project_id}/deploy_logs"
|
||||
return self._request("POST", path, payload)
|
||||
@@ -0,0 +1,107 @@
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
|
||||
|
||||
class Progress:
|
||||
delay: float = 0.1
|
||||
|
||||
@staticmethod
|
||||
def spinning_cursor():
|
||||
while True:
|
||||
yield from "|/-\\"
|
||||
|
||||
def __init__(self, *, message="", elapsed: bool = False, json_mode: bool = False):
|
||||
self.message = message
|
||||
self._base_message = message
|
||||
self._show_elapsed = elapsed
|
||||
self._json_mode = json_mode
|
||||
# use this to make sure we don't kill thread when we set msg to ""
|
||||
self._stop = threading.Event()
|
||||
# signalled when the spinner has no text on screen
|
||||
self._line_clear = threading.Event()
|
||||
self._line_clear.set()
|
||||
self.spinner_generator = self.spinning_cursor()
|
||||
|
||||
def spinner_iteration(self):
|
||||
message = self.message
|
||||
sys.stdout.write(next(self.spinner_generator) + " " + message)
|
||||
sys.stdout.flush()
|
||||
time.sleep(self.delay)
|
||||
# clear the spinner and message
|
||||
sys.stdout.write(
|
||||
"\b" * (len(message) + 2)
|
||||
+ " " * (len(message) + 2)
|
||||
+ "\b" * (len(message) + 2)
|
||||
)
|
||||
sys.stdout.flush()
|
||||
|
||||
def _format_elapsed(self, seconds: float) -> str:
|
||||
mins, secs = divmod(int(seconds), 60)
|
||||
if mins:
|
||||
return f"{self._base_message} ({mins}m {secs:02d}s)"
|
||||
return f"{self._base_message} ({secs}s)"
|
||||
|
||||
def spinner_task(self):
|
||||
start = time.monotonic()
|
||||
while not self._stop.is_set():
|
||||
if not self.message:
|
||||
self._line_clear.set()
|
||||
time.sleep(self.delay)
|
||||
continue
|
||||
if self._show_elapsed:
|
||||
self.message = self._format_elapsed(time.monotonic() - start)
|
||||
message = self.message
|
||||
if not message:
|
||||
self._line_clear.set()
|
||||
continue
|
||||
self._line_clear.clear()
|
||||
sys.stdout.write(next(self.spinner_generator) + " " + message)
|
||||
sys.stdout.flush()
|
||||
time.sleep(self.delay)
|
||||
# clear the spinner and message
|
||||
sys.stdout.write(
|
||||
"\b" * (len(message) + 2)
|
||||
+ " " * (len(message) + 2)
|
||||
+ "\b" * (len(message) + 2)
|
||||
)
|
||||
sys.stdout.flush()
|
||||
self._line_clear.set()
|
||||
|
||||
def __enter__(self) -> Callable[[str], None]:
|
||||
if self._json_mode:
|
||||
return lambda message: None
|
||||
|
||||
if sys.stdout.isatty():
|
||||
self.thread = threading.Thread(target=self.spinner_task)
|
||||
self.thread.start()
|
||||
|
||||
def set_message(message):
|
||||
self.message = message
|
||||
self._base_message = message or self._base_message
|
||||
if not message:
|
||||
self._line_clear.wait(timeout=0.5)
|
||||
|
||||
return set_message
|
||||
else:
|
||||
|
||||
def set_message(message):
|
||||
if message:
|
||||
sys.stderr.write(message + "\n")
|
||||
sys.stderr.flush()
|
||||
|
||||
return set_message
|
||||
|
||||
def __exit__(self, exception, value, tb):
|
||||
if self._json_mode:
|
||||
return
|
||||
if sys.stdout.isatty():
|
||||
self.message = ""
|
||||
self._stop.set()
|
||||
try:
|
||||
self.thread.join()
|
||||
finally:
|
||||
del self.thread
|
||||
if exception is not None:
|
||||
return False
|
||||
@@ -0,0 +1,788 @@
|
||||
from typing import Any, Literal, TypedDict
|
||||
|
||||
from typing_extensions import Required
|
||||
|
||||
Distros = Literal["debian", "wolfi", "bookworm"]
|
||||
MiddlewareOrders = Literal["auth_first", "middleware_first"]
|
||||
|
||||
|
||||
class TTLConfig(TypedDict, total=False):
|
||||
"""Configuration for TTL (time-to-live) behavior in the store."""
|
||||
|
||||
refresh_on_read: bool
|
||||
"""Default behavior for refreshing TTLs on read operations (`GET` and `SEARCH`).
|
||||
|
||||
If `True`, TTLs will be refreshed on read operations (get/search) by default.
|
||||
This can be overridden per-operation by explicitly setting `refresh_ttl`.
|
||||
Defaults to `True` if not configured.
|
||||
"""
|
||||
default_ttl: float | None
|
||||
"""Optional. Default TTL (time-to-live) in minutes for new items.
|
||||
|
||||
If provided, all new items will have this TTL unless explicitly overridden.
|
||||
If omitted, items will have no TTL by default.
|
||||
"""
|
||||
sweep_interval_minutes: int | None
|
||||
"""Optional. Interval in minutes between TTL sweep iterations.
|
||||
|
||||
If provided, the store will periodically delete expired items based on the TTL.
|
||||
If omitted, no automatic sweeping will occur.
|
||||
"""
|
||||
|
||||
|
||||
class IndexConfig(TypedDict, total=False):
|
||||
"""Configuration for indexing documents for semantic search in the store.
|
||||
|
||||
This governs how text is converted into embeddings and stored for vector-based lookups.
|
||||
"""
|
||||
|
||||
dims: int
|
||||
"""Required. Dimensionality of the embedding vectors you will store.
|
||||
|
||||
Must match the output dimension of your selected embedding model or custom embed function.
|
||||
If mismatched, you will likely encounter shape/size errors when inserting or querying vectors.
|
||||
|
||||
Common embedding model output dimensions:
|
||||
- openai:text-embedding-3-large: 3072
|
||||
- openai:text-embedding-3-small: 1536
|
||||
- openai:text-embedding-ada-002: 1536
|
||||
- cohere:embed-english-v3.0: 1024
|
||||
- cohere:embed-english-light-v3.0: 384
|
||||
- cohere:embed-multilingual-v3.0: 1024
|
||||
- cohere:embed-multilingual-light-v3.0: 384
|
||||
"""
|
||||
|
||||
embed: str
|
||||
"""Required. Identifier or reference to the embedding model or a custom embedding function.
|
||||
|
||||
The format can vary:
|
||||
- "<provider>:<model_name>" for recognized providers (e.g., "openai:text-embedding-3-large")
|
||||
- "path/to/module.py:function_name" for your own local embedding function
|
||||
- "my_custom_embed" if it's a known alias in your system
|
||||
|
||||
Examples:
|
||||
- "openai:text-embedding-3-large"
|
||||
- "cohere:embed-multilingual-v3.0"
|
||||
- "src/app.py:embeddings"
|
||||
|
||||
Note: Must return embeddings of dimension `dims`.
|
||||
"""
|
||||
|
||||
fields: list[str] | None
|
||||
"""Optional. List of JSON fields to extract before generating embeddings.
|
||||
|
||||
Defaults to ["$"], which means the entire JSON object is embedded as one piece of text.
|
||||
If you provide multiple fields (e.g. ["title", "content"]), each is extracted and embedded separately,
|
||||
often saving token usage if you only care about certain parts of the data.
|
||||
|
||||
Example:
|
||||
fields=["title", "abstract", "author.biography"]
|
||||
"""
|
||||
|
||||
|
||||
class StoreConfig(TypedDict, total=False):
|
||||
"""Configuration for the built-in long-term memory store.
|
||||
|
||||
This store can optionally perform semantic search. If you omit `index`,
|
||||
the store will just handle traditional (non-embedded) data without vector lookups.
|
||||
"""
|
||||
|
||||
index: IndexConfig | None
|
||||
"""Optional. Defines the vector-based semantic search configuration.
|
||||
|
||||
If provided, the store will:
|
||||
- Generate embeddings according to `index.embed`
|
||||
- Enforce the embedding dimension given by `index.dims`
|
||||
- Embed only specified JSON fields (if any) from `index.fields`
|
||||
|
||||
If omitted, no vector index is initialized.
|
||||
"""
|
||||
|
||||
ttl: TTLConfig | None
|
||||
"""Optional. Defines the TTL (time-to-live) behavior configuration.
|
||||
|
||||
If provided, the store will apply TTL settings according to the configuration.
|
||||
If omitted, no TTL behavior is configured.
|
||||
"""
|
||||
|
||||
|
||||
class ThreadTTLConfig(TypedDict, total=False):
|
||||
"""Configure a default TTL for checkpointed data within threads."""
|
||||
|
||||
strategy: Literal["delete", "keep_latest"]
|
||||
"""Action taken when a thread exceeds its TTL.
|
||||
|
||||
- "delete": Remove the thread and all its data entirely.
|
||||
- "keep_latest": Prune old checkpoints but keep the thread and its latest state.
|
||||
"""
|
||||
default_ttl: float | None
|
||||
"""Default TTL (time-to-live) in minutes for checkpointed data."""
|
||||
sweep_interval_minutes: int | None
|
||||
"""Interval in minutes between sweep iterations.
|
||||
If omitted, a default interval will be used (typically ~ 5 minutes)."""
|
||||
sweep_limit: int | None
|
||||
"""Maximum number of threads to process per sweep iteration. Defaults to 1000."""
|
||||
|
||||
|
||||
class SerdeConfig(TypedDict, total=False):
|
||||
"""Configuration for the built-in serde, which handles checkpointing of state.
|
||||
|
||||
If omitted, no serde is set up (the object store will still be present, however)."""
|
||||
|
||||
allowed_json_modules: list[list[str]] | bool | None
|
||||
"""Optional. List of allowed python modules to de-serialize custom objects from JSON.
|
||||
|
||||
If provided, only the specified modules will be allowed to be deserialized.
|
||||
If omitted, no modules are allowed, and the object returned will simply be a json object OR
|
||||
a deserialized langchain object.
|
||||
|
||||
Example:
|
||||
{...
|
||||
"serde": {
|
||||
"allowed_json_modules": [
|
||||
["my_agent", "my_file", "SomeType"],
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
If you set this to True, any module will be allowed to be deserialized.
|
||||
|
||||
Example:
|
||||
{...
|
||||
"serde": {
|
||||
"allowed_json_modules": True
|
||||
}
|
||||
}
|
||||
|
||||
"""
|
||||
allowed_msgpack_modules: list[list[str]] | bool | None
|
||||
"""Optional. List of allowed python modules to de-serialize custom objects from msgpack.
|
||||
|
||||
Known safe types (langgraph.checkpoint.serde.jsonplus.SAFE_MSGPACK_TYPES) are always
|
||||
allowed regardless of this setting. Use this to allowlist your custom Pydantic models,
|
||||
dataclasses, and other user-defined types.
|
||||
|
||||
If True (default), unregistered types will log a warning but still be deserialized.
|
||||
If None, only known safe types will be deserialized; unregistered types will be blocked.
|
||||
|
||||
Example - allowlist specific types (no warnings for these):
|
||||
{...
|
||||
"serde": {
|
||||
"allowed_msgpack_modules": [
|
||||
["my_agent.models", "MyState"],
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Example - strict mode (only safe types allowed):
|
||||
{...
|
||||
"serde": {
|
||||
"allowed_msgpack_modules": null
|
||||
}
|
||||
}
|
||||
|
||||
"""
|
||||
pickle_fallback: bool
|
||||
"""Optional. Whether to allow pickling as a fallback for deserialization.
|
||||
|
||||
If True, pickling will be allowed as a fallback for deserialization.
|
||||
If False, pickling will not be allowed as a fallback for deserialization.
|
||||
Defaults to True if not configured."""
|
||||
|
||||
|
||||
class CheckpointerConfig(TypedDict, total=False):
|
||||
"""Configuration for the built-in checkpointer, which handles checkpointing of state.
|
||||
|
||||
If omitted, no checkpointer is set up (the object store will still be present, however).
|
||||
"""
|
||||
|
||||
path: str
|
||||
"""Import path to an async context manager that yields a `BaseCheckpointSaver`
|
||||
instance.
|
||||
|
||||
The referenced object should be an `@asynccontextmanager`-decorated function
|
||||
so that the server can properly manage the checkpointer's lifecycle (e.g.
|
||||
opening and closing connections).
|
||||
|
||||
Examples:
|
||||
- "./my_checkpointer.py:create_checkpointer"
|
||||
- "my_package.checkpointer:create_checkpointer"
|
||||
|
||||
When provided, this replaces the default checkpointer.
|
||||
|
||||
You can use the `langgraph-checkpoint-conformance` package
|
||||
(https://pypi.org/project/langgraph-checkpoint-conformance/) to run simple
|
||||
conformance tests against your custom checkpointer and catch
|
||||
incompatibilities early.
|
||||
"""
|
||||
|
||||
ttl: ThreadTTLConfig | None
|
||||
"""Optional. Defines the TTL (time-to-live) behavior configuration.
|
||||
|
||||
If provided, the checkpointer will apply TTL settings according to the configuration.
|
||||
If omitted, no TTL behavior is configured.
|
||||
"""
|
||||
serde: SerdeConfig | None
|
||||
"""Optional. Defines the serde configuration.
|
||||
|
||||
If provided, the checkpointer will apply serde settings according to the configuration.
|
||||
If omitted, no serde behavior is configured.
|
||||
|
||||
This configuration requires server version 0.5 or later to take effect.
|
||||
"""
|
||||
|
||||
|
||||
class SecurityConfig(TypedDict, total=False):
|
||||
"""Configuration for OpenAPI security definitions and requirements.
|
||||
|
||||
Useful for specifying global or path-level authentication and authorization flows
|
||||
(e.g., OAuth2, API key headers, etc.).
|
||||
"""
|
||||
|
||||
securitySchemes: dict[str, dict[str, Any]]
|
||||
"""Describe each security scheme recognized by your OpenAPI spec.
|
||||
|
||||
Keys are scheme names (e.g. "OAuth2", "ApiKeyAuth") and values are their definitions.
|
||||
Example:
|
||||
{
|
||||
"OAuth2": {
|
||||
"type": "oauth2",
|
||||
"flows": {
|
||||
"password": {
|
||||
"tokenUrl": "/token",
|
||||
"scopes": {"read": "Read data", "write": "Write data"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
security: list[dict[str, list[str]]]
|
||||
"""Global security requirements across all endpoints.
|
||||
|
||||
Each element in the list maps a security scheme (e.g. "OAuth2") to a list of scopes (e.g. ["read", "write"]).
|
||||
Example:
|
||||
[
|
||||
{"OAuth2": ["read", "write"]},
|
||||
{"ApiKeyAuth": []}
|
||||
]
|
||||
"""
|
||||
# path => {method => security}
|
||||
paths: dict[str, dict[str, list[dict[str, list[str]]]]]
|
||||
"""Path-specific security overrides.
|
||||
|
||||
Keys are path templates (e.g., "/items/{item_id}"), mapping to:
|
||||
- Keys that are HTTP methods (e.g., "GET", "POST"),
|
||||
- Values are lists of security definitions (just like `security`) for that method.
|
||||
|
||||
Example:
|
||||
{
|
||||
"/private_data": {
|
||||
"GET": [{"OAuth2": ["read"]}],
|
||||
"POST": [{"OAuth2": ["write"]}]
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class CacheConfig(TypedDict, total=False):
|
||||
cache_keys: list[str]
|
||||
"""Optional. List of header keys to use for caching.
|
||||
|
||||
Example:
|
||||
["user_id", "workspace_id"]
|
||||
"""
|
||||
ttl_seconds: int
|
||||
"""Optional. Time-to-live in seconds for cached items.
|
||||
|
||||
Example:
|
||||
3600
|
||||
"""
|
||||
max_size: int
|
||||
"""Optional. Maximum size of the cache.
|
||||
|
||||
Example:
|
||||
100
|
||||
"""
|
||||
|
||||
|
||||
class AuthConfig(TypedDict, total=False):
|
||||
"""Configuration for custom authentication logic and how it integrates into the OpenAPI spec."""
|
||||
|
||||
path: str
|
||||
"""Required. Path to an instance of the Auth() class that implements custom authentication.
|
||||
|
||||
Format: "path/to/file.py:my_auth"
|
||||
"""
|
||||
disable_studio_auth: bool
|
||||
"""Optional. Whether to disable LangSmith API-key authentication for requests originating the Studio.
|
||||
|
||||
Defaults to False, meaning that if a particular header is set, the server will verify the `x-api-key` header
|
||||
value is a valid API key for the deployment's workspace. If `True`, all requests will go through your custom
|
||||
authentication logic, regardless of origin of the request.
|
||||
"""
|
||||
openapi: SecurityConfig
|
||||
"""The security configuration to include in your server's OpenAPI spec.
|
||||
|
||||
Example (OAuth2):
|
||||
{
|
||||
"securitySchemes": {
|
||||
"OAuth2": {
|
||||
"type": "oauth2",
|
||||
"flows": {
|
||||
"password": {
|
||||
"tokenUrl": "/token",
|
||||
"scopes": {"me": "Read user info", "items": "Manage items"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{"OAuth2": ["me"]}
|
||||
]
|
||||
}
|
||||
"""
|
||||
cache: CacheConfig
|
||||
"""Optional. Cache configuration for the server.
|
||||
|
||||
Example:
|
||||
{
|
||||
"cache_keys": ["user_id", "workspace_id"],
|
||||
"ttl_seconds": 3600,
|
||||
"max_size": 100
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class EncryptionConfig(TypedDict, total=False):
|
||||
"""Configuration for custom at-rest encryption logic.
|
||||
|
||||
Allows you to implement custom encryption for sensitive data stored in the database,
|
||||
including metadata fields and checkpoint blobs."""
|
||||
|
||||
path: str
|
||||
"""Required. Path to an instance of the Encryption() class that implements custom encryption handlers.
|
||||
|
||||
Format: "path/to/file.py:my_encryption"
|
||||
|
||||
Example:
|
||||
{
|
||||
"encryption": {
|
||||
"path": "./encryption.py:my_encryption"
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class CorsConfig(TypedDict, total=False):
|
||||
"""Specifies Cross-Origin Resource Sharing (CORS) rules for your server.
|
||||
|
||||
If omitted, defaults are typically very restrictive (often no cross-origin requests).
|
||||
Configure carefully if you want to allow usage from browsers hosted on other domains.
|
||||
"""
|
||||
|
||||
allow_origins: list[str]
|
||||
"""Optional. List of allowed origins (e.g., "https://example.com").
|
||||
|
||||
Default is often an empty list (no external origins).
|
||||
Use "*" only if you trust all origins, as that bypasses most restrictions.
|
||||
"""
|
||||
allow_methods: list[str]
|
||||
"""Optional. HTTP methods permitted for cross-origin requests (e.g. ["GET", "POST"]).
|
||||
|
||||
Default might be ["GET", "POST", "OPTIONS"] depending on your server framework.
|
||||
"""
|
||||
allow_headers: list[str]
|
||||
"""Optional. HTTP headers that can be used in cross-origin requests (e.g. ["Content-Type", "Authorization"])."""
|
||||
allow_credentials: bool
|
||||
"""Optional. If `True`, cross-origin requests can include credentials (cookies, auth headers).
|
||||
|
||||
Default False to avoid accidentally exposing secured endpoints to untrusted sites.
|
||||
"""
|
||||
allow_origin_regex: str
|
||||
"""Optional. A regex pattern for matching allowed origins, used if you have dynamic subdomains.
|
||||
|
||||
Example: "^https://.*\\.mycompany\\.com$"
|
||||
"""
|
||||
expose_headers: list[str]
|
||||
"""Optional. List of headers that browsers are allowed to read from the response in cross-origin contexts."""
|
||||
max_age: int
|
||||
"""Optional. How many seconds the browser may cache preflight responses.
|
||||
|
||||
Default might be 600 (10 minutes). Larger values reduce preflight requests but can cause stale configurations.
|
||||
"""
|
||||
|
||||
|
||||
class ConfigurableHeaderConfig(TypedDict, total=False):
|
||||
"""Customize which headers to include as configurable values in your runs.
|
||||
|
||||
By default, omits x-api-key, x-tenant-id, and x-service-key.
|
||||
|
||||
Exclusions (if provided) take precedence.
|
||||
|
||||
Each value can be a raw string with an optional wildcard.
|
||||
"""
|
||||
|
||||
includes: list[str] | None
|
||||
"""Headers to include (if not also matched against an 'excludes' pattern).
|
||||
|
||||
Examples:
|
||||
- 'user-agent'
|
||||
- 'x-configurable-*'
|
||||
"""
|
||||
excludes: list[str] | None
|
||||
"""Headers to exclude. Applied before the 'includes' checks.
|
||||
|
||||
Examples:
|
||||
- 'x-api-key'
|
||||
- '*key*'
|
||||
- '*token*'
|
||||
"""
|
||||
|
||||
|
||||
class HttpConfig(TypedDict, total=False):
|
||||
"""Configuration for the built-in HTTP server that powers your deployment's routes and endpoints."""
|
||||
|
||||
app: str
|
||||
"""Optional. Import path to a custom Starlette/FastAPI application to mount.
|
||||
|
||||
Format: "path/to/module.py:app_var"
|
||||
If provided, it can override or extend the default routes.
|
||||
"""
|
||||
disable_assistants: bool
|
||||
"""Optional. If `True`, /assistants routes are removed from the server.
|
||||
|
||||
Default is False (meaning /assistants is enabled).
|
||||
"""
|
||||
disable_threads: bool
|
||||
"""Optional. If `True`, /threads routes are removed.
|
||||
|
||||
Default is False.
|
||||
"""
|
||||
disable_runs: bool
|
||||
"""Optional. If `True`, /runs routes are removed.
|
||||
|
||||
Default is False.
|
||||
"""
|
||||
disable_store: bool
|
||||
"""Optional. If `True`, /store routes are removed, disabling direct store interactions via HTTP.
|
||||
|
||||
Default is False.
|
||||
"""
|
||||
disable_mcp: bool
|
||||
"""Optional. If `True`, /mcp routes are removed, disabling default support to expose the deployment as an MCP server.
|
||||
|
||||
Default is False.
|
||||
"""
|
||||
disable_a2a: bool
|
||||
"""Optional. If `True`, /a2a routes are removed, disabling default support to expose the deployment as an agent-to-agent (A2A) server.
|
||||
|
||||
Default is False.
|
||||
"""
|
||||
disable_meta: bool
|
||||
"""Optional. Remove meta endpoints.
|
||||
|
||||
Set to True to disable the following endpoints: /openapi.json, /info, /metrics, /docs.
|
||||
This will also make the /ok endpoint skip any DB or other checks, always returning {"ok": True}.
|
||||
|
||||
Default is False.
|
||||
"""
|
||||
disable_ui: bool
|
||||
"""Optional. If `True`, /ui routes are removed, disabling the UI server.
|
||||
|
||||
Default is False.
|
||||
"""
|
||||
disable_webhooks: bool
|
||||
"""Optional. If `True`, webhooks are disabled. Runs created with an associated webhook will
|
||||
still be executed, but the webhook event will not be sent.
|
||||
|
||||
Default is False.
|
||||
"""
|
||||
cors: CorsConfig | None
|
||||
"""Optional. Defines CORS restrictions. If omitted, no special rules are set and
|
||||
cross-origin behavior depends on default server settings.
|
||||
"""
|
||||
configurable_headers: ConfigurableHeaderConfig | None
|
||||
"""Optional. Defines how headers are treated for a run's configuration.
|
||||
|
||||
You can include or exclude headers as configurable values to condition your
|
||||
agent's behavior or permissions on a request's headers."""
|
||||
logging_headers: ConfigurableHeaderConfig | None
|
||||
"""Optional. Defines which headers are excluded from logging."""
|
||||
middleware_order: MiddlewareOrders | None
|
||||
"""Optional. Defines the order in which to apply server customizations.
|
||||
|
||||
Choices:
|
||||
- "auth_first": Authentication hooks (custom or default) are evaluated
|
||||
before custom middleware.
|
||||
- "middleware_first": Custom middleware is evaluated
|
||||
before authentication hooks (custom or default).
|
||||
|
||||
Default is `middleware_first`.
|
||||
"""
|
||||
enable_custom_route_auth: bool
|
||||
"""Optional. If `True`, authentication is enabled for custom routes,
|
||||
not just the routes that are protected by default.
|
||||
(Routes protected by default include /assistants, /threads, and /runs).
|
||||
|
||||
Default is False. This flag only affects authentication behavior
|
||||
if `app` is provided and contains custom routes.
|
||||
"""
|
||||
mount_prefix: str
|
||||
"""Optional. URL prefix to prepend to all the routes.
|
||||
|
||||
Example:
|
||||
"/api"
|
||||
"""
|
||||
|
||||
|
||||
class WebhookUrlPolicy(TypedDict, total=False):
|
||||
require_https: bool
|
||||
"""Enforce HTTPS scheme for absolute URLs; reject `http://` when true."""
|
||||
allowed_domains: list[str]
|
||||
"""Hostname allowlist. Supports exact hosts and wildcard subdomains.
|
||||
|
||||
Use entries like "hooks.example.com" or "*.mycorp.com". The wildcard only
|
||||
matches subdomains ("foo.mycorp.com"), not the apex ("mycorp.com"). When
|
||||
empty or omitted, any public host is allowed (subject to SSRF IP checks).
|
||||
"""
|
||||
allowed_ports: list[int]
|
||||
"""Explicit port allowlist for absolute URLs.
|
||||
|
||||
If set, requests must use one of these ports. Defaults are respected when
|
||||
a port is not present in the URL (443 for https, 80 for http).
|
||||
"""
|
||||
max_url_length: int
|
||||
"""Maximum permitted URL length in characters; longer inputs are rejected early."""
|
||||
disable_loopback: bool
|
||||
"""Disallow relative URLs (internal loopback calls) when true."""
|
||||
|
||||
|
||||
class GraphDef(TypedDict, total=False):
|
||||
"""Definition of a graph with additional metadata."""
|
||||
|
||||
path: str
|
||||
"""Required. Import path to the graph object.
|
||||
|
||||
Format: "path/to/file.py:object_name"
|
||||
"""
|
||||
description: str | None
|
||||
"""Optional. A description of the graph's purpose and functionality.
|
||||
|
||||
This description is surfaced in the API and can help users understand what the graph does.
|
||||
"""
|
||||
|
||||
|
||||
class WebhooksConfig(TypedDict, total=False):
|
||||
env_prefix: str
|
||||
"""Required prefix for environment variables referenced in header templates.
|
||||
|
||||
Acts as an allowlist boundary to prevent leaking arbitrary environment
|
||||
variables. Defaults to "LG_WEBHOOK_" when omitted.
|
||||
"""
|
||||
url: WebhookUrlPolicy
|
||||
"""URL validation policy for user-supplied webhook endpoints."""
|
||||
headers: dict[str, str]
|
||||
"""Static headers to include with webhook requests.
|
||||
|
||||
Values may contain templates of the form "${{ env.VAR }}". On startup, these
|
||||
are resolved via the process environment after verifying `VAR` starts with
|
||||
`env_prefix`. Mixed literals and multiple templates are allowed.
|
||||
"""
|
||||
|
||||
|
||||
class UvSource(TypedDict, total=False):
|
||||
"""Deployment source rooted at a uv project or workspace."""
|
||||
|
||||
kind: Required[Literal["uv"]]
|
||||
"""Discriminator for uv-backed deployment mode."""
|
||||
|
||||
root: str
|
||||
"""Relative path from langgraph.json to the authoritative uv project root.
|
||||
|
||||
The resolved directory must contain `pyproject.toml` and `uv.lock`. If the
|
||||
root is a workspace, package discovery happens within this root.
|
||||
"""
|
||||
|
||||
package: str
|
||||
"""Optional. Workspace package name to deploy when the target is ambiguous.
|
||||
|
||||
If omitted, the CLI tries to infer the target package from the location of
|
||||
`langgraph.json`, or falls back to the only package if the root contains
|
||||
exactly one candidate.
|
||||
"""
|
||||
|
||||
|
||||
class Config(TypedDict, total=False):
|
||||
"""Top-level config for langgraph-cli or similar deployment tooling."""
|
||||
|
||||
python_version: str
|
||||
"""Optional. Python version in 'major.minor' format (e.g. '3.11').
|
||||
Must be at least 3.11 or greater for this deployment to function properly.
|
||||
"""
|
||||
|
||||
node_version: str | None
|
||||
"""Optional. Node.js version as a major version (e.g. '20'), if your deployment needs Node.
|
||||
Must be >= 20 if provided.
|
||||
"""
|
||||
|
||||
api_version: str | None
|
||||
"""Optional. Which semantic version of the LangGraph API server to use.
|
||||
|
||||
Defaults to latest. Check the
|
||||
[changelog](https://docs.langchain.com/langgraph-platform/langgraph-server-changelog)
|
||||
for more information."""
|
||||
|
||||
_INTERNAL_docker_tag: str | None
|
||||
"""Optional. Internal use only.
|
||||
"""
|
||||
|
||||
base_image: str | None
|
||||
"""Optional. Base image to use for the LangGraph API server.
|
||||
|
||||
Defaults to langchain/langgraph-api or langchain/langgraphjs-api."""
|
||||
|
||||
image_distro: Distros | None
|
||||
"""Optional. Linux distribution for the base image.
|
||||
|
||||
Must be one of 'wolfi', 'debian', or 'bookworm'.
|
||||
If omitted, defaults to 'debian' ('latest').
|
||||
"""
|
||||
|
||||
pip_config_file: str | None
|
||||
"""Optional. Path to a pip config file (e.g., "/etc/pip.conf" or "pip.ini") for controlling
|
||||
package installation (custom indices, credentials, etc.).
|
||||
|
||||
Only relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.
|
||||
"""
|
||||
|
||||
pip_installer: str | None
|
||||
"""Optional. Python package installer to use ('auto', 'pip', or 'uv').
|
||||
|
||||
- 'auto' (default): Use uv for supported base images, otherwise pip
|
||||
- 'pip': Force use of pip regardless of base image support
|
||||
- 'uv': Force use of uv (will fail if base image doesn't support it)
|
||||
"""
|
||||
|
||||
source: UvSource | None
|
||||
"""Optional. Explicit deployment source configuration.
|
||||
|
||||
Use `{ "kind": "uv", "root": "." }` to deploy from a uv project rooted at
|
||||
`root/pyproject.toml` and `root/uv.lock`. If `root` is a workspace and the
|
||||
target is ambiguous, set `package` to the desired workspace member.
|
||||
"""
|
||||
|
||||
dockerfile_lines: list[str]
|
||||
"""Optional. Additional Docker instructions that will be appended to your base Dockerfile.
|
||||
|
||||
Useful for installing OS packages, setting environment variables, etc.
|
||||
Example:
|
||||
dockerfile_lines=[
|
||||
"RUN apt-get update && apt-get install -y libmagic-dev",
|
||||
"ENV MY_CUSTOM_VAR=hello_world"
|
||||
]
|
||||
"""
|
||||
|
||||
dependencies: list[str]
|
||||
"""List of Python dependencies to install, either from PyPI or local paths.
|
||||
|
||||
Examples:
|
||||
- "." or "./src" if you have a local Python package
|
||||
- str (aka "anthropic") for a PyPI package
|
||||
- "git+https://github.com/org/repo.git@main" for a Git-based package
|
||||
Defaults to an empty list, meaning no additional packages installed beyond your base environment.
|
||||
|
||||
This field is not supported when `source.kind` is `uv`.
|
||||
"""
|
||||
|
||||
graphs: dict[str, str | GraphDef]
|
||||
"""Optional. Named definitions of graphs, each pointing to a Python object.
|
||||
|
||||
|
||||
Graphs can be StateGraph, @entrypoint, or any other Pregel object OR they can point to (async) context
|
||||
managers that accept a single configuration argument (of type RunnableConfig) and return a pregel object
|
||||
(instance of Stategraph, etc.).
|
||||
|
||||
Keys are graph names, values are either "path/to/file.py:object_name" strings
|
||||
or objects with a "path" key and optional "description" key.
|
||||
Example:
|
||||
{
|
||||
"mygraph": "graphs/my_graph.py:graph_definition",
|
||||
"anothergraph": {
|
||||
"path": "graphs/another.py:get_graph",
|
||||
"description": "A graph that does X"
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
env: dict[str, str] | str
|
||||
"""Optional. Environment variables to set for your deployment.
|
||||
|
||||
- If given as a dict, keys are variable names and values are their values.
|
||||
- If given as a string, it must be a path to a file containing lines in KEY=VALUE format.
|
||||
|
||||
Example as a dict:
|
||||
env={"API_TOKEN": "abc123", "DEBUG": "true"}
|
||||
Example as a file path:
|
||||
env=".env"
|
||||
"""
|
||||
|
||||
store: StoreConfig | None
|
||||
"""Optional. Configuration for the built-in long-term memory store, including semantic search indexing.
|
||||
|
||||
If omitted, no vector index is set up (the object store will still be present, however).
|
||||
"""
|
||||
|
||||
checkpointer: CheckpointerConfig | None
|
||||
"""Optional. Configuration for the built-in checkpointer, which handles checkpointing of state.
|
||||
|
||||
If omitted, no checkpointer is set up (the object store will still be present, however).
|
||||
"""
|
||||
|
||||
auth: AuthConfig | None
|
||||
"""Optional. Custom authentication config, including the path to your Python auth logic and
|
||||
the OpenAPI security definitions it uses.
|
||||
"""
|
||||
|
||||
encryption: EncryptionConfig | None
|
||||
"""Optional. Custom at-rest encryption config, including the path to your Python encryption logic.
|
||||
|
||||
Allows you to implement custom encryption for sensitive data stored in the database.
|
||||
"""
|
||||
|
||||
http: HttpConfig | None
|
||||
"""Optional. Configuration for the built-in HTTP server, controlling which custom routes are exposed
|
||||
and how cross-origin requests are handled.
|
||||
"""
|
||||
|
||||
webhooks: WebhooksConfig | None
|
||||
"""Optional. Webhooks configuration for outbound event delivery.
|
||||
|
||||
Forwarded into the container as `LANGGRAPH_WEBHOOKS`. See `WebhooksConfig`
|
||||
for URL policy and header templating details.
|
||||
"""
|
||||
|
||||
ui: dict[str, str] | None
|
||||
"""Optional. Named definitions of UI components emitted by the agent, each pointing to a JS/TS file.
|
||||
"""
|
||||
|
||||
keep_pkg_tools: bool | list[str] | None
|
||||
"""Optional. Control whether to retain Python packaging tools in the final image.
|
||||
|
||||
Allowed tools are: "pip", "setuptools", "wheel".
|
||||
You can also set to true to include all packaging tools.
|
||||
"""
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Config",
|
||||
"GraphDef",
|
||||
"StoreConfig",
|
||||
"CheckpointerConfig",
|
||||
"AuthConfig",
|
||||
"EncryptionConfig",
|
||||
"HttpConfig",
|
||||
"MiddlewareOrders",
|
||||
"Distros",
|
||||
"TTLConfig",
|
||||
"IndexConfig",
|
||||
]
|
||||
@@ -0,0 +1,186 @@
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from io import BytesIO
|
||||
from urllib import error, request
|
||||
from zipfile import ZipFile
|
||||
|
||||
import click
|
||||
|
||||
TEMPLATES: dict[str, dict[str, str]] = {
|
||||
"Deep Agent": {
|
||||
"description": "An opinionated deployment template for a Deep Agent.",
|
||||
"python": "https://github.com/langchain-ai/deep-agent-template/archive/refs/heads/main.zip",
|
||||
"js": "https://github.com/langchain-ai/deep-agent-template-js/archive/refs/heads/main.zip",
|
||||
},
|
||||
"Agent": {
|
||||
"description": "A simple agent that can be flexibly extended to many tools.",
|
||||
"python": "https://github.com/langchain-ai/simple-agent-template/archive/refs/heads/main.zip",
|
||||
},
|
||||
"New LangGraph Project": {
|
||||
"description": "A simple, minimal chatbot with memory.",
|
||||
"python": "https://github.com/langchain-ai/new-langgraph-project/archive/refs/heads/main.zip",
|
||||
"js": "https://github.com/langchain-ai/new-langgraphjs-project/archive/refs/heads/main.zip",
|
||||
},
|
||||
}
|
||||
|
||||
# Generate TEMPLATE_IDS programmatically
|
||||
TEMPLATE_ID_TO_CONFIG = {
|
||||
f"{name.lower().replace(' ', '-')}-{lang}": (name, lang, url)
|
||||
for name, versions in TEMPLATES.items()
|
||||
for lang, url in versions.items()
|
||||
if lang in {"python", "js"}
|
||||
}
|
||||
|
||||
TEMPLATE_IDS = list(TEMPLATE_ID_TO_CONFIG.keys())
|
||||
|
||||
TEMPLATE_HELP_STRING = (
|
||||
"The name of the template to use. Available options:\n"
|
||||
+ "\n".join(f"{id_}" for id_ in TEMPLATE_ID_TO_CONFIG)
|
||||
)
|
||||
|
||||
|
||||
def _choose_template() -> str:
|
||||
"""Presents a list of templates to the user and prompts them to select one.
|
||||
|
||||
Returns:
|
||||
str: The URL of the selected template.
|
||||
"""
|
||||
click.secho("🌟 Please select a template:", bold=True, fg="yellow")
|
||||
for idx, (template_name, template_info) in enumerate(TEMPLATES.items(), 1):
|
||||
click.secho(f"{idx}. ", nl=False, fg="cyan")
|
||||
click.secho(template_name, fg="cyan", nl=False)
|
||||
click.secho(f" - {template_info['description']}", fg="white")
|
||||
|
||||
# Get the template choice from the user, defaulting to the first template if blank
|
||||
template_choice: int | None = click.prompt(
|
||||
"Enter the number of your template choice (default is 1)",
|
||||
type=int,
|
||||
default=1,
|
||||
show_default=False,
|
||||
)
|
||||
|
||||
template_keys = list(TEMPLATES.keys())
|
||||
if 1 <= template_choice <= len(template_keys):
|
||||
selected_template: str = template_keys[template_choice - 1]
|
||||
else:
|
||||
click.secho("❌ Invalid choice. Please try again.", fg="red")
|
||||
return _choose_template()
|
||||
|
||||
template_info = TEMPLATES[selected_template]
|
||||
available_langs = [lang for lang in ("python", "js") if lang in template_info]
|
||||
|
||||
click.secho(
|
||||
f"\nYou selected: {selected_template} - {template_info['description']}",
|
||||
fg="green",
|
||||
)
|
||||
|
||||
if len(available_langs) == 1:
|
||||
return template_info[available_langs[0]]
|
||||
|
||||
version_choice: int = click.prompt(
|
||||
"Choose language (1 for Python 🐍, 2 for JS/TS 🌐)", type=int
|
||||
)
|
||||
|
||||
if version_choice == 1:
|
||||
return template_info["python"]
|
||||
elif version_choice == 2:
|
||||
return template_info["js"]
|
||||
else:
|
||||
click.secho("❌ Invalid choice. Please try again.", fg="red")
|
||||
return _choose_template()
|
||||
|
||||
|
||||
def _download_repo_with_requests(repo_url: str, path: str) -> None:
|
||||
"""Download a ZIP archive from the given URL and extracts it to the specified path.
|
||||
|
||||
Args:
|
||||
repo_url: The URL of the repository to download.
|
||||
path: The path where the repository should be extracted.
|
||||
"""
|
||||
click.secho("📥 Attempting to download repository as a ZIP archive...", fg="yellow")
|
||||
click.secho(f"URL: {repo_url}", fg="yellow")
|
||||
try:
|
||||
with request.urlopen(repo_url) as response:
|
||||
if response.status == 200:
|
||||
with ZipFile(BytesIO(response.read())) as zip_file:
|
||||
zip_file.extractall(path)
|
||||
# Move extracted contents to path
|
||||
for item in os.listdir(path):
|
||||
if item.endswith("-main"):
|
||||
extracted_dir = os.path.join(path, item)
|
||||
for filename in os.listdir(extracted_dir):
|
||||
shutil.move(os.path.join(extracted_dir, filename), path)
|
||||
shutil.rmtree(extracted_dir)
|
||||
click.secho(
|
||||
f"✅ Downloaded and extracted repository to {path}", fg="green"
|
||||
)
|
||||
except error.HTTPError as e:
|
||||
click.secho(
|
||||
f"❌ Error: Failed to download repository.\nDetails: {e}\n",
|
||||
fg="red",
|
||||
bold=True,
|
||||
err=True,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def create_new(path: str | None, template: str | None) -> None:
|
||||
"""Create a new LangGraph project at the specified PATH using the chosen TEMPLATE.
|
||||
|
||||
Args:
|
||||
path: The path where the new project will be created.
|
||||
template: The name of the template to use.
|
||||
"""
|
||||
# Prompt for path if not provided
|
||||
if not path:
|
||||
path = click.prompt(
|
||||
"📂 Please specify the path to create the application", default="."
|
||||
)
|
||||
|
||||
path = os.path.abspath(path) # Ensure path is absolute
|
||||
|
||||
# Check if path exists and is not empty
|
||||
if os.path.exists(path) and os.listdir(path):
|
||||
click.secho(
|
||||
"❌ The specified directory already exists and is not empty. "
|
||||
"Aborting to prevent overwriting files.",
|
||||
fg="red",
|
||||
bold=True,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# Get template URL either from command-line argument or
|
||||
# through interactive selection
|
||||
if template:
|
||||
if template not in TEMPLATE_ID_TO_CONFIG:
|
||||
# Format available options in a readable way with descriptions
|
||||
template_options = ""
|
||||
for id_ in TEMPLATE_IDS:
|
||||
name, lang, _ = TEMPLATE_ID_TO_CONFIG[id_]
|
||||
description = TEMPLATES[name]["description"]
|
||||
|
||||
# Add each template option with color formatting
|
||||
template_options += (
|
||||
click.style("- ", fg="yellow", bold=True)
|
||||
+ click.style(f"{id_}", fg="cyan")
|
||||
+ click.style(f": {description}", fg="white")
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
# Display error message with colors and formatting
|
||||
click.secho("❌ Error:", fg="red", bold=True, nl=False)
|
||||
click.secho(f" Template '{template}' not found.", fg="red")
|
||||
click.secho(
|
||||
"Please select from the available options:\n", fg="yellow", bold=True
|
||||
)
|
||||
click.secho(template_options, fg="cyan")
|
||||
sys.exit(1)
|
||||
_, _, template_url = TEMPLATE_ID_TO_CONFIG[template]
|
||||
else:
|
||||
template_url = _choose_template()
|
||||
|
||||
# Download and extract the template
|
||||
_download_repo_with_requests(template_url, path)
|
||||
|
||||
click.secho(f"🎉 New project created at {path}", fg="green", bold=True)
|
||||
@@ -0,0 +1,50 @@
|
||||
"""General-purpose utilities shared across the LangGraph CLI."""
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
import click
|
||||
|
||||
|
||||
def clean_empty_lines(input_str: str):
|
||||
return "\n".join(filter(None, input_str.splitlines()))
|
||||
|
||||
|
||||
def warn_non_wolfi_distro(
|
||||
config_json: dict,
|
||||
*,
|
||||
emit: Callable[[str], None] | None = None,
|
||||
) -> None:
|
||||
"""Show warning if image_distro is not set to 'wolfi'.
|
||||
|
||||
When ``emit`` is provided, each warning line is sent through it (used by
|
||||
callers that need JSON-aware output). Otherwise falls back to colored
|
||||
``click.secho`` output.
|
||||
"""
|
||||
image_distro = config_json.get("image_distro", "debian") # Default is debian
|
||||
if image_distro == "wolfi":
|
||||
return
|
||||
if emit is not None:
|
||||
emit(
|
||||
"⚠️ Security Recommendation: Consider switching to Wolfi Linux for enhanced security."
|
||||
)
|
||||
emit(
|
||||
" Wolfi is a security-oriented, minimal Linux distribution designed for containers."
|
||||
)
|
||||
emit(
|
||||
' To switch, add \'"image_distro": "wolfi"\' to your langgraph.json config file.'
|
||||
)
|
||||
return
|
||||
click.secho(
|
||||
"⚠️ Security Recommendation: Consider switching to Wolfi Linux for enhanced security.",
|
||||
fg="yellow",
|
||||
bold=True,
|
||||
)
|
||||
click.secho(
|
||||
" Wolfi is a security-oriented, minimal Linux distribution designed for containers.",
|
||||
fg="yellow",
|
||||
)
|
||||
click.secho(
|
||||
' To switch, add \'"image_distro": "wolfi"\' to your langgraph.json config file.',
|
||||
fg="yellow",
|
||||
)
|
||||
click.secho("") # Empty line for better readability
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
||||
"""Main entrypoint into package."""
|
||||
|
||||
from importlib import metadata
|
||||
|
||||
try:
|
||||
__version__ = metadata.version(__package__)
|
||||
except metadata.PackageNotFoundError:
|
||||
# Case where package metadata is not available.
|
||||
__version__ = ""
|
||||
del metadata # optional, avoids polluting the results of dir(__package__)
|
||||
Reference in New Issue
Block a user