mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-08-27 01:52:25 +02:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9dc75105f9 | ||
|
|
97dd3903af |
@@ -1 +1 @@
|
||||
__version__ = "0.4.19"
|
||||
__version__ = "0.4.18"
|
||||
|
||||
@@ -31,11 +31,7 @@ from langgraph_cli.helpers import format_log_entry, level_fg, resolve_deployment
|
||||
from langgraph_cli.host_backend import HostBackendClient, HostBackendError
|
||||
from langgraph_cli.progress import Progress
|
||||
from langgraph_cli.templates import TEMPLATE_HELP_STRING, create_new
|
||||
from langgraph_cli.util import (
|
||||
format_deployments_table,
|
||||
format_revisions_table,
|
||||
warn_non_wolfi_distro,
|
||||
)
|
||||
from langgraph_cli.util import format_deployments_table, warn_non_wolfi_distro
|
||||
from langgraph_cli.version import __version__
|
||||
|
||||
RESERVED_ENV_VARS = frozenset(
|
||||
@@ -335,25 +331,25 @@ class NestedHelpGroup(click.Group):
|
||||
self, ctx: click.Context, formatter: click.HelpFormatter
|
||||
) -> None:
|
||||
command_entries: list[tuple[str, click.Command]] = []
|
||||
|
||||
def collect_commands(
|
||||
group: click.Group, parent_ctx: click.Context, prefix: str = ""
|
||||
) -> None:
|
||||
for command_name in group.list_commands(parent_ctx):
|
||||
command = group.get_command(parent_ctx, command_name)
|
||||
if command is None or command.hidden:
|
||||
continue
|
||||
qualified_name = f"{prefix} {command_name}" if prefix else command_name
|
||||
command_entries.append((qualified_name, command))
|
||||
if isinstance(command, click.Group):
|
||||
sub_ctx = click.Context(
|
||||
command,
|
||||
info_name=qualified_name,
|
||||
parent=parent_ctx,
|
||||
# Collect the top-level commands first, then append one level of nested
|
||||
# subcommands using names like "deploy list" so they show up in the
|
||||
# top-level help output.
|
||||
for command_name in self.list_commands(ctx):
|
||||
command = self.get_command(ctx, command_name)
|
||||
if command is None or command.hidden:
|
||||
continue
|
||||
command_entries.append((command_name, command))
|
||||
if isinstance(command, click.Group):
|
||||
# Build a child context so Click resolves the subcommands the same
|
||||
# way it would for the nested group itself.
|
||||
sub_ctx = click.Context(command, info_name=command_name, parent=ctx)
|
||||
for subcommand_name in command.list_commands(sub_ctx):
|
||||
subcommand = command.get_command(sub_ctx, subcommand_name)
|
||||
if subcommand is None or subcommand.hidden:
|
||||
continue
|
||||
command_entries.append(
|
||||
(f"{command_name} {subcommand_name}", subcommand)
|
||||
)
|
||||
collect_commands(command, sub_ctx, qualified_name)
|
||||
|
||||
collect_commands(self, ctx)
|
||||
|
||||
# Compute the available width for help text up front so we can truncate
|
||||
# descriptions before handing them to Click. That keeps each command on
|
||||
@@ -377,30 +373,13 @@ class DeployGroup(NestedHelpGroup):
|
||||
"""Group that treats leading '-' args as passthrough docker flags."""
|
||||
|
||||
def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]:
|
||||
"""Treat leading option-like subcommand tokens as passthrough args.
|
||||
|
||||
Click stores the unresolved nested command token on the context after
|
||||
``Group.parse_args()`` runs, but the backing attribute changed across
|
||||
supported Click versions. Click 8.1.x stores the value directly on
|
||||
``protected_args``, while Click 8.2+ stores it on ``_protected_args``
|
||||
and exposes ``protected_args`` as a deprecated compatibility property.
|
||||
Since this package allows ``click>=8.1.7``, we need to check both
|
||||
names to support the full version range without relying on one
|
||||
version-specific internal detail.
|
||||
"""
|
||||
result = super().parse_args(ctx, args)
|
||||
protected_args = ctx.__dict__.get("protected_args")
|
||||
if protected_args is None:
|
||||
protected_args = ctx.__dict__.get("_protected_args", [])
|
||||
if protected_args and protected_args[0].startswith("-"):
|
||||
if ctx._protected_args and ctx._protected_args[0].startswith("-"):
|
||||
# Click stores the would-be subcommand in _protected_args; if it looks
|
||||
# like an option (e.g. --build-arg) treat it as passthrough docker
|
||||
# args instead of insisting on a nested command.
|
||||
ctx.args = [*protected_args, *ctx.args]
|
||||
if "protected_args" in ctx.__dict__:
|
||||
ctx.protected_args = []
|
||||
elif "_protected_args" in ctx.__dict__:
|
||||
ctx._protected_args = []
|
||||
ctx.args = [*ctx._protected_args, *ctx.args]
|
||||
ctx._protected_args = []
|
||||
return ctx.args
|
||||
return result
|
||||
|
||||
@@ -809,13 +788,6 @@ def deploy(ctx: click.Context, **_: object):
|
||||
return ctx.forward(_deploy, docker_build_args=docker_build_args)
|
||||
|
||||
|
||||
@deploy.group(
|
||||
"revisions", cls=NestedHelpGroup, help="[Beta] Manage deployment revisions."
|
||||
)
|
||||
def deploy_revisions() -> None:
|
||||
pass
|
||||
|
||||
|
||||
@_deploy_base_options()
|
||||
@click.command(context_settings=dict(ignore_unknown_options=True))
|
||||
def _deploy(
|
||||
@@ -1253,39 +1225,6 @@ def deploy_list(api_key: str | None, host_url: str | None, name_contains: str) -
|
||||
click.echo(format_deployments_table(deployments))
|
||||
|
||||
|
||||
@OPT_HOST_API_KEY
|
||||
@OPT_HOST_URL
|
||||
@click.option(
|
||||
"--limit",
|
||||
type=int,
|
||||
default=10,
|
||||
show_default=True,
|
||||
help="Maximum number of revisions to return.",
|
||||
)
|
||||
@click.argument("deployment_id")
|
||||
@deploy_revisions.command(
|
||||
"list",
|
||||
help=(
|
||||
"[Beta] List revisions for a LangSmith Deployment.\n\n"
|
||||
"Use the `deploy list` command to list deployment IDs."
|
||||
),
|
||||
)
|
||||
def deploy_revisions_list(
|
||||
api_key: str | None, host_url: str | None, limit: int, deployment_id: str
|
||||
) -> None:
|
||||
client = _create_host_backend_client(host_url, api_key)
|
||||
response = _call_host_backend_with_optional_tenant(
|
||||
client,
|
||||
lambda c: c.list_revisions(deployment_id, limit=limit),
|
||||
)
|
||||
resources = response.get("resources", []) if isinstance(response, dict) else []
|
||||
revisions = [item for item in resources if isinstance(item, dict)]
|
||||
if not revisions:
|
||||
click.echo(f"No revisions found for deployment {deployment_id}.")
|
||||
return
|
||||
click.echo(format_revisions_table(revisions))
|
||||
|
||||
|
||||
@OPT_HOST_API_KEY
|
||||
@OPT_HOST_URL
|
||||
@click.option(
|
||||
|
||||
@@ -287,18 +287,6 @@ def validate_config(config: Config) -> Config:
|
||||
f"Invalid http.app format: '{http_conf['app']}'. "
|
||||
"Must be in format './path/to/file.py:attribute_name'"
|
||||
)
|
||||
if "apps" in http_conf and http_conf["apps"]:
|
||||
for prefix, app_path in http_conf["apps"].items():
|
||||
if not prefix.startswith("/"):
|
||||
raise ValueError(
|
||||
f"Invalid http.apps prefix: '{prefix}'. Must start with '/'"
|
||||
)
|
||||
if ":" not in app_path:
|
||||
raise ValueError(
|
||||
f"Invalid http.apps format for prefix '{prefix}': '{app_path}'. "
|
||||
"Must be in format './path/to/file:attribute_name' "
|
||||
"or 'package:attribute_name'"
|
||||
)
|
||||
if keep_pkg_tools := config.get("keep_pkg_tools"):
|
||||
if isinstance(keep_pkg_tools, list):
|
||||
for tool in keep_pkg_tools:
|
||||
@@ -819,94 +807,6 @@ def _update_http_app_path(
|
||||
http_config["app"] = f"{module_str}:{attr_str}"
|
||||
|
||||
|
||||
_JS_EXTENSIONS = frozenset({".js", ".ts", ".mts", ".mjs", ".cts", ".cjs"})
|
||||
|
||||
|
||||
def _is_js_http_app(app_path: str) -> bool:
|
||||
"""Check if an http.apps entry is a JavaScript/TypeScript app or npm package."""
|
||||
module_str = app_path.split(":")[0]
|
||||
ext = os.path.splitext(module_str)[1]
|
||||
if ext:
|
||||
return ext in _JS_EXTENSIONS
|
||||
# No extension and not a file path → npm package reference (JS)
|
||||
if not module_str.startswith((".", "/")):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _has_js_http_apps(config: Config) -> bool:
|
||||
"""Check if any http.apps entries are JavaScript apps."""
|
||||
http_config = config.get("http")
|
||||
if not http_config:
|
||||
return False
|
||||
apps = http_config.get("apps")
|
||||
if not apps:
|
||||
return False
|
||||
return any(_is_js_http_app(app_path) for app_path in apps.values())
|
||||
|
||||
|
||||
def _update_http_apps_paths(
|
||||
config_path: pathlib.Path, config: Config, local_deps: LocalDeps
|
||||
) -> None:
|
||||
"""Update HTTP apps paths to point to the correct location in the Docker container.
|
||||
|
||||
Similar to _update_http_app_path, but handles the `http.apps` dictionary
|
||||
which maps path prefixes to app module paths. Python app paths are rewritten
|
||||
to container paths; npm package references are left unchanged.
|
||||
"""
|
||||
if not (http_config := config.get("http")) or not (apps := http_config.get("apps")):
|
||||
return
|
||||
|
||||
for prefix, app_str in apps.items():
|
||||
module_str, _, attr_str = app_str.partition(":")
|
||||
if not module_str or not attr_str:
|
||||
message = (
|
||||
'Import string "{import_str}" must be in format "<module>:<attribute>".'
|
||||
)
|
||||
raise ValueError(message.format(import_str=app_str))
|
||||
|
||||
# Skip npm package references (bare module names without path separators)
|
||||
if (
|
||||
not module_str.startswith((".", "/"))
|
||||
and not os.path.splitext(module_str)[1]
|
||||
):
|
||||
continue
|
||||
|
||||
if "/" in module_str or "\\" in module_str:
|
||||
resolved = (config_path.parent / module_str).resolve()
|
||||
if not resolved.exists():
|
||||
raise FileNotFoundError(
|
||||
f"Could not find HTTP app module for prefix '{prefix}': {resolved}"
|
||||
)
|
||||
elif not resolved.is_file():
|
||||
raise IsADirectoryError(
|
||||
f"HTTP app module must be a file for prefix '{prefix}': {resolved}"
|
||||
)
|
||||
else:
|
||||
for path in local_deps.real_pkgs:
|
||||
if resolved.is_relative_to(path):
|
||||
container_path = (
|
||||
pathlib.Path("/deps")
|
||||
/ path.name
|
||||
/ resolved.relative_to(path)
|
||||
)
|
||||
module_str = container_path.as_posix()
|
||||
break
|
||||
else:
|
||||
for faux_pkg, (_, destpath) in local_deps.faux_pkgs.items():
|
||||
if resolved.is_relative_to(faux_pkg):
|
||||
container_subpath = resolved.relative_to(faux_pkg)
|
||||
module_str = f"{destpath}/{container_subpath.as_posix()}"
|
||||
break
|
||||
else:
|
||||
raise ValueError(
|
||||
f"HTTP app module '{app_str}' for prefix '{prefix}' "
|
||||
"not found in 'dependencies' list. "
|
||||
"Add its containing package to 'dependencies' list."
|
||||
)
|
||||
apps[prefix] = f"{module_str}:{attr_str}"
|
||||
|
||||
|
||||
def _get_node_pm_install_cmd(config_path: pathlib.Path, config: Config) -> str:
|
||||
def test_file(file_name):
|
||||
full_path = config_path.parent / file_name
|
||||
@@ -1057,8 +957,6 @@ def python_config_to_docker(
|
||||
_update_checkpointer_path(config_path, config, local_deps)
|
||||
# Rewrite HTTP app path, so it points to the correct location in the Docker container
|
||||
_update_http_app_path(config_path, config, local_deps)
|
||||
# Rewrite HTTP apps paths, so they point to the correct location in the Docker container
|
||||
_update_http_apps_paths(config_path, config, local_deps)
|
||||
|
||||
pip_pkgs_str = (
|
||||
f"RUN {local_reqs_pip_install} {' '.join(pypi_deps)}" if pypi_deps else ""
|
||||
@@ -1119,11 +1017,9 @@ ADD {relpath} /deps/{name}
|
||||
for fullpath, (relpath, name) in local_deps.real_pkgs.items()
|
||||
)
|
||||
|
||||
has_js_apps = _has_js_http_apps(config)
|
||||
install_node_str: str = (
|
||||
"RUN /storage/install-node.sh"
|
||||
if ((config.get("ui") or config.get("node_version")) and local_deps.working_dir)
|
||||
or has_js_apps
|
||||
if (config.get("ui") or config.get("node_version")) and local_deps.working_dir
|
||||
else ""
|
||||
)
|
||||
|
||||
@@ -1182,10 +1078,6 @@ ADD {relpath} /deps/{name}
|
||||
"# -- End of JS dependencies install --",
|
||||
]
|
||||
)
|
||||
elif has_js_apps:
|
||||
js_inst_str = (
|
||||
f"ENV NODE_VERSION={config.get('node_version') or DEFAULT_NODE_VERSION}"
|
||||
)
|
||||
image_str = docker_tag(config, base_image, api_version)
|
||||
|
||||
# Prepare docker file contents
|
||||
|
||||
@@ -446,25 +446,6 @@ class HttpConfig(TypedDict, total=False):
|
||||
Format: "path/to/module.py:app_var"
|
||||
If provided, it can override or extend the default routes.
|
||||
"""
|
||||
apps: dict[str, str] | None
|
||||
"""Optional. Record mapping path prefix to app module path.
|
||||
|
||||
Each key is a URL path prefix (e.g., "/dashboard"), and each value is an
|
||||
import path in "file:export" format (e.g., "./dashboard.py:app").
|
||||
|
||||
Python apps (.py) are mounted in-process as FastAPI/Starlette sub-applications.
|
||||
JS apps (.js, .ts, .mts, .mjs) are spawned as Node.js subprocess servers
|
||||
with a reverse proxy forwarding requests from the main server.
|
||||
npm package references (paths not starting with . or /) are resolved as
|
||||
npm imports in the JS subprocess.
|
||||
|
||||
Example:
|
||||
{
|
||||
"/dashboard": "./dashboard.py:app",
|
||||
"/ext": "./extension.js:app",
|
||||
"/conduit": "some-npm-package:app"
|
||||
}
|
||||
"""
|
||||
disable_assistants: bool
|
||||
"""Optional. If `True`, /assistants routes are removed from the server.
|
||||
|
||||
|
||||
@@ -57,35 +57,3 @@ def format_deployments_table(deployments: Sequence[dict[str, object]]) -> str:
|
||||
lines = [format_row(headers), format_row(tuple("-" * width for width in widths))]
|
||||
lines.extend(format_row(row) for row in rows)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_revisions_table(revisions: Sequence[dict[str, object]]) -> str:
|
||||
headers = ("Revision ID", "Status", "Created At")
|
||||
latest_deployed_seen = False
|
||||
rows = []
|
||||
for revision in revisions:
|
||||
status = str(revision.get("status", "-") or "-")
|
||||
if status == "DEPLOYED":
|
||||
if latest_deployed_seen:
|
||||
status = "REPLACED"
|
||||
else:
|
||||
latest_deployed_seen = True
|
||||
rows.append(
|
||||
(
|
||||
str(revision.get("id", "-") or "-"),
|
||||
status,
|
||||
str(revision.get("created_at", "-") or "-"),
|
||||
)
|
||||
)
|
||||
|
||||
widths = [
|
||||
max(len(headers[index]), *(len(row[index]) for row in rows))
|
||||
for index in range(len(headers))
|
||||
]
|
||||
|
||||
def format_row(row: Sequence[str]) -> str:
|
||||
return " ".join(value.ljust(widths[index]) for index, value in enumerate(row))
|
||||
|
||||
lines = [format_row(headers), format_row(tuple("-" * width for width in widths))]
|
||||
lines.extend(format_row(row) for row in rows)
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -687,7 +687,7 @@
|
||||
},
|
||||
"EncryptionConfig": {
|
||||
"title": "EncryptionConfig",
|
||||
"description": "Configuration for custom at-rest encryption logic.\n\n Allows you to implement custom encryption for sensitive data stored in the database,\n including metadata fields and checkpoint blobs.",
|
||||
"description": "Configuration for custom at-rest encryption logic.\n\nAllows you to implement custom encryption for sensitive data stored in the database,\nincluding metadata fields and checkpoint blobs.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
@@ -728,20 +728,6 @@
|
||||
"type": "string",
|
||||
"description": "Optional. Import path to a custom Starlette/FastAPI application to mount.\n"
|
||||
},
|
||||
"apps": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional. Record mapping path prefix to app module path.\n\nEach key is a URL path prefix (e.g., \"/dashboard\"), and each value is an\n\nPython apps (.py) are mounted in-process as FastAPI/Starlette sub-applications.\nJS apps (.js, .ts, .mts, .mjs) are spawned as Node.js subprocess servers\nwith a reverse proxy forwarding requests from the main server.\nnpm package references (paths not starting with . or /) are resolved as\nnpm imports in the JS subprocess.\n"
|
||||
},
|
||||
"configurable_headers": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
||||
@@ -687,7 +687,7 @@
|
||||
},
|
||||
"EncryptionConfig": {
|
||||
"title": "EncryptionConfig",
|
||||
"description": "Configuration for custom at-rest encryption logic.\n\n Allows you to implement custom encryption for sensitive data stored in the database,\n including metadata fields and checkpoint blobs.",
|
||||
"description": "Configuration for custom at-rest encryption logic.\n\nAllows you to implement custom encryption for sensitive data stored in the database,\nincluding metadata fields and checkpoint blobs.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
@@ -728,20 +728,6 @@
|
||||
"type": "string",
|
||||
"description": "Optional. Import path to a custom Starlette/FastAPI application to mount.\n"
|
||||
},
|
||||
"apps": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional. Record mapping path prefix to app module path.\n\nEach key is a URL path prefix (e.g., \"/dashboard\"), and each value is an\n\nPython apps (.py) are mounted in-process as FastAPI/Starlette sub-applications.\nJS apps (.js, .ts, .mts, .mjs) are spawned as Node.js subprocess servers\nwith a reverse proxy forwarding requests from the main server.\nnpm package references (paths not starting with . or /) are resolved as\nnpm imports in the JS subprocess.\n"
|
||||
},
|
||||
"configurable_headers": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
||||
@@ -297,8 +297,6 @@ def test_top_level_help_shows_deploy_subcommands() -> None:
|
||||
assert "deploy" in result.output
|
||||
assert "deploy list" in result.output
|
||||
assert "deploy delete" in result.output
|
||||
assert "deploy revisions" in result.output
|
||||
assert "deploy revisions list" in result.output
|
||||
assert "[Beta] List LangSmith Deployments." in result.output
|
||||
|
||||
|
||||
@@ -404,140 +402,6 @@ def test_deploy_list_command_no_results(monkeypatch) -> None:
|
||||
assert result.output.strip() == "No deployments found."
|
||||
|
||||
|
||||
def test_deploy_revisions_list_command(monkeypatch) -> None:
|
||||
runner = CliRunner()
|
||||
captured: dict[str, str] = {}
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, host_url: str, api_key: str, tenant_id: str | None = None):
|
||||
captured["host_url"] = host_url
|
||||
captured["api_key"] = api_key
|
||||
captured["tenant_id"] = tenant_id or ""
|
||||
|
||||
def list_revisions(self, deployment_id: str, limit: int = 1):
|
||||
captured["deployment_id"] = deployment_id
|
||||
captured["limit"] = str(limit)
|
||||
return {
|
||||
"resources": [
|
||||
{
|
||||
"id": "rev-123",
|
||||
"status": "CREATING",
|
||||
"created_at": "2023-11-07T05:31:56Z",
|
||||
},
|
||||
{
|
||||
"id": "rev-456",
|
||||
"status": "DEPLOYED",
|
||||
"created_at": "2023-11-08T10:00:00Z",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
monkeypatch.setattr(cli_module, "HostBackendClient", FakeClient)
|
||||
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"deploy",
|
||||
"revisions",
|
||||
"list",
|
||||
"--api-key",
|
||||
"test-key",
|
||||
"--host-url",
|
||||
"https://api.example.com",
|
||||
"dep-123",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert captured == {
|
||||
"host_url": "https://api.example.com",
|
||||
"api_key": "test-key",
|
||||
"tenant_id": "",
|
||||
"deployment_id": "dep-123",
|
||||
"limit": "10",
|
||||
}
|
||||
assert "Revision ID" in result.output
|
||||
assert "Status" in result.output
|
||||
assert "Created At" in result.output
|
||||
assert "rev-123" in result.output
|
||||
assert "2023-11-08T10:00:00Z" in result.output
|
||||
|
||||
|
||||
def test_deploy_revisions_list_command_no_results(monkeypatch) -> None:
|
||||
runner = CliRunner()
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, host_url: str, api_key: str, tenant_id: str | None = None):
|
||||
pass
|
||||
|
||||
def list_revisions(self, deployment_id: str, limit: int = 1):
|
||||
return {"resources": []}
|
||||
|
||||
monkeypatch.setattr(cli_module, "HostBackendClient", FakeClient)
|
||||
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"deploy",
|
||||
"revisions",
|
||||
"list",
|
||||
"--api-key",
|
||||
"test-key",
|
||||
"--host-url",
|
||||
"https://api.example.com",
|
||||
"dep-123",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert result.output.strip() == "No revisions found for deployment dep-123."
|
||||
|
||||
|
||||
def test_deploy_revisions_list_command_with_explicit_limit(monkeypatch) -> None:
|
||||
runner = CliRunner()
|
||||
captured: dict[str, str] = {}
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, host_url: str, api_key: str, tenant_id: str | None = None):
|
||||
pass
|
||||
|
||||
def list_revisions(self, deployment_id: str, limit: int = 1):
|
||||
captured["deployment_id"] = deployment_id
|
||||
captured["limit"] = str(limit)
|
||||
return {"resources": []}
|
||||
|
||||
monkeypatch.setattr(cli_module, "HostBackendClient", FakeClient)
|
||||
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"deploy",
|
||||
"revisions",
|
||||
"list",
|
||||
"--api-key",
|
||||
"test-key",
|
||||
"--host-url",
|
||||
"https://api.example.com",
|
||||
"--limit",
|
||||
"25",
|
||||
"dep-123",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert captured == {"deployment_id": "dep-123", "limit": "25"}
|
||||
assert result.output.strip() == "No revisions found for deployment dep-123."
|
||||
|
||||
|
||||
def test_deploy_revisions_list_missing_deployment_id_shows_usage() -> None:
|
||||
runner = CliRunner()
|
||||
|
||||
result = runner.invoke(cli, ["deploy", "revisions", "list"])
|
||||
|
||||
assert result.exit_code == 2, result.output
|
||||
assert "Missing argument 'DEPLOYMENT_ID'" in result.output
|
||||
|
||||
|
||||
def test_deploy_delete_command(monkeypatch) -> None:
|
||||
runner = CliRunner()
|
||||
captured: dict[str, str] = {}
|
||||
|
||||
@@ -11,8 +11,6 @@ import pytest
|
||||
from langgraph_cli.config import (
|
||||
_BUILD_TOOLS,
|
||||
_get_pip_cleanup_lines,
|
||||
_has_js_http_apps,
|
||||
_is_js_http_app,
|
||||
config_to_compose,
|
||||
config_to_docker,
|
||||
default_base_image,
|
||||
@@ -152,148 +150,6 @@ def test_validate_config():
|
||||
)
|
||||
|
||||
|
||||
def test_validate_config_http_apps():
|
||||
"""Test validation of http.apps field."""
|
||||
# Valid http.apps config
|
||||
config = validate_config(
|
||||
{
|
||||
"python_version": "3.12",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"http": {
|
||||
"apps": {
|
||||
"/dashboard": "./dashboard.py:app",
|
||||
"/ext": "./extension.js:app",
|
||||
"/conduit": "some-npm-package:app",
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
assert config["http"]["apps"] == {
|
||||
"/dashboard": "./dashboard.py:app",
|
||||
"/ext": "./extension.js:app",
|
||||
"/conduit": "some-npm-package:app",
|
||||
}
|
||||
|
||||
# Valid with both app and apps
|
||||
config = validate_config(
|
||||
{
|
||||
"python_version": "3.12",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"http": {
|
||||
"app": "./custom_routes.py:app",
|
||||
"apps": {
|
||||
"/dashboard": "./dashboard.py:app",
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
assert config["http"]["app"] == "./custom_routes.py:app"
|
||||
assert config["http"]["apps"] == {"/dashboard": "./dashboard.py:app"}
|
||||
|
||||
# Invalid prefix (doesn't start with /)
|
||||
with pytest.raises(ValueError, match="Invalid http.apps prefix"):
|
||||
validate_config(
|
||||
{
|
||||
"python_version": "3.12",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"http": {"apps": {"dashboard": "./dashboard.py:app"}},
|
||||
}
|
||||
)
|
||||
|
||||
# Invalid app path (no colon separator)
|
||||
with pytest.raises(ValueError, match="Invalid http.apps format"):
|
||||
validate_config(
|
||||
{
|
||||
"python_version": "3.12",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"http": {"apps": {"/dashboard": "./dashboard.py"}},
|
||||
}
|
||||
)
|
||||
|
||||
# Empty apps dict is fine (no-op)
|
||||
config = validate_config(
|
||||
{
|
||||
"python_version": "3.12",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"http": {"apps": {}},
|
||||
}
|
||||
)
|
||||
assert config["http"]["apps"] == {}
|
||||
|
||||
# None apps is fine
|
||||
config = validate_config(
|
||||
{
|
||||
"python_version": "3.12",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"http": {"apps": None},
|
||||
}
|
||||
)
|
||||
assert config["http"]["apps"] is None
|
||||
|
||||
|
||||
def test_is_js_http_app():
|
||||
"""Test _is_js_http_app detection logic."""
|
||||
# Python files are not JS
|
||||
assert not _is_js_http_app("./dashboard.py:app")
|
||||
assert not _is_js_http_app("./app.pyx:app")
|
||||
|
||||
# JS/TS files are JS
|
||||
assert _is_js_http_app("./extension.js:app")
|
||||
assert _is_js_http_app("./extension.ts:app")
|
||||
assert _is_js_http_app("./extension.mts:app")
|
||||
assert _is_js_http_app("./extension.mjs:app")
|
||||
assert _is_js_http_app("./extension.cts:app")
|
||||
assert _is_js_http_app("./extension.cjs:app")
|
||||
|
||||
# npm package references (bare module names) are JS
|
||||
assert _is_js_http_app("some-npm-package:app")
|
||||
assert _is_js_http_app("@scope/package:app")
|
||||
|
||||
# File paths without extensions that start with . or / are not JS
|
||||
assert not _is_js_http_app("./mymodule:app")
|
||||
|
||||
|
||||
def test_has_js_http_apps():
|
||||
"""Test _has_js_http_apps config detection."""
|
||||
# No http config
|
||||
assert not _has_js_http_apps({})
|
||||
|
||||
# No apps field
|
||||
assert not _has_js_http_apps({"http": {}})
|
||||
|
||||
# Empty apps
|
||||
assert not _has_js_http_apps({"http": {"apps": {}}})
|
||||
|
||||
# Python-only apps
|
||||
assert not _has_js_http_apps(
|
||||
{"http": {"apps": {"/dashboard": "./dashboard.py:app"}}}
|
||||
)
|
||||
|
||||
# JS apps
|
||||
assert _has_js_http_apps({"http": {"apps": {"/ext": "./extension.js:app"}}})
|
||||
|
||||
# npm package
|
||||
assert _has_js_http_apps({"http": {"apps": {"/conduit": "some-npm-package:app"}}})
|
||||
|
||||
# Mixed
|
||||
assert _has_js_http_apps(
|
||||
{
|
||||
"http": {
|
||||
"apps": {
|
||||
"/dashboard": "./dashboard.py:app",
|
||||
"/ext": "./extension.js:app",
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_validate_config_image_distro():
|
||||
"""Test validation of image_distro field."""
|
||||
# Valid image_distro values should work
|
||||
@@ -635,111 +491,6 @@ WORKDIR /deps/outer-unit_tests/unit_tests\
|
||||
}
|
||||
|
||||
|
||||
def test_config_to_docker_with_http_apps_python():
|
||||
"""Test Docker generation with http.apps containing Python apps."""
|
||||
graphs = {"agent": "./agent.py:graph"}
|
||||
actual_docker_stdin, additional_contexts = config_to_docker(
|
||||
PATH_TO_CONFIG,
|
||||
validate_config(
|
||||
{
|
||||
"dependencies": [".", "../../examples"],
|
||||
"graphs": graphs,
|
||||
"http": {
|
||||
"apps": {
|
||||
"/dashboard": "../../examples/my_app.py:app",
|
||||
}
|
||||
},
|
||||
}
|
||||
),
|
||||
base_image="langchain/langgraph-api",
|
||||
)
|
||||
# Python http.apps paths should be rewritten to container paths
|
||||
assert "LANGGRAPH_HTTP" in actual_docker_stdin
|
||||
assert "/deps/examples/my_app.py:app" in actual_docker_stdin
|
||||
assert "/dashboard" in actual_docker_stdin
|
||||
# No Node.js installation for Python-only apps
|
||||
assert "install-node" not in actual_docker_stdin
|
||||
|
||||
|
||||
def test_config_to_docker_with_http_apps_js():
|
||||
"""Test Docker generation with http.apps containing JS apps triggers Node install."""
|
||||
graphs = {"agent": "./agent.py:graph"}
|
||||
# Use npm package references to avoid needing actual JS files on disk
|
||||
actual_docker_stdin, _ = config_to_docker(
|
||||
PATH_TO_CONFIG,
|
||||
validate_config(
|
||||
{
|
||||
"dependencies": ["."],
|
||||
"graphs": graphs,
|
||||
"http": {
|
||||
"apps": {
|
||||
"/ext": "my-js-package:app",
|
||||
}
|
||||
},
|
||||
}
|
||||
),
|
||||
base_image="langchain/langgraph-api",
|
||||
)
|
||||
# JS http.apps should trigger Node.js installation
|
||||
assert "install-node" in actual_docker_stdin
|
||||
assert "NODE_VERSION=20" in actual_docker_stdin
|
||||
assert "LANGGRAPH_HTTP" in actual_docker_stdin
|
||||
|
||||
|
||||
def test_config_to_docker_with_http_apps_npm_package():
|
||||
"""Test Docker generation with http.apps containing npm package triggers Node install."""
|
||||
graphs = {"agent": "./agent.py:graph"}
|
||||
actual_docker_stdin, _ = config_to_docker(
|
||||
PATH_TO_CONFIG,
|
||||
validate_config(
|
||||
{
|
||||
"dependencies": ["."],
|
||||
"graphs": graphs,
|
||||
"http": {
|
||||
"apps": {
|
||||
"/conduit": "some-npm-package:app",
|
||||
}
|
||||
},
|
||||
}
|
||||
),
|
||||
base_image="langchain/langgraph-api",
|
||||
)
|
||||
# npm package should trigger Node.js installation
|
||||
assert "install-node" in actual_docker_stdin
|
||||
assert "NODE_VERSION=20" in actual_docker_stdin
|
||||
assert "LANGGRAPH_HTTP" in actual_docker_stdin
|
||||
# npm package path should not be rewritten
|
||||
assert "some-npm-package:app" in actual_docker_stdin
|
||||
|
||||
|
||||
def test_config_to_docker_with_http_apps_mixed():
|
||||
"""Test Docker generation with mixed Python and JS http.apps."""
|
||||
graphs = {"agent": "./agent.py:graph"}
|
||||
actual_docker_stdin, _ = config_to_docker(
|
||||
PATH_TO_CONFIG,
|
||||
validate_config(
|
||||
{
|
||||
"dependencies": [".", "../../examples"],
|
||||
"graphs": graphs,
|
||||
"http": {
|
||||
"app": "../../examples/my_app.py:app",
|
||||
"apps": {
|
||||
"/dashboard": "../../examples/my_app.py:app",
|
||||
"/conduit": "some-npm-package:app",
|
||||
},
|
||||
},
|
||||
}
|
||||
),
|
||||
base_image="langchain/langgraph-api",
|
||||
)
|
||||
# Should have both Python path rewriting and Node.js installation
|
||||
assert "install-node" in actual_docker_stdin
|
||||
assert "NODE_VERSION=20" in actual_docker_stdin
|
||||
assert "LANGGRAPH_HTTP" in actual_docker_stdin
|
||||
# Python paths should be rewritten
|
||||
assert "/deps/examples/my_app.py:app" in actual_docker_stdin
|
||||
|
||||
|
||||
def test_config_to_docker_outside_path():
|
||||
graphs = {"agent": "./agent.py:graph"}
|
||||
actual_docker_stdin, additional_contexts = config_to_docker(
|
||||
|
||||
@@ -4,7 +4,6 @@ from langgraph_cli.util import (
|
||||
_extract_deployment_url,
|
||||
clean_empty_lines,
|
||||
format_deployments_table,
|
||||
format_revisions_table,
|
||||
warn_non_wolfi_distro,
|
||||
)
|
||||
|
||||
@@ -225,34 +224,3 @@ def test_format_deployments_table():
|
||||
assert "alpha" in output
|
||||
assert "https://alpha.example.com" in output
|
||||
assert "dep-456" in output
|
||||
|
||||
|
||||
def test_format_revisions_table():
|
||||
output = format_revisions_table(
|
||||
[
|
||||
{
|
||||
"id": "rev-123",
|
||||
"status": "DEPLOYED",
|
||||
"created_at": "2023-11-09T10:00:00Z",
|
||||
},
|
||||
{
|
||||
"id": "rev-456",
|
||||
"status": "CREATING",
|
||||
"created_at": "2023-11-07T05:31:56Z",
|
||||
},
|
||||
{
|
||||
"id": "rev-789",
|
||||
"status": "DEPLOYED",
|
||||
"created_at": "2023-11-08T10:00:00Z",
|
||||
},
|
||||
]
|
||||
)
|
||||
assert "Revision ID" in output
|
||||
assert "Status" in output
|
||||
assert "Created At" in output
|
||||
assert "rev-123" in output
|
||||
assert "CREATING" in output
|
||||
assert "2023-11-07T05:31:56Z" in output
|
||||
assert "rev-456" in output
|
||||
assert "rev-789" in output
|
||||
assert "REPLACED" in output
|
||||
|
||||
@@ -7,9 +7,18 @@ Values must be JSON-serializable (dicts, lists, strings, numbers, booleans,
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from datetime import timedelta
|
||||
from typing import Any, Generic, Literal, TypeVar
|
||||
import dataclasses
|
||||
import enum
|
||||
import functools
|
||||
import inspect
|
||||
from collections.abc import Awaitable, Callable, Mapping
|
||||
from datetime import date, datetime, time, timedelta
|
||||
from decimal import Decimal
|
||||
from pathlib import PurePath
|
||||
from typing import Any, Generic, Literal, TypeVar, get_type_hints, overload
|
||||
from uuid import UUID
|
||||
|
||||
import orjson
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
@@ -50,6 +59,7 @@ __all__ = [
|
||||
"cache_get",
|
||||
"cache_set",
|
||||
"swr",
|
||||
"swr_cached",
|
||||
]
|
||||
|
||||
|
||||
@@ -138,3 +148,268 @@ async def swr(
|
||||
return await _api_swr(
|
||||
key, loader, fresh_for=fresh_for, max_age=max_age, model=model
|
||||
)
|
||||
|
||||
|
||||
def _build_cache_key(
|
||||
module: str,
|
||||
qualname: str,
|
||||
sig: inspect.Signature,
|
||||
args: tuple[Any, ...],
|
||||
kwargs: dict[str, Any],
|
||||
) -> str:
|
||||
bound = sig.bind(*args, **kwargs)
|
||||
bound.apply_defaults()
|
||||
payload = {
|
||||
"module": module,
|
||||
"qualname": qualname,
|
||||
"args": [
|
||||
{
|
||||
"name": name,
|
||||
"value": _normalize_cache_key_value(value, name=name, path=name),
|
||||
}
|
||||
for name, value in bound.arguments.items()
|
||||
],
|
||||
}
|
||||
return orjson.dumps(payload, option=orjson.OPT_SORT_KEYS).decode()
|
||||
|
||||
|
||||
def _type_identifier(tp: type[Any]) -> str:
|
||||
return f"{tp.__module__}.{tp.__qualname__}"
|
||||
|
||||
|
||||
def _stable_key_dump(value: Any) -> bytes:
|
||||
return orjson.dumps(value, option=orjson.OPT_SORT_KEYS)
|
||||
|
||||
|
||||
def _normalize_cache_key_value(
|
||||
value: Any,
|
||||
*,
|
||||
name: str | None = None,
|
||||
path: str = "value",
|
||||
) -> Any:
|
||||
if name in {"self", "cls"}:
|
||||
cls = value if isinstance(value, type) else type(value)
|
||||
return {"class": _type_identifier(cls), "kind": name}
|
||||
|
||||
if value is None or isinstance(value, (str, int, float, bool)):
|
||||
return value
|
||||
|
||||
if isinstance(value, bytes):
|
||||
return {"hex": value.hex(), "kind": "bytes"}
|
||||
|
||||
if isinstance(value, (datetime, date, time)):
|
||||
return {"kind": type(value).__name__, "value": value.isoformat()}
|
||||
|
||||
if isinstance(value, timedelta):
|
||||
return {"kind": "timedelta", "value": value.total_seconds()}
|
||||
|
||||
if isinstance(value, Decimal):
|
||||
return {"kind": "decimal", "value": str(value)}
|
||||
|
||||
if isinstance(value, UUID):
|
||||
return {"kind": "uuid", "value": str(value)}
|
||||
|
||||
if isinstance(value, PurePath):
|
||||
return {"kind": "path", "value": str(value)}
|
||||
|
||||
if isinstance(value, enum.Enum):
|
||||
return {
|
||||
"kind": "enum",
|
||||
"type": _type_identifier(type(value)),
|
||||
"value": _normalize_cache_key_value(value.value, path=f"{path}.value"),
|
||||
}
|
||||
|
||||
if dataclasses.is_dataclass(value) and not isinstance(value, type):
|
||||
return {
|
||||
"fields": {
|
||||
field.name: _normalize_cache_key_value(
|
||||
getattr(value, field.name),
|
||||
path=f"{path}.{field.name}",
|
||||
)
|
||||
for field in dataclasses.fields(value)
|
||||
},
|
||||
"kind": "dataclass",
|
||||
"type": _type_identifier(type(value)),
|
||||
}
|
||||
|
||||
if hasattr(value, "model_dump") and callable(value.model_dump):
|
||||
if isinstance(value, type):
|
||||
raise TypeError(
|
||||
f"Cannot auto-generate a stable cache key for `{path}` from the "
|
||||
f"type object {value!r}. Pass `key=` to `swr_cached` instead."
|
||||
)
|
||||
return {
|
||||
"kind": "model",
|
||||
"type": _type_identifier(type(value)),
|
||||
"value": _normalize_cache_key_value(
|
||||
value.model_dump(mode="json"),
|
||||
path=path,
|
||||
),
|
||||
}
|
||||
|
||||
if hasattr(value, "dict") and callable(value.dict):
|
||||
if isinstance(value, type):
|
||||
raise TypeError(
|
||||
f"Cannot auto-generate a stable cache key for `{path}` from the "
|
||||
f"type object {value!r}. Pass `key=` to `swr_cached` instead."
|
||||
)
|
||||
return {
|
||||
"kind": "model",
|
||||
"type": _type_identifier(type(value)),
|
||||
"value": _normalize_cache_key_value(value.dict(), path=path),
|
||||
}
|
||||
|
||||
if isinstance(value, Mapping):
|
||||
items = [
|
||||
{
|
||||
"key": _normalize_cache_key_value(key, path=f"{path}.<key>"),
|
||||
"value": _normalize_cache_key_value(
|
||||
item,
|
||||
path=f"{path}[{key!r}]",
|
||||
),
|
||||
}
|
||||
for key, item in value.items()
|
||||
]
|
||||
items.sort(key=lambda item: _stable_key_dump(item["key"]))
|
||||
return {"items": items, "kind": "mapping"}
|
||||
|
||||
if isinstance(value, tuple):
|
||||
return {
|
||||
"items": [
|
||||
_normalize_cache_key_value(item, path=f"{path}[{index}]")
|
||||
for index, item in enumerate(value)
|
||||
],
|
||||
"kind": "tuple",
|
||||
}
|
||||
|
||||
if isinstance(value, list):
|
||||
return {
|
||||
"items": [
|
||||
_normalize_cache_key_value(item, path=f"{path}[{index}]")
|
||||
for index, item in enumerate(value)
|
||||
],
|
||||
"kind": "list",
|
||||
}
|
||||
|
||||
if isinstance(value, (set, frozenset)):
|
||||
items = [_normalize_cache_key_value(item, path=f"{path}[]") for item in value]
|
||||
items.sort(key=_stable_key_dump)
|
||||
return {"items": items, "kind": type(value).__name__}
|
||||
|
||||
if isinstance(value, type):
|
||||
return {"kind": "type", "type": _type_identifier(value)}
|
||||
|
||||
if type(value).__repr__ is object.__repr__:
|
||||
raise TypeError(
|
||||
f"Cannot auto-generate a stable cache key for `{path}` of type "
|
||||
f"`{_type_identifier(type(value))}`. Pass `key=` to `swr_cached` "
|
||||
"instead."
|
||||
)
|
||||
|
||||
return {
|
||||
"kind": "repr",
|
||||
"repr": repr(value),
|
||||
"type": _type_identifier(type(value)),
|
||||
}
|
||||
|
||||
|
||||
def _get_model_from_hints(func: Callable[..., Any]) -> type | None:
|
||||
try:
|
||||
hints = get_type_hints(func)
|
||||
except Exception:
|
||||
return None
|
||||
ret = hints.get("return")
|
||||
if ret is None:
|
||||
return None
|
||||
try:
|
||||
from pydantic import BaseModel
|
||||
except ImportError:
|
||||
return None
|
||||
if isinstance(ret, type) and issubclass(ret, BaseModel):
|
||||
return ret
|
||||
return None
|
||||
|
||||
|
||||
@overload
|
||||
def swr_cached(
|
||||
fn: Callable[..., Awaitable[T]],
|
||||
/,
|
||||
) -> Callable[..., Awaitable[SWRResult[T]]]: ...
|
||||
|
||||
|
||||
@overload
|
||||
def swr_cached(
|
||||
*,
|
||||
key: str | Callable[..., str] | None = ...,
|
||||
fresh_for: timedelta | None = ...,
|
||||
max_age: timedelta | None = ...,
|
||||
model: type[T] | None = ...,
|
||||
) -> Callable[
|
||||
[Callable[..., Awaitable[T]]], Callable[..., Awaitable[SWRResult[T]]]
|
||||
]: ...
|
||||
|
||||
|
||||
def swr_cached(
|
||||
fn=None,
|
||||
*,
|
||||
key=None,
|
||||
fresh_for=None,
|
||||
max_age=None,
|
||||
model=None,
|
||||
):
|
||||
"""Decorator that wraps an async function with :func:`swr` caching.
|
||||
|
||||
Can be used with or without parentheses::
|
||||
|
||||
@swr_cached
|
||||
async def fetch_config():
|
||||
...
|
||||
|
||||
@swr_cached(fresh_for=timedelta(minutes=5))
|
||||
async def fetch_profile(user_id: str) -> Profile:
|
||||
...
|
||||
|
||||
The cache key is auto-derived from the function's module, qualified name,
|
||||
and a structured serialization of the bound call arguments. For methods,
|
||||
``self`` and ``cls`` are keyed by class identity rather than object
|
||||
instance identity. Override with ``key=`` (a static string or a callable
|
||||
that receives the same arguments as the decorated function) when method
|
||||
state matters or arguments are not stably serializable.
|
||||
|
||||
If the return annotation is a Pydantic `BaseModel` subclass and
|
||||
``model`` is not provided, the model is inferred automatically.
|
||||
"""
|
||||
|
||||
def decorator(
|
||||
func: Callable[..., Awaitable[T]],
|
||||
) -> Callable[..., Awaitable[SWRResult[T]]]:
|
||||
sig = inspect.signature(func)
|
||||
resolved_model = model
|
||||
if resolved_model is None:
|
||||
resolved_model = _get_model_from_hints(func)
|
||||
|
||||
@functools.wraps(func)
|
||||
async def wrapper(*args: Any, **kwargs: Any) -> SWRResult[T]:
|
||||
if key is None:
|
||||
module = getattr(func, "__module__", None) or "unknown"
|
||||
qualname = getattr(func, "__qualname__", None) or getattr(
|
||||
func, "__name__", "unknown"
|
||||
)
|
||||
cache_key = _build_cache_key(module, qualname, sig, args, kwargs)
|
||||
elif callable(key):
|
||||
cache_key = key(*args, **kwargs)
|
||||
else:
|
||||
cache_key = key
|
||||
return await swr(
|
||||
cache_key,
|
||||
lambda: func(*args, **kwargs),
|
||||
fresh_for=fresh_for,
|
||||
max_age=max_age,
|
||||
model=resolved_model,
|
||||
)
|
||||
|
||||
return wrapper
|
||||
|
||||
if fn is not None:
|
||||
return decorator(fn)
|
||||
return decorator
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import inspect
|
||||
from datetime import timedelta
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import orjson
|
||||
import pytest
|
||||
|
||||
import langgraph_sdk.cache as cache_module
|
||||
from langgraph_sdk.cache import swr_cached
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -67,3 +70,239 @@ async def test_swr_defaults(monkeypatch):
|
||||
|
||||
assert forwarded["fresh_for"] == timedelta(0)
|
||||
assert forwarded["max_age"] == timedelta(days=1)
|
||||
|
||||
|
||||
# -- swr_cached decorator tests --
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_swr_cached_no_parens(monkeypatch):
|
||||
"""`@swr_cached` without parentheses uses a structured auto-derived key."""
|
||||
forwarded = {}
|
||||
|
||||
async def fake_swr(key, loader, *, fresh_for, max_age, model): # noqa: ARG001
|
||||
forwarded["key"] = key
|
||||
forwarded["model"] = model
|
||||
return await loader()
|
||||
|
||||
monkeypatch.setattr(cache_module, "_api_swr", fake_swr)
|
||||
|
||||
@swr_cached
|
||||
async def fetch_config():
|
||||
return {"debug": True}
|
||||
|
||||
result = await fetch_config()
|
||||
cache_key = orjson.loads(forwarded["key"])
|
||||
|
||||
assert result == {"debug": True}
|
||||
assert cache_key == {
|
||||
"args": [],
|
||||
"module": fetch_config.__module__,
|
||||
"qualname": fetch_config.__qualname__,
|
||||
}
|
||||
assert forwarded["model"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_swr_cached_with_options(monkeypatch):
|
||||
"""@swr_cached(...) with keyword options."""
|
||||
forwarded = {}
|
||||
|
||||
async def fake_swr(key, loader, *, fresh_for, max_age, model): # noqa: ARG001
|
||||
forwarded["key"] = key
|
||||
forwarded["fresh_for"] = fresh_for
|
||||
forwarded["max_age"] = max_age
|
||||
return await loader()
|
||||
|
||||
monkeypatch.setattr(cache_module, "_api_swr", fake_swr)
|
||||
|
||||
@swr_cached(fresh_for=timedelta(minutes=5), max_age=timedelta(hours=1))
|
||||
async def fetch_config():
|
||||
return "ok"
|
||||
|
||||
await fetch_config()
|
||||
|
||||
assert forwarded["fresh_for"] == timedelta(minutes=5)
|
||||
assert forwarded["max_age"] == timedelta(hours=1)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_swr_cached_key_includes_args(monkeypatch):
|
||||
"""Arguments are serialized structurally into the auto-derived cache key."""
|
||||
forwarded = {}
|
||||
|
||||
async def fake_swr(key, loader, *, fresh_for, max_age, model): # noqa: ARG001
|
||||
forwarded["key"] = key
|
||||
return await loader()
|
||||
|
||||
monkeypatch.setattr(cache_module, "_api_swr", fake_swr)
|
||||
|
||||
@swr_cached
|
||||
async def fetch_profile(user_id: str):
|
||||
return {"id": user_id}
|
||||
|
||||
await fetch_profile("abc123")
|
||||
cache_key = orjson.loads(forwarded["key"])
|
||||
assert cache_key["args"] == [{"name": "user_id", "value": "abc123"}]
|
||||
|
||||
|
||||
def test_build_cache_key_distinguishes_modules():
|
||||
async def fetch_profile(user_id: str): ...
|
||||
|
||||
sig = inspect.signature(fetch_profile)
|
||||
key_one = cache_module._build_cache_key(
|
||||
"alpha.module", "fetch_profile", sig, ("1",), {}
|
||||
)
|
||||
key_two = cache_module._build_cache_key(
|
||||
"beta.module", "fetch_profile", sig, ("1",), {}
|
||||
)
|
||||
|
||||
assert key_one != key_two
|
||||
|
||||
|
||||
def test_build_cache_key_distinguishes_argument_boundaries():
|
||||
async def fetch_profile(left: str, right: str): ...
|
||||
|
||||
sig = inspect.signature(fetch_profile)
|
||||
key_one = cache_module._build_cache_key(
|
||||
"alpha.module",
|
||||
"fetch_profile",
|
||||
sig,
|
||||
("a:b", "c"),
|
||||
{},
|
||||
)
|
||||
key_two = cache_module._build_cache_key(
|
||||
"alpha.module",
|
||||
"fetch_profile",
|
||||
sig,
|
||||
("a", "b:c"),
|
||||
{},
|
||||
)
|
||||
|
||||
assert key_one != key_two
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_swr_cached_explicit_key_string(monkeypatch):
|
||||
"""Explicit string key overrides auto-derivation."""
|
||||
forwarded = {}
|
||||
|
||||
async def fake_swr(key, loader, *, fresh_for, max_age, model): # noqa: ARG001
|
||||
forwarded["key"] = key
|
||||
return await loader()
|
||||
|
||||
monkeypatch.setattr(cache_module, "_api_swr", fake_swr)
|
||||
|
||||
@swr_cached(key="my-static-key")
|
||||
async def fetch_stuff():
|
||||
return 42
|
||||
|
||||
await fetch_stuff()
|
||||
assert forwarded["key"] == "my-static-key"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_swr_cached_explicit_key_callable(monkeypatch):
|
||||
"""Explicit callable key receives the function's arguments."""
|
||||
forwarded = {}
|
||||
|
||||
async def fake_swr(key, loader, *, fresh_for, max_age, model): # noqa: ARG001
|
||||
forwarded["key"] = key
|
||||
return await loader()
|
||||
|
||||
monkeypatch.setattr(cache_module, "_api_swr", fake_swr)
|
||||
|
||||
@swr_cached(key=lambda org, repo: f"repo:{org}/{repo}")
|
||||
async def fetch_repo(org: str, repo: str):
|
||||
return {"full_name": f"{org}/{repo}"}
|
||||
|
||||
await fetch_repo("langchain-ai", "langgraph")
|
||||
assert forwarded["key"] == "repo:langchain-ai/langgraph"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_swr_cached_method_key_uses_class_identity(monkeypatch):
|
||||
forwarded = []
|
||||
|
||||
async def fake_swr(key, loader, *, fresh_for, max_age, model): # noqa: ARG001
|
||||
forwarded.append(key)
|
||||
return await loader()
|
||||
|
||||
monkeypatch.setattr(cache_module, "_api_swr", fake_swr)
|
||||
|
||||
class Client:
|
||||
@swr_cached
|
||||
async def fetch_repo(self, repo: str):
|
||||
return {"repo": repo}
|
||||
|
||||
await Client().fetch_repo("langgraph")
|
||||
await Client().fetch_repo("langgraph")
|
||||
|
||||
assert forwarded[0] == forwarded[1]
|
||||
cache_key = orjson.loads(forwarded[0])
|
||||
assert cache_key["args"][0] == {
|
||||
"name": "self",
|
||||
"value": {
|
||||
"class": f"{Client.__module__}.{Client.__qualname__}",
|
||||
"kind": "self",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_swr_cached_rejects_unstable_object_args():
|
||||
class Unstable:
|
||||
pass
|
||||
|
||||
@swr_cached
|
||||
async def fetch_profile(user: Unstable):
|
||||
return {"user_type": type(user).__name__}
|
||||
|
||||
with pytest.raises(
|
||||
TypeError,
|
||||
match="Cannot auto-generate a stable cache key for `user`",
|
||||
):
|
||||
await fetch_profile(Unstable())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_swr_cached_preserves_function_metadata(monkeypatch):
|
||||
"""functools.wraps preserves __name__ and __doc__."""
|
||||
|
||||
async def fake_swr(_key, loader, **_kw):
|
||||
return await loader()
|
||||
|
||||
monkeypatch.setattr(cache_module, "_api_swr", fake_swr)
|
||||
|
||||
@swr_cached
|
||||
async def my_loader():
|
||||
"""My docstring."""
|
||||
return 1
|
||||
|
||||
assert my_loader.__name__ == "my_loader"
|
||||
assert my_loader.__doc__ == "My docstring."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_swr_cached_infers_pydantic_model(monkeypatch):
|
||||
"""Model is auto-detected from return type annotation."""
|
||||
pytest.importorskip("pydantic")
|
||||
from pydantic import BaseModel
|
||||
|
||||
forwarded = {}
|
||||
|
||||
async def fake_swr(key, loader, *, fresh_for, max_age, model): # noqa: ARG001
|
||||
forwarded["model"] = model
|
||||
return await loader()
|
||||
|
||||
monkeypatch.setattr(cache_module, "_api_swr", fake_swr)
|
||||
|
||||
class Profile(BaseModel):
|
||||
name: str
|
||||
|
||||
@swr_cached
|
||||
async def fetch_profile() -> Profile:
|
||||
return Profile(name="Alice")
|
||||
|
||||
await fetch_profile()
|
||||
assert forwarded["model"] is Profile
|
||||
|
||||
Reference in New Issue
Block a user