mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-09-01 12:48:50 +02:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e05912899 | ||
|
|
f899af1c73 | ||
|
|
5f479f1af5 | ||
|
|
33c3edfde2 | ||
|
|
292fd5787c | ||
|
|
b904db211c | ||
|
|
ecee22fa16 |
@@ -32,8 +32,6 @@ def test(config: pathlib.Path, port: int, tag: str, verbose: bool):
|
||||
docker_compose=None,
|
||||
port=port,
|
||||
watch=False,
|
||||
debugger_port=None,
|
||||
debugger_base_url=f"http://127.0.0.1:{port}",
|
||||
postgres_uri=None,
|
||||
api_version=None,
|
||||
image=tag,
|
||||
@@ -173,5 +171,5 @@ if __name__ == "__main__":
|
||||
except BaseException:
|
||||
logger.exception("Test failed")
|
||||
raise
|
||||
|
||||
|
||||
logger.info("Test execution finished")
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
sdk_py: ${{ steps.filter.outputs.sdk_py || 'true' }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4
|
||||
- uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4
|
||||
if: github.event_name != 'workflow_dispatch'
|
||||
id: filter
|
||||
with:
|
||||
|
||||
@@ -76,7 +76,6 @@ __pypackages__/
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.env.*
|
||||
.envrc
|
||||
*.crt
|
||||
*.key
|
||||
|
||||
@@ -2,14 +2,6 @@
|
||||
|
||||
This repository is a monorepo. Each library lives in a subdirectory under `libs/`.
|
||||
|
||||
<corridor>
|
||||
|
||||
## Corridor security analysis
|
||||
|
||||
When Corridor's `analyzePlan` tool is available, create a plan and use the tool to analyze it before generating or modifying code. Apply the resulting security guidance before writing code.
|
||||
|
||||
</corridor>
|
||||
|
||||
When you modify code in any library, run the following commands in that library's directory before creating a pull request:
|
||||
|
||||
- `make format` – run code formatters
|
||||
|
||||
@@ -2,14 +2,6 @@
|
||||
|
||||
This repository is a monorepo. Each library lives in a subdirectory under `libs/`.
|
||||
|
||||
<corridor>
|
||||
|
||||
## Corridor security analysis
|
||||
|
||||
Before generating or modifying code, create a plan and use Corridor's `analyzePlan` tool to analyze it. Apply the resulting security guidance before writing code.
|
||||
|
||||
</corridor>
|
||||
|
||||
When you modify code in any library, run the following commands in that library's directory before creating a pull request:
|
||||
|
||||
- `make format` – run code formatters
|
||||
|
||||
@@ -7,7 +7,7 @@ import logging
|
||||
import re
|
||||
import threading
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence
|
||||
from collections.abc import Callable, Iterable, Iterator, Sequence
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime
|
||||
from typing import (
|
||||
@@ -354,7 +354,7 @@ class BasePostgresStore(Generic[C]):
|
||||
(
|
||||
_namespace_to_text(op.namespace),
|
||||
op.key,
|
||||
Jsonb(dict(cast(Mapping[str, Any], op.value))),
|
||||
Jsonb(cast(dict, op.value)),
|
||||
)
|
||||
)
|
||||
if op.ttl is not None:
|
||||
|
||||
@@ -7,7 +7,7 @@ import re
|
||||
import sqlite3
|
||||
import threading
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence
|
||||
from collections.abc import Callable, Iterable, Iterator, Sequence
|
||||
from contextlib import contextmanager
|
||||
from typing import Any, Literal, NamedTuple, cast
|
||||
|
||||
@@ -387,7 +387,7 @@ class BaseSqliteStore:
|
||||
[
|
||||
_namespace_to_text(op.namespace),
|
||||
op.key,
|
||||
orjson.dumps(dict(cast(Mapping[str, Any], op.value))),
|
||||
orjson.dumps(cast(dict, op.value)),
|
||||
expires_at,
|
||||
op.ttl,
|
||||
]
|
||||
|
||||
@@ -12,7 +12,7 @@ Core types:
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Iterable, Mapping
|
||||
from collections.abc import Iterable
|
||||
from datetime import datetime
|
||||
from typing import (
|
||||
Any,
|
||||
@@ -473,10 +473,10 @@ class PutOp(NamedTuple):
|
||||
the full path would effectively be `"documents/user123/report1"`
|
||||
"""
|
||||
|
||||
value: Mapping[str, Any] | None
|
||||
value: dict[str, Any] | None
|
||||
"""The data to store, or `None` to mark the item for deletion.
|
||||
|
||||
The value must be a mapping with string keys and JSON-serializable values.
|
||||
The value must be a dictionary with string keys and JSON-serializable values.
|
||||
Setting this to `None` signals that the item should be deleted.
|
||||
|
||||
Example:
|
||||
@@ -857,7 +857,7 @@ class BaseStore(ABC):
|
||||
self,
|
||||
namespace: tuple[str, ...],
|
||||
key: str,
|
||||
value: Mapping[str, Any],
|
||||
value: dict[str, Any],
|
||||
index: Literal[False] | list[str] | None = None,
|
||||
*,
|
||||
ttl: float | None | NotProvided = NOT_PROVIDED,
|
||||
@@ -869,7 +869,7 @@ class BaseStore(ABC):
|
||||
Example: `("documents", "user123")`
|
||||
key: Unique identifier within the namespace. Together with namespace forms
|
||||
the complete path to the item.
|
||||
value: Mapping containing the item's data. Must contain string keys
|
||||
value: Dictionary containing the item's data. Must contain string keys
|
||||
and JSON-serializable values.
|
||||
index: Controls how the item's fields are indexed for search:
|
||||
|
||||
@@ -1110,7 +1110,7 @@ class BaseStore(ABC):
|
||||
self,
|
||||
namespace: tuple[str, ...],
|
||||
key: str,
|
||||
value: Mapping[str, Any],
|
||||
value: dict[str, Any],
|
||||
index: Literal[False] | list[str] | None = None,
|
||||
*,
|
||||
ttl: float | None | NotProvided = NOT_PROVIDED,
|
||||
@@ -1122,7 +1122,7 @@ class BaseStore(ABC):
|
||||
Example: `("documents", "user123")`
|
||||
key: Unique identifier within the namespace. Together with namespace forms
|
||||
the complete path to the item.
|
||||
value: Mapping containing the item's data. Must contain string keys
|
||||
value: Dictionary containing the item's data. Must contain string keys
|
||||
and JSON-serializable values.
|
||||
index: Controls how the item's fields are indexed for search:
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import functools
|
||||
import weakref
|
||||
from collections.abc import Callable, Iterable, Mapping
|
||||
from collections.abc import Callable, Iterable
|
||||
from typing import Any, Literal, TypeVar
|
||||
|
||||
from langgraph.store.base import (
|
||||
@@ -132,7 +132,7 @@ class AsyncBatchedBaseStore(BaseStore):
|
||||
self,
|
||||
namespace: tuple[str, ...],
|
||||
key: str,
|
||||
value: Mapping[str, Any],
|
||||
value: dict[str, Any],
|
||||
index: Literal[False] | list[str] | None = None,
|
||||
*,
|
||||
ttl: float | None | NotProvided = NOT_PROVIDED,
|
||||
@@ -231,7 +231,7 @@ class AsyncBatchedBaseStore(BaseStore):
|
||||
self,
|
||||
namespace: tuple[str, ...],
|
||||
key: str,
|
||||
value: Mapping[str, Any],
|
||||
value: dict[str, Any],
|
||||
index: Literal[False] | list[str] | None = None,
|
||||
*,
|
||||
ttl: float | None | NotProvided = NOT_PROVIDED,
|
||||
|
||||
@@ -11,7 +11,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import functools
|
||||
import json
|
||||
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
||||
from collections.abc import Awaitable, Callable, Sequence
|
||||
from typing import Any
|
||||
|
||||
from langchain_core.embeddings import Embeddings
|
||||
@@ -244,9 +244,6 @@ def get_text_at_path(obj: Any, path: str | list[str]) -> list[str]:
|
||||
- Multi-field selection: "{field1,field2}"
|
||||
- Nested paths in multi-field: "{field1,nested.field2}"
|
||||
"""
|
||||
if isinstance(obj, Mapping) and not isinstance(obj, dict):
|
||||
obj = dict(obj)
|
||||
|
||||
if not path or path == "$":
|
||||
return [json.dumps(obj, sort_keys=True, ensure_ascii=False)]
|
||||
|
||||
|
||||
@@ -408,7 +408,7 @@ class InMemoryStore(BaseStore):
|
||||
self._vectors[namespace].pop(key, None)
|
||||
else:
|
||||
self._data[namespace][key] = Item(
|
||||
value=dict(op.value),
|
||||
value=op.value,
|
||||
key=key,
|
||||
namespace=namespace,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import asyncio
|
||||
import json
|
||||
from collections import UserDict
|
||||
from collections.abc import Iterable, Mapping
|
||||
from collections.abc import Iterable
|
||||
from datetime import datetime
|
||||
from types import MappingProxyType
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
@@ -139,18 +137,6 @@ def test_get_text_at_path() -> None:
|
||||
assert get_text_at_path(nested_data, "nested[{invalid}]") == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mapping",
|
||||
[
|
||||
UserDict({"text": "searchable"}),
|
||||
MappingProxyType({"text": "searchable"}),
|
||||
],
|
||||
)
|
||||
def test_get_text_at_path_with_non_dict_mapping(mapping: Mapping[str, str]) -> None:
|
||||
assert get_text_at_path(mapping, "$") == ['{"text": "searchable"}']
|
||||
assert get_text_at_path(mapping, "text") == ["searchable"]
|
||||
|
||||
|
||||
async def test_async_batch_store(mocker: MockerFixture) -> None:
|
||||
abatch = mocker.stub()
|
||||
|
||||
|
||||
@@ -48,9 +48,6 @@ def get_anonymized_params(
|
||||
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
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import pathlib
|
||||
import shutil
|
||||
import sys
|
||||
from collections.abc import Sequence
|
||||
from urllib.parse import SplitResult, urlencode, urlsplit, urlunsplit
|
||||
|
||||
import click
|
||||
import click.exceptions
|
||||
@@ -140,17 +141,6 @@ OPT_VERBOSE = click.option(
|
||||
help="Show more output from the server logs",
|
||||
)
|
||||
OPT_WATCH = click.option("--watch", is_flag=True, help="Restart on file changes")
|
||||
OPT_DEBUGGER_PORT = click.option(
|
||||
"--debugger-port",
|
||||
type=int,
|
||||
help="Pull the debugger image locally and serve the UI on specified port",
|
||||
)
|
||||
OPT_DEBUGGER_BASE_URL = click.option(
|
||||
"--debugger-base-url",
|
||||
type=str,
|
||||
help="URL used by the debugger to access LangGraph API. Defaults to http://127.0.0.1:[PORT]",
|
||||
)
|
||||
|
||||
OPT_POSTGRES_URI = click.option(
|
||||
"--postgres-uri",
|
||||
help="Postgres URI to use for the database. Defaults to launching a local database",
|
||||
@@ -242,18 +232,94 @@ cli.add_command(deploy)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _validated_http_url(value: str, option_name: str) -> SplitResult:
|
||||
try:
|
||||
parsed = urlsplit(value)
|
||||
hostname = parsed.hostname
|
||||
_ = parsed.port
|
||||
except ValueError as exc:
|
||||
raise click.UsageError(
|
||||
f"{option_name} must be a valid HTTP(S) URL without credentials."
|
||||
) from exc
|
||||
|
||||
if (
|
||||
value != value.strip()
|
||||
or parsed.scheme not in {"http", "https"}
|
||||
or not parsed.netloc
|
||||
or not hostname
|
||||
or parsed.username is not None
|
||||
or parsed.password is not None
|
||||
):
|
||||
raise click.UsageError(
|
||||
f"{option_name} must be a valid HTTP(S) URL without credentials."
|
||||
)
|
||||
return parsed
|
||||
|
||||
|
||||
def _studio_link(
|
||||
*,
|
||||
port: int,
|
||||
studio_url: str | None,
|
||||
api_url: str | None,
|
||||
debugger_base_url: str | None,
|
||||
) -> str:
|
||||
if debugger_base_url is not None:
|
||||
if api_url is not None and api_url != debugger_base_url:
|
||||
raise click.UsageError(
|
||||
"--api-url and --debugger-base-url cannot specify different URLs."
|
||||
)
|
||||
click.echo(
|
||||
"Warning: --debugger-base-url is deprecated; use --api-url instead.",
|
||||
err=True,
|
||||
)
|
||||
api_url = debugger_base_url
|
||||
|
||||
studio_url = "https://smith.langchain.com" if studio_url is None else studio_url
|
||||
api_url = f"http://127.0.0.1:{port}" if api_url is None else api_url
|
||||
studio_parts = _validated_http_url(studio_url, "--studio-url")
|
||||
_validated_http_url(api_url, "--api-url")
|
||||
if studio_parts.query or studio_parts.fragment:
|
||||
raise click.UsageError(
|
||||
"--studio-url must not include a query string or fragment."
|
||||
)
|
||||
|
||||
studio_path = f"{studio_parts.path.rstrip('/')}/studio/"
|
||||
return urlunsplit(
|
||||
studio_parts._replace(
|
||||
path=studio_path,
|
||||
query=urlencode({"baseUrl": api_url}),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@OPT_RECREATE
|
||||
@OPT_PULL
|
||||
@OPT_PORT
|
||||
@OPT_DOCKER_COMPOSE
|
||||
@OPT_CONFIG
|
||||
@OPT_VERBOSE
|
||||
@OPT_DEBUGGER_PORT
|
||||
@OPT_DEBUGGER_BASE_URL
|
||||
@OPT_WATCH
|
||||
@OPT_POSTGRES_URI
|
||||
@OPT_API_VERSION
|
||||
@OPT_ENGINE_RUNTIME_MODE
|
||||
@click.option(
|
||||
"--studio-url",
|
||||
type=str,
|
||||
default=None,
|
||||
help="URL of the LangGraph Studio instance. Defaults to https://smith.langchain.com",
|
||||
)
|
||||
@click.option(
|
||||
"--api-url",
|
||||
type=str,
|
||||
default=None,
|
||||
help="URL that LangGraph Studio uses to access the API. Defaults to http://127.0.0.1:[PORT]",
|
||||
)
|
||||
@click.option(
|
||||
"--debugger-base-url",
|
||||
type=str,
|
||||
default=None,
|
||||
hidden=True,
|
||||
)
|
||||
@click.option(
|
||||
"--image",
|
||||
type=str,
|
||||
@@ -284,14 +350,21 @@ def up(
|
||||
watch: bool,
|
||||
wait: bool,
|
||||
verbose: bool,
|
||||
debugger_port: int | None,
|
||||
debugger_base_url: str | None,
|
||||
postgres_uri: str | None,
|
||||
api_version: str | None,
|
||||
engine_runtime_mode: str,
|
||||
studio_url: str | None,
|
||||
api_url: str | None,
|
||||
debugger_base_url: str | None,
|
||||
image: str | None,
|
||||
base_image: str | None,
|
||||
):
|
||||
studio_link = _studio_link(
|
||||
port=port,
|
||||
studio_url=studio_url,
|
||||
api_url=api_url,
|
||||
debugger_base_url=debugger_base_url,
|
||||
)
|
||||
click.secho("Starting LangGraph API server...", fg="green")
|
||||
click.secho(
|
||||
"""For local dev, requires env var LANGSMITH_API_KEY with access to LangSmith Deployment.
|
||||
@@ -308,8 +381,6 @@ For production use, requires a license key in env var LANGGRAPH_CLOUD_LICENSE_KE
|
||||
pull=pull,
|
||||
watch=watch,
|
||||
verbose=verbose,
|
||||
debugger_port=debugger_port,
|
||||
debugger_base_url=debugger_base_url,
|
||||
postgres_uri=postgres_uri,
|
||||
api_version=api_version,
|
||||
engine_runtime_mode=engine_runtime_mode,
|
||||
@@ -337,20 +408,12 @@ For production use, requires a license key in env var LANGGRAPH_CLOUD_LICENSE_KE
|
||||
if "unpacking to docker.io" in line:
|
||||
set("Starting...")
|
||||
elif "Application startup complete" in line:
|
||||
debugger_origin = (
|
||||
f"http://localhost:{debugger_port}"
|
||||
if debugger_port
|
||||
else "https://smith.langchain.com"
|
||||
)
|
||||
debugger_base_url_query = (
|
||||
debugger_base_url or f"http://127.0.0.1:{port}"
|
||||
)
|
||||
set("")
|
||||
sys.stdout.write(
|
||||
f"""Ready!
|
||||
- API: http://localhost:{port}
|
||||
- Docs: http://localhost:{port}/docs
|
||||
- LangGraph Studio: {debugger_origin}/studio/?baseUrl={debugger_base_url_query}
|
||||
- LangGraph Studio: {studio_link}
|
||||
"""
|
||||
)
|
||||
sys.stdout.flush()
|
||||
@@ -935,8 +998,6 @@ def prepare_args_and_stdin(
|
||||
docker_compose: pathlib.Path | None,
|
||||
port: int,
|
||||
watch: bool,
|
||||
debugger_port: int | None = None,
|
||||
debugger_base_url: str | None = None,
|
||||
postgres_uri: str | None = None,
|
||||
api_version: str | None = None,
|
||||
engine_runtime_mode: str = "combined_queue_worker",
|
||||
@@ -950,8 +1011,6 @@ def prepare_args_and_stdin(
|
||||
stdin = langgraph_cli.docker.compose(
|
||||
capabilities,
|
||||
port=port,
|
||||
debugger_port=debugger_port,
|
||||
debugger_base_url=debugger_base_url,
|
||||
postgres_uri=postgres_uri,
|
||||
image=image,
|
||||
base_image=base_image,
|
||||
@@ -989,8 +1048,6 @@ def prepare(
|
||||
pull: bool,
|
||||
watch: bool,
|
||||
verbose: bool,
|
||||
debugger_port: int | None = None,
|
||||
debugger_base_url: str | None = None,
|
||||
postgres_uri: str | None = None,
|
||||
api_version: str | None = None,
|
||||
engine_runtime_mode: str = "combined_queue_worker",
|
||||
@@ -1032,8 +1089,6 @@ def prepare(
|
||||
docker_compose=docker_compose,
|
||||
port=port,
|
||||
watch=watch,
|
||||
debugger_port=debugger_port,
|
||||
debugger_base_url=debugger_base_url or f"http://127.0.0.1:{port}",
|
||||
postgres_uri=postgres_uri,
|
||||
api_version=api_version,
|
||||
engine_runtime_mode=engine_runtime_mode,
|
||||
|
||||
@@ -142,29 +142,6 @@ def check_capabilities(runner) -> DockerCapabilities:
|
||||
)
|
||||
|
||||
|
||||
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."""
|
||||
@@ -191,8 +168,6 @@ 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
|
||||
@@ -253,12 +228,6 @@ def compose_as_dict(
|
||||
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",
|
||||
@@ -289,7 +258,7 @@ def compose_as_dict(
|
||||
"test": "python /api/healthcheck.py",
|
||||
"interval": "60s",
|
||||
"start_interval": "1s",
|
||||
"start_period": "10s",
|
||||
"start_period": "60s",
|
||||
}
|
||||
|
||||
# Final compose dictionary with volumes included if needed
|
||||
@@ -305,8 +274,6 @@ 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,
|
||||
@@ -318,8 +285,6 @@ def compose(
|
||||
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,
|
||||
|
||||
@@ -8,10 +8,11 @@ from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
import langgraph_cli.deploy as deploy_module
|
||||
from langgraph_cli.cli import cli, prepare_args_and_stdin
|
||||
from langgraph_cli.cli import _studio_link, cli, prepare_args_and_stdin
|
||||
from langgraph_cli.config import Config, _get_pip_cleanup_lines, validate_config
|
||||
from langgraph_cli.docker import DEFAULT_POSTGRES_URI, DockerCapabilities, Version
|
||||
from langgraph_cli.util import clean_empty_lines
|
||||
@@ -56,8 +57,6 @@ def test_prepare_args_and_stdin() -> None:
|
||||
Config(dependencies=[".", "../../.."], graphs={"agent": "agent.py:graph"})
|
||||
)
|
||||
port = 8000
|
||||
debugger_port = 8001
|
||||
debugger_graph_url = f"http://127.0.0.1:{port}"
|
||||
|
||||
actual_args, actual_stdin = prepare_args_and_stdin(
|
||||
capabilities=DEFAULT_DOCKER_CAPABILITIES,
|
||||
@@ -65,8 +64,6 @@ def test_prepare_args_and_stdin() -> None:
|
||||
config=config,
|
||||
docker_compose=pathlib.Path("custom-docker-compose.yml"),
|
||||
port=port,
|
||||
debugger_port=debugger_port,
|
||||
debugger_base_url=debugger_graph_url,
|
||||
watch=True,
|
||||
)
|
||||
|
||||
@@ -110,16 +107,6 @@ services:
|
||||
retries: 5
|
||||
interval: 60s
|
||||
start_interval: 1s
|
||||
langgraph-debugger:
|
||||
image: langchain/langgraph-debugger
|
||||
restart: on-failure
|
||||
depends_on:
|
||||
langgraph-postgres:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "{debugger_port}:3968"
|
||||
environment:
|
||||
VITE_STUDIO_LOCAL_GRAPH_URL: {debugger_graph_url}
|
||||
langgraph-api:
|
||||
ports:
|
||||
- "8000:8000"
|
||||
@@ -135,7 +122,7 @@ services:
|
||||
test: python /api/healthcheck.py
|
||||
interval: 60s
|
||||
start_interval: 1s
|
||||
start_period: 10s
|
||||
start_period: 60s
|
||||
|
||||
pull_policy: build
|
||||
build:
|
||||
@@ -178,8 +165,6 @@ def test_prepare_args_and_stdin_with_image() -> None:
|
||||
Config(dependencies=[".", "../../.."], graphs={"agent": "agent.py:graph"})
|
||||
)
|
||||
port = 8000
|
||||
debugger_port = 8001
|
||||
debugger_graph_url = f"http://127.0.0.1:{port}"
|
||||
|
||||
actual_args, actual_stdin = prepare_args_and_stdin(
|
||||
capabilities=DEFAULT_DOCKER_CAPABILITIES,
|
||||
@@ -187,8 +172,6 @@ def test_prepare_args_and_stdin_with_image() -> None:
|
||||
config=config,
|
||||
docker_compose=pathlib.Path("custom-docker-compose.yml"),
|
||||
port=port,
|
||||
debugger_port=debugger_port,
|
||||
debugger_base_url=debugger_graph_url,
|
||||
watch=True,
|
||||
image="my-cool-image",
|
||||
)
|
||||
@@ -233,16 +216,6 @@ services:
|
||||
retries: 5
|
||||
interval: 60s
|
||||
start_interval: 1s
|
||||
langgraph-debugger:
|
||||
image: langchain/langgraph-debugger
|
||||
restart: on-failure
|
||||
depends_on:
|
||||
langgraph-postgres:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "{debugger_port}:3968"
|
||||
environment:
|
||||
VITE_STUDIO_LOCAL_GRAPH_URL: {debugger_graph_url}
|
||||
langgraph-api:
|
||||
ports:
|
||||
- "8000:8000"
|
||||
@@ -259,7 +232,7 @@ services:
|
||||
test: python /api/healthcheck.py
|
||||
interval: 60s
|
||||
start_interval: 1s
|
||||
start_period: 10s
|
||||
start_period: 60s
|
||||
|
||||
|
||||
develop:
|
||||
@@ -289,6 +262,82 @@ def test_version_option() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_up_help_shows_hosted_studio_options() -> None:
|
||||
result = CliRunner().invoke(cli, ["up", "--help"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "--studio-url" in result.output
|
||||
assert "--api-url" in result.output
|
||||
assert "--debugger-port" not in result.output
|
||||
assert "--debugger-base-url" not in result.output
|
||||
|
||||
|
||||
def test_studio_link_defaults_to_hosted_studio() -> None:
|
||||
assert _studio_link(
|
||||
port=8123,
|
||||
studio_url=None,
|
||||
api_url=None,
|
||||
debugger_base_url=None,
|
||||
) == ("https://smith.langchain.com/studio/?baseUrl=http%3A%2F%2F127.0.0.1%3A8123")
|
||||
|
||||
|
||||
def test_studio_link_supports_self_hosted_and_remote_urls() -> None:
|
||||
assert _studio_link(
|
||||
port=8123,
|
||||
studio_url="https://langsmith.example.com/prefix/",
|
||||
api_url="https://api.example.com/graph?tenant=a®ion=eu",
|
||||
debugger_base_url=None,
|
||||
) == (
|
||||
"https://langsmith.example.com/prefix/studio/"
|
||||
"?baseUrl=https%3A%2F%2Fapi.example.com%2Fgraph%3Ftenant%3Da%26region%3Deu"
|
||||
)
|
||||
|
||||
|
||||
def test_studio_link_supports_deprecated_debugger_base_url(capsys) -> None:
|
||||
assert _studio_link(
|
||||
port=8123,
|
||||
studio_url=None,
|
||||
api_url=None,
|
||||
debugger_base_url="https://api.example.com",
|
||||
).endswith("?baseUrl=https%3A%2F%2Fapi.example.com")
|
||||
assert "--debugger-base-url is deprecated; use --api-url" in capsys.readouterr().err
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("studio_url", "api_url"),
|
||||
[
|
||||
("javascript:alert(1)", None),
|
||||
("https://user:password@example.com", None),
|
||||
("https://smith.langchain.com?workspace=test", None),
|
||||
(None, "file:///tmp/langgraph.sock"),
|
||||
(None, "https://user:password@example.com"),
|
||||
],
|
||||
)
|
||||
def test_studio_link_rejects_unsafe_urls(
|
||||
studio_url: str | None, api_url: str | None
|
||||
) -> None:
|
||||
with pytest.raises(click.UsageError):
|
||||
_studio_link(
|
||||
port=8123,
|
||||
studio_url=studio_url,
|
||||
api_url=api_url,
|
||||
debugger_base_url=None,
|
||||
)
|
||||
|
||||
|
||||
def test_studio_link_rejects_conflicting_api_url_aliases() -> None:
|
||||
with pytest.raises(
|
||||
click.UsageError,
|
||||
match="cannot specify different URLs",
|
||||
):
|
||||
_studio_link(
|
||||
port=8123,
|
||||
studio_url=None,
|
||||
api_url="https://api.example.com",
|
||||
debugger_base_url="https://other.example.com",
|
||||
)
|
||||
|
||||
|
||||
def test_top_level_help_shows_deploy_subcommands() -> None:
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ DEFAULT_DOCKER_CAPABILITIES = DockerCapabilities(
|
||||
)
|
||||
|
||||
|
||||
def test_compose_with_no_debugger_and_custom_db():
|
||||
def test_compose_with_custom_db():
|
||||
port = 8123
|
||||
custom_postgres_uri = "custom_postgres_uri"
|
||||
actual_compose_str = compose(
|
||||
@@ -42,7 +42,7 @@ def test_compose_with_no_debugger_and_custom_db():
|
||||
assert clean_empty_lines(actual_compose_str) == expected_compose_str
|
||||
|
||||
|
||||
def test_compose_with_no_debugger_and_custom_db_with_healthcheck():
|
||||
def test_compose_with_custom_db_and_healthcheck():
|
||||
port = 8123
|
||||
custom_postgres_uri = "custom_postgres_uri"
|
||||
actual_compose_str = compose(
|
||||
@@ -71,39 +71,11 @@ def test_compose_with_no_debugger_and_custom_db_with_healthcheck():
|
||||
test: python /api/healthcheck.py
|
||||
interval: 60s
|
||||
start_interval: 1s
|
||||
start_period: 10s"""
|
||||
start_period: 60s"""
|
||||
assert clean_empty_lines(actual_compose_str) == expected_compose_str
|
||||
|
||||
|
||||
def test_compose_with_debugger_and_custom_db():
|
||||
port = 8123
|
||||
custom_postgres_uri = "custom_postgres_uri"
|
||||
actual_compose_str = compose(
|
||||
DEFAULT_DOCKER_CAPABILITIES,
|
||||
port=port,
|
||||
postgres_uri=custom_postgres_uri,
|
||||
)
|
||||
expected_compose_str = f"""services:
|
||||
langgraph-redis:
|
||||
image: redis:6
|
||||
healthcheck:
|
||||
test: redis-cli ping
|
||||
interval: 5s
|
||||
timeout: 1s
|
||||
retries: 5
|
||||
langgraph-api:
|
||||
ports:
|
||||
- "{port}:8000"
|
||||
depends_on:
|
||||
langgraph-redis:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
REDIS_URI: redis://langgraph-redis:6379
|
||||
POSTGRES_URI: {custom_postgres_uri}"""
|
||||
assert clean_empty_lines(actual_compose_str) == expected_compose_str
|
||||
|
||||
|
||||
def test_compose_with_debugger_and_default_db():
|
||||
def test_compose_with_default_db():
|
||||
port = 8123
|
||||
actual_compose_str = compose(DEFAULT_DOCKER_CAPABILITIES, port=port)
|
||||
expected_compose_str = f"""volumes:
|
||||
@@ -302,72 +274,6 @@ def test_compose_with_api_version_and_custom_postgres():
|
||||
assert clean_empty_lines(actual_compose_str) == expected_compose_str
|
||||
|
||||
|
||||
def test_compose_with_api_version_and_debugger():
|
||||
"""Test compose function with api_version and debugger port."""
|
||||
port = 8123
|
||||
debugger_port = 8001
|
||||
api_version = "0.2.74"
|
||||
|
||||
actual_compose_str = compose(
|
||||
DEFAULT_DOCKER_CAPABILITIES,
|
||||
port=port,
|
||||
api_version=api_version,
|
||||
debugger_port=debugger_port,
|
||||
)
|
||||
|
||||
expected_compose_str = f"""volumes:
|
||||
langgraph-data:
|
||||
driver: local
|
||||
services:
|
||||
langgraph-redis:
|
||||
image: redis:6
|
||||
healthcheck:
|
||||
test: redis-cli ping
|
||||
interval: 5s
|
||||
timeout: 1s
|
||||
retries: 5
|
||||
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
|
||||
interval: 5s
|
||||
langgraph-debugger:
|
||||
image: langchain/langgraph-debugger
|
||||
restart: on-failure
|
||||
depends_on:
|
||||
langgraph-postgres:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "{debugger_port}:3968"
|
||||
langgraph-api:
|
||||
ports:
|
||||
- "{port}:8000"
|
||||
depends_on:
|
||||
langgraph-redis:
|
||||
condition: service_healthy
|
||||
langgraph-postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
REDIS_URI: redis://langgraph-redis:6379
|
||||
POSTGRES_URI: {DEFAULT_POSTGRES_URI}"""
|
||||
assert clean_empty_lines(actual_compose_str) == expected_compose_str
|
||||
|
||||
|
||||
def test_compose_distributed_mode_with_custom_db():
|
||||
"""Test compose with engine_runtime_mode='distributed' adds N_JOBS_PER_WORKER=0."""
|
||||
port = 8123
|
||||
|
||||
@@ -39,15 +39,6 @@
|
||||
- `client.threads.stream()` now accepts `transport="sse"` (default) or
|
||||
`transport="websocket"` in place of the previous transport-agnostic default.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Resource-scoped auth decorators now honor `actions=` and reject empty or
|
||||
invalid action lists. Because unmatched custom-auth paths remain allowed,
|
||||
deployments using action-scoped handlers should configure a global
|
||||
default-deny handler; `langgraph-api` 0.10+ warns about uncovered paths at
|
||||
startup. Resource-specific decorators retain matching `resources=` selectors
|
||||
for backward compatibility; use `@auth.on(resources=...)` for other resources.
|
||||
|
||||
### Notes
|
||||
|
||||
- The v3 streaming surface (`AsyncThreadStream`, `SyncThreadStream`, and all
|
||||
|
||||
@@ -3,7 +3,7 @@ from langgraph_sdk.client import get_client, get_sync_client
|
||||
from langgraph_sdk.encryption import Encryption
|
||||
from langgraph_sdk.encryption.types import DecryptResult, EncryptionContext
|
||||
|
||||
__version__ = "0.4.4"
|
||||
__version__ = "0.4.3"
|
||||
|
||||
__all__ = [
|
||||
"Auth",
|
||||
|
||||
@@ -24,7 +24,7 @@ from langchain_core.language_models.chat_model_stream import AsyncChatModelStrea
|
||||
from langchain_protocol import Event, SubscribeParams
|
||||
|
||||
from langgraph_sdk._async.http import HttpClient
|
||||
from langgraph_sdk.schema import LangSmithTracing, QueryParamTypes
|
||||
from langgraph_sdk.schema import QueryParamTypes
|
||||
from langgraph_sdk.stream.controller import _SeenEventIds
|
||||
from langgraph_sdk.stream.decoders import (
|
||||
DataDecoder,
|
||||
@@ -172,7 +172,6 @@ class RunModule:
|
||||
input: Any = None,
|
||||
config: dict[str, Any] | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
langsmith_tracing: LangSmithTracing | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Send `run.start` to the server. Returns the result (`{"run_id": ...}`)."""
|
||||
params: dict[str, Any] = {"assistant_id": self._owner.assistant_id}
|
||||
@@ -182,8 +181,6 @@ class RunModule:
|
||||
params["config"] = config
|
||||
if metadata is not None:
|
||||
params["metadata"] = metadata
|
||||
if langsmith_tracing is not None:
|
||||
params["langsmith_tracer"] = langsmith_tracing
|
||||
loop = asyncio.get_running_loop()
|
||||
gate: asyncio.Future[None] = loop.create_future()
|
||||
self._owner._run_start_ready = gate
|
||||
|
||||
@@ -23,7 +23,7 @@ from langchain_core.language_models.chat_model_stream import ChatModelStream
|
||||
from langchain_protocol import Event, SubscribeParams
|
||||
|
||||
from langgraph_sdk._sync.http import SyncHttpClient
|
||||
from langgraph_sdk.schema import LangSmithTracing, QueryParamTypes
|
||||
from langgraph_sdk.schema import QueryParamTypes
|
||||
from langgraph_sdk.stream.decoders import (
|
||||
DataDecoder,
|
||||
Decoder,
|
||||
@@ -215,7 +215,6 @@ class SyncRunModule:
|
||||
input: Any = None,
|
||||
config: dict[str, Any] | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
langsmith_tracing: LangSmithTracing | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Send `run.start` to the server. Returns the result (`{"run_id": ...}`)."""
|
||||
params: dict[str, Any] = {"assistant_id": self._owner.assistant_id}
|
||||
@@ -225,8 +224,6 @@ class SyncRunModule:
|
||||
params["config"] = config
|
||||
if metadata is not None:
|
||||
params["metadata"] = metadata
|
||||
if langsmith_tracing is not None:
|
||||
params["langsmith_tracer"] = langsmith_tracing
|
||||
result = self._owner._send_command("run.start", params)
|
||||
self._owner._run_seen = True
|
||||
controller = self._owner._controller
|
||||
|
||||
@@ -341,15 +341,9 @@ VUpdate = typing.TypeVar("VUpdate", covariant=True)
|
||||
VRead = typing.TypeVar("VRead", covariant=True)
|
||||
VDelete = typing.TypeVar("VDelete", covariant=True)
|
||||
VSearch = typing.TypeVar("VSearch", covariant=True)
|
||||
ResourceActionT = typing.TypeVar("ResourceActionT", bound=str)
|
||||
|
||||
_ResourceAction = typing.Literal["create", "read", "update", "delete", "search"]
|
||||
_ThreadAction = _ResourceAction | typing.Literal["create_run"]
|
||||
|
||||
|
||||
class _ResourceOn(
|
||||
typing.Generic[VCreate, VRead, VUpdate, VDelete, VSearch, ResourceActionT]
|
||||
):
|
||||
class _ResourceOn(typing.Generic[VCreate, VRead, VUpdate, VDelete, VSearch]):
|
||||
"""
|
||||
Generic base class for resource-specific handlers.
|
||||
"""
|
||||
@@ -398,8 +392,8 @@ class _ResourceOn(
|
||||
def __call__(
|
||||
self,
|
||||
*,
|
||||
resources: str | Sequence[str] | None = None,
|
||||
actions: ResourceActionT | Sequence[ResourceActionT] | None = None,
|
||||
resources: str | Sequence[str],
|
||||
actions: str | Sequence[str] | None = None,
|
||||
) -> Callable[
|
||||
[_ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch]],
|
||||
_ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch],
|
||||
@@ -414,7 +408,7 @@ class _ResourceOn(
|
||||
) = None,
|
||||
*,
|
||||
resources: str | Sequence[str] | None = None,
|
||||
actions: ResourceActionT | Sequence[ResourceActionT] | None = None,
|
||||
actions: str | Sequence[str] | None = None,
|
||||
) -> (
|
||||
_ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch]
|
||||
| Callable[
|
||||
@@ -422,66 +416,24 @@ class _ResourceOn(
|
||||
_ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch],
|
||||
]
|
||||
):
|
||||
if fn is not None:
|
||||
_validate_handler(fn)
|
||||
return typing.cast(
|
||||
"_ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch]",
|
||||
_register_handler(self.auth, self.resource, "*", fn),
|
||||
)
|
||||
|
||||
def decorator(
|
||||
handler: _ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch],
|
||||
) -> _ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch]:
|
||||
_validate_handler(handler)
|
||||
if resources is None:
|
||||
resource_list = [self.resource]
|
||||
elif isinstance(resources, str):
|
||||
resource_list = [resources]
|
||||
elif isinstance(resources, Sequence):
|
||||
resource_list = list(resources)
|
||||
else:
|
||||
raise TypeError("resources must be a string or sequence of strings")
|
||||
if resource_list != [self.resource]:
|
||||
raise ValueError(
|
||||
f"Resource-specific decorator for {self.resource!r} cannot "
|
||||
f"register handlers for {resource_list!r}. Use @auth.on(...) "
|
||||
"for other or multiple resources."
|
||||
)
|
||||
if actions is None:
|
||||
action_list = ["*"]
|
||||
elif isinstance(actions, str):
|
||||
action_list = [actions]
|
||||
elif isinstance(actions, Sequence):
|
||||
action_list = list(actions)
|
||||
else:
|
||||
raise TypeError("actions must be a string or sequence of strings")
|
||||
if not action_list:
|
||||
raise ValueError("actions must not be empty")
|
||||
if not all(isinstance(action, str) for action in action_list):
|
||||
raise TypeError("actions must be a string or sequence of strings")
|
||||
valid_actions = {
|
||||
value.action
|
||||
for value in vars(self).values()
|
||||
if isinstance(value, _ResourceActionOn)
|
||||
}
|
||||
invalid_actions = (
|
||||
sorted(set(action_list) - valid_actions) if actions is not None else []
|
||||
return typing.cast(
|
||||
"_ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch]",
|
||||
_register_handler(self.auth, self.resource, "*", handler),
|
||||
)
|
||||
if invalid_actions:
|
||||
raise ValueError(
|
||||
f"Invalid action(s) for {self.resource}: {', '.join(invalid_actions)}"
|
||||
)
|
||||
if len(action_list) != len(set(action_list)):
|
||||
raise ValueError("actions must not contain duplicates")
|
||||
for action in action_list:
|
||||
if (self.resource, action) in self.auth._handlers:
|
||||
raise ValueError(
|
||||
f"types.Handler already set for {self.resource}, {action}."
|
||||
)
|
||||
for action in action_list:
|
||||
_register_handler(self.auth, self.resource, action, handler)
|
||||
return handler
|
||||
|
||||
if fn is not None:
|
||||
return decorator(
|
||||
typing.cast(
|
||||
"_ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch]",
|
||||
fn,
|
||||
)
|
||||
)
|
||||
# Accept keyword-only parameters for future filtering behavior; referenced to satisfy linters.
|
||||
_ = resources, actions
|
||||
return decorator
|
||||
|
||||
|
||||
@@ -492,7 +444,6 @@ class _AssistantsOn(
|
||||
types.AssistantsUpdate,
|
||||
types.AssistantsDelete,
|
||||
types.AssistantsSearch,
|
||||
_ResourceAction,
|
||||
]
|
||||
):
|
||||
value = (
|
||||
@@ -516,7 +467,6 @@ class _ThreadsOn(
|
||||
types.ThreadsUpdate,
|
||||
types.ThreadsDelete,
|
||||
types.ThreadsSearch,
|
||||
_ThreadAction,
|
||||
]
|
||||
):
|
||||
value = (
|
||||
@@ -552,7 +502,6 @@ class _CronsOn(
|
||||
types.CronsUpdate,
|
||||
types.CronsDelete,
|
||||
types.CronsSearch,
|
||||
_ResourceAction,
|
||||
]
|
||||
):
|
||||
value = type[
|
||||
|
||||
@@ -426,17 +426,11 @@ def test_sync_run_start_sends_command():
|
||||
with httpx.Client(transport=fake.transport, base_url="http://test") as raw:
|
||||
threads = SyncThreadsClient(SyncHttpClient(raw))
|
||||
with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
|
||||
result = thread.run.start(
|
||||
input={"x": 1},
|
||||
langsmith_tracing={"project_name": "replica-project"},
|
||||
)
|
||||
result = thread.run.start(input={"x": 1})
|
||||
|
||||
assert result == {"run_id": "run-1"}
|
||||
assert fake.received_commands[0]["method"] == "run.start"
|
||||
assert fake.received_commands[0]["params"]["assistant_id"] == "agent"
|
||||
assert fake.received_commands[0]["params"]["langsmith_tracer"] == {
|
||||
"project_name": "replica-project"
|
||||
}
|
||||
|
||||
|
||||
def test_sync_events_iterates_raw_events():
|
||||
|
||||
@@ -287,7 +287,7 @@ async def test_command_ids_are_monotonic():
|
||||
assert [c["id"] for c in fake.received_commands] == [1, 2]
|
||||
|
||||
|
||||
async def test_run_start_forwards_config_metadata_and_langsmith_tracing():
|
||||
async def test_run_start_forwards_config_and_metadata():
|
||||
fake = FakeServer()
|
||||
transport = httpx.ASGITransport(app=fake.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as raw:
|
||||
@@ -297,18 +297,10 @@ async def test_run_start_forwards_config_metadata_and_langsmith_tracing():
|
||||
input={"x": 1},
|
||||
config={"recursion_limit": 5},
|
||||
metadata={"trace": "abc"},
|
||||
langsmith_tracing={
|
||||
"project_name": "replica-project",
|
||||
"example_id": "example-1",
|
||||
},
|
||||
)
|
||||
params = fake.received_commands[0]["params"]
|
||||
assert params["config"] == {"recursion_limit": 5}
|
||||
assert params["metadata"] == {"trace": "abc"}
|
||||
assert params["langsmith_tracer"] == {
|
||||
"project_name": "replica-project",
|
||||
"example_id": "example-1",
|
||||
}
|
||||
|
||||
|
||||
async def test_run_start_raises_outside_context_manager():
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
import pytest
|
||||
|
||||
from langgraph_sdk import Auth
|
||||
|
||||
|
||||
def test_handler_multiple_resources_and_actions() -> None:
|
||||
auth = Auth()
|
||||
|
||||
@auth.on(resources=["threads", "assistants"], actions=["read", "search"])
|
||||
async def allow_reads(ctx, value):
|
||||
del value
|
||||
return {"owner": ctx.user.identity}
|
||||
|
||||
assert auth._handlers == {
|
||||
("threads", "read"): [allow_reads],
|
||||
("threads", "search"): [allow_reads],
|
||||
("assistants", "read"): [allow_reads],
|
||||
("assistants", "search"): [allow_reads],
|
||||
}
|
||||
|
||||
|
||||
def test_resource_handler_actions_are_scoped() -> None:
|
||||
auth = Auth()
|
||||
|
||||
@auth.on
|
||||
async def deny_all(ctx, value):
|
||||
del ctx, value
|
||||
return False
|
||||
|
||||
@auth.on.threads(actions=["create", "search"])
|
||||
async def handler(ctx, value):
|
||||
del ctx, value
|
||||
return None
|
||||
|
||||
@auth.on.threads(actions="create_run")
|
||||
async def run_handler(ctx, value):
|
||||
del ctx, value
|
||||
return None
|
||||
|
||||
assert auth._handlers == {
|
||||
("threads", "create"): [handler],
|
||||
("threads", "search"): [handler],
|
||||
("threads", "create_run"): [run_handler],
|
||||
}
|
||||
assert auth._global_handlers == [deny_all]
|
||||
|
||||
|
||||
def test_resource_handler_preserves_wildcard() -> None:
|
||||
auth = Auth()
|
||||
|
||||
@auth.on.threads
|
||||
async def handler(ctx, value):
|
||||
del ctx, value
|
||||
return None
|
||||
|
||||
assert auth._handlers == {("threads", "*"): [handler]}
|
||||
|
||||
|
||||
def test_resource_handler_preserves_wildcard_with_parentheses() -> None:
|
||||
auth = Auth()
|
||||
|
||||
@auth.on.threads()
|
||||
async def handler(ctx, value):
|
||||
del ctx, value
|
||||
return None
|
||||
|
||||
assert auth._handlers == {("threads", "*"): [handler]}
|
||||
|
||||
|
||||
def test_resource_handler_accepts_matching_resource() -> None:
|
||||
auth = Auth()
|
||||
|
||||
@auth.on.threads(resources=["threads"], actions="read")
|
||||
async def handler(ctx, value):
|
||||
del ctx, value
|
||||
return None
|
||||
|
||||
assert auth._handlers == {("threads", "read"): [handler]}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"resources", [["assistants"], ["threads", "assistants"], [], [1]]
|
||||
)
|
||||
def test_resource_handler_rejects_nonmatching_resources(resources) -> None:
|
||||
auth = Auth()
|
||||
|
||||
async def handler(ctx, value):
|
||||
del ctx, value
|
||||
return None
|
||||
|
||||
with pytest.raises(ValueError, match=r"Use @auth\.on"):
|
||||
auth.on.threads(resources=resources)(handler)
|
||||
assert auth._handlers == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("resource", "actions", "error"),
|
||||
[
|
||||
("threads", [], ValueError),
|
||||
("threads", ["reed"], ValueError),
|
||||
("threads", ["create", "create"], ValueError),
|
||||
("threads", {"create": True}, TypeError),
|
||||
("crons", ["create_run"], ValueError),
|
||||
],
|
||||
)
|
||||
def test_resource_handler_rejects_invalid_actions(resource, actions, error) -> None:
|
||||
auth = Auth()
|
||||
|
||||
async def handler(ctx, value):
|
||||
del ctx, value
|
||||
return None
|
||||
|
||||
with pytest.raises(error):
|
||||
getattr(auth.on, resource)(actions=actions)(handler)
|
||||
assert auth._handlers == {}
|
||||
|
||||
|
||||
def test_resource_handler_registration_is_atomic() -> None:
|
||||
auth = Auth()
|
||||
|
||||
@auth.on.threads.read
|
||||
async def read_handler(ctx, value):
|
||||
del ctx, value
|
||||
return None
|
||||
|
||||
async def handler(ctx, value):
|
||||
del ctx, value
|
||||
return None
|
||||
|
||||
with pytest.raises(ValueError, match="already set"):
|
||||
auth.on.threads(actions=["create", "read"])(handler)
|
||||
assert auth._handlers == {("threads", "read"): [read_handler]}
|
||||
Reference in New Issue
Block a user