Compare commits

..
Author SHA1 Message Date
dependabot[bot]andGitHub 4d2f891448 chore(deps): bump dorny/paths-filter in the minor-and-patch group
Bumps the minor-and-patch group with 1 update: [dorny/paths-filter](https://github.com/dorny/paths-filter).


Updates `dorny/paths-filter` from 4.0.2 to 4.0.3
- [Release notes](https://github.com/dorny/paths-filter/releases)
- [Changelog](https://github.com/dorny/paths-filter/blob/master/CHANGELOG.md)
- [Commits](https://github.com/dorny/paths-filter/compare/7b450fff21473bca461d4b92ce414b9d0420d706...ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d)

---
updated-dependencies:
- dependency-name: dorny/paths-filter
  dependency-version: 4.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-01 06:10:15 +00:00
11ee185999 fix(checkpoint): widen Store put value type to Mapping[str, Any] (#8617)
TypedDict values don't structurally satisfy dict[str, Any] since dict
implies full mutability. Mapping[str, Any] accepts both plain dicts and
TypedDicts while still requiring string keys, matching what put()
actually needs from callers.

Fixes #8616

Verified by running lint/type/test locally across checkpoint,
checkpoint-sqlite, checkpoint-postgres, prebuilt, sdk-py, and a scoped
langgraph subset.

LinkedIn: https://linkedin.com/in/lisandro-navarra

---------

Co-authored-by: Mason Daugherty <github@mdrxy.com>
2026-08-28 08:05:04 -05:00
Sreekara YachamaneniandGitHub d5f4b2aa96 release(sdk-py): 0.4.4 (#8738)
Bumps the Python SDK version from 0.4.3 to 0.4.4.
2026-08-27 17:14:54 -04:00
Sreekara YachamaneniandGitHub 5a77be5e8b Merge commit from fork
* authz fix for custom auth

* add back resource param

* auth on multiple resources
2026-08-27 13:28:59 -07:00
Mason DaughertyGitHubopen-swe[bot] <open-swe@users.noreply.github.com>
bdb8a9c7a4 feat: route LangSmith traces from thread streams (#8723)
## Description
Expose the existing `langsmith_tracing` option on Python sync and async
thread-stream run starts and forward it through the protocol.

## Release Note
Python thread streams can route traces to an additional LangSmith
project per run.

## Test Plan
- [x] Verify sync and async run-start payloads include tracing settings

## Related PRs
- langchain-ai/agent-protocol#95
- langchain-ai/langgraphjs#2745
- langchain-ai/langgraph-api#4033

Made by [Open
SWE](https://openswe.vercel.app/agents/f9e34294-b9c3-52f0-815a-0102188e1181)

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
2026-08-26 16:57:39 -04:00
21 changed files with 338 additions and 480 deletions
+28 -33
View File
@@ -17,9 +17,6 @@ jobs:
python-version:
- "3.10"
- "3.14"
build_mode:
- layered
- flat
example:
- name: A
workdir: libs/cli/examples
@@ -33,11 +30,9 @@ jobs:
- name: D
workdir: libs/cli/examples/graphs_reqs_b
tag: langgraph-test-d
name: "CLI integration test (${{ matrix.python-version }}, ${{ matrix.example.name }}, ${{ matrix.build_mode }})"
name: "CLI integration test"
env:
BUILD_ARGS: ${{ matrix.build_mode == 'flat' && '--flat' || '' }}
HAS_LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY != '' }}
TAG_SUFFIX: ${{ matrix.build_mode == 'flat' && '-flat' || '' }}
defaults:
run:
working-directory: libs/cli
@@ -63,7 +58,7 @@ jobs:
if: (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch')
working-directory: ${{ matrix.example.workdir }}
run: |
langgraph build $BUILD_ARGS -t ${{ matrix.example.tag }}$TAG_SUFFIX
langgraph build -t ${{ matrix.example.tag }}
- name: Test service ${{ matrix.example.name }}
if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&env.HAS_LANGSMITH_API_KEY == 'true' }}
working-directory: ${{ matrix.example.workdir }}
@@ -76,98 +71,98 @@ jobs:
if [ -f ../.env ]; then echo "LANGSMITH_API_KEY=${{ secrets.LANGSMITH_API_KEY }}" >> ../.env; fi
# Run the integration test using the built tag
REPO_ROOT=$(git rev-parse --show-toplevel)
timeout 60 python "$REPO_ROOT/.github/scripts/run_langgraph_cli_test.py" -t ${{ matrix.example.tag }}$TAG_SUFFIX
timeout 60 python "$REPO_ROOT/.github/scripts/run_langgraph_cli_test.py" -t ${{ matrix.example.tag }}
- name: Build JS service
if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') && matrix.example.name == 'A' }}
if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' }}
working-directory: libs/cli/js-examples
run: |
langgraph build $BUILD_ARGS -t langgraph-test-e$TAG_SUFFIX
langgraph build -t langgraph-test-e
- name: Build JS monorepo service
if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') && matrix.example.name == 'A' }}
if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' }}
working-directory: libs/cli/js-monorepo-example
run: |
langgraph build $BUILD_ARGS -t langgraph-test-f$TAG_SUFFIX -c apps/agent/langgraph.json --build-command "yarn run turbo build" --install-command "yarn install"
langgraph build -t langgraph-test-f -c apps/agent/langgraph.json --build-command "yarn run turbo build" --install-command "yarn install"
- name: Build Python monorepo service
if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') && matrix.example.name == 'A' }}
if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' }}
working-directory: libs/cli/python-monorepo-example
run: |
langgraph build $BUILD_ARGS -t langgraph-test-g$TAG_SUFFIX -c apps/agent/langgraph.json
langgraph build -t langgraph-test-g -c apps/agent/langgraph.json
- name: Test Python monorepo service
if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') && matrix.example.name == 'A' && env.HAS_LANGSMITH_API_KEY == 'true' }}
if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' && env.HAS_LANGSMITH_API_KEY == 'true' }}
working-directory: libs/cli/python-monorepo-example
env:
LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }}
run: |
cp apps/agent/.env.example apps/agent/.env
echo "LANGSMITH_API_KEY=${{ secrets.LANGSMITH_API_KEY }}" >> apps/agent/.env
timeout 60 python ../../../.github/scripts/run_langgraph_cli_test.py -t langgraph-test-g$TAG_SUFFIX -c apps/agent/langgraph.json
timeout 60 python ../../../.github/scripts/run_langgraph_cli_test.py -t langgraph-test-g -c apps/agent/langgraph.json
- name: Build prerelease reqs service
if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') && matrix.example.name == 'A' }}
if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' }}
working-directory: libs/cli/examples/graph_prerelease_reqs
run: |
langgraph build $BUILD_ARGS -t langgraph-test-h$TAG_SUFFIX
langgraph build -t langgraph-test-h
- name: Test prerelease reqs service
if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') && matrix.example.name == 'A' && env.HAS_LANGSMITH_API_KEY == 'true' }}
if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' && env.HAS_LANGSMITH_API_KEY == 'true' }}
working-directory: libs/cli/examples/graph_prerelease_reqs
env:
LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }}
run: |
cp ../.env.example .env
echo "LANGSMITH_API_KEY=${{ secrets.LANGSMITH_API_KEY }}" >> .env
timeout 60 python ../../../../.github/scripts/run_langgraph_cli_test.py -t langgraph-test-h$TAG_SUFFIX
timeout 60 python ../../../../.github/scripts/run_langgraph_cli_test.py -t langgraph-test-h
echo "Finished starting up langgraph-test-h"
LANGGRAPH_VERSION=$(docker run --rm --entrypoint "" langgraph-test-h$TAG_SUFFIX python -c "import sys; from importlib.metadata import version; v = version('langgraph'); print(v);")
LANGGRAPH_VERSION=$(docker run --rm --entrypoint "" langgraph-test-h python -c "import sys; from importlib.metadata import version; v = version('langgraph'); print(v);")
if [ "$LANGGRAPH_VERSION" != "1.1.5" ]; then
echo "LANGGRAPH_VERSION != 1.1.5; $LANGGRAPH_VERSION"
exit 1
fi
LANGCHAIN_OPENAI_VERSION=$(docker run --rm --entrypoint "" langgraph-test-h$TAG_SUFFIX python -c "import sys; from importlib.metadata import version; v = version('langchain-openai'); print(v);")
LANGCHAIN_OPENAI_VERSION=$(docker run --rm --entrypoint "" langgraph-test-h python -c "import sys; from importlib.metadata import version; v = version('langchain-openai'); print(v);")
if [ "$LANGCHAIN_OPENAI_VERSION" != "1.1.14" ]; then
echo "LANGCHAIN_OPENAI_VERSION != 1.1.14; $LANGCHAIN_OPENAI_VERSION"
exit 1
fi
LANGCHAIN_ANTHROPIC_VERSION=$(docker run --rm --entrypoint "" langgraph-test-h$TAG_SUFFIX python -c "import sys; from importlib.metadata import version; v = version('langchain-anthropic'); print(v);")
LANGCHAIN_ANTHROPIC_VERSION=$(docker run --rm --entrypoint "" langgraph-test-h python -c "import sys; from importlib.metadata import version; v = version('langchain-anthropic'); print(v);")
if [ "$LANGCHAIN_ANTHROPIC_VERSION" != "1.4.6" ]; then
echo "LANGCHAIN_ANTHROPIC_VERSION != 1.4.6; $LANGCHAIN_ANTHROPIC_VERSION"
exit 1
fi
- name: Build and test prerelease reqs fail service
if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') && matrix.example.name == 'A' }}
if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' }}
working-directory: libs/cli/examples/graph_prerelease_reqs_fail
run: |
langgraph build $BUILD_ARGS -t langgraph-test-i$TAG_SUFFIX || [ $? -eq 1 ]
langgraph build -t langgraph-test-i || [ $? -eq 1 ]
- name: Build uv simple service
if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') && matrix.example.name == 'A' }}
if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' }}
working-directory: libs/cli/uv-examples/simple
run: |
langgraph build $BUILD_ARGS -t langgraph-test-uv-simple$TAG_SUFFIX
langgraph build -t langgraph-test-uv-simple
- name: Test uv simple service
if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') && matrix.example.name == 'A' && env.HAS_LANGSMITH_API_KEY == 'true' }}
if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' && env.HAS_LANGSMITH_API_KEY == 'true' }}
working-directory: libs/cli/uv-examples/simple
env:
LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }}
run: |
cp .env.example .env
echo "LANGSMITH_API_KEY=${{ secrets.LANGSMITH_API_KEY }}" >> .env
timeout 60 python ../../../../.github/scripts/run_langgraph_cli_test.py -t langgraph-test-uv-simple$TAG_SUFFIX
timeout 60 python ../../../../.github/scripts/run_langgraph_cli_test.py -t langgraph-test-uv-simple
- name: Build uv monorepo service
if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') && matrix.example.name == 'A' }}
if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' }}
working-directory: libs/cli/uv-examples/monorepo/apps/agent
run: |
langgraph build $BUILD_ARGS -t langgraph-test-uv-monorepo$TAG_SUFFIX
langgraph build -t langgraph-test-uv-monorepo
- name: Test uv monorepo service
if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') && matrix.example.name == 'A' && env.HAS_LANGSMITH_API_KEY == 'true' }}
if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' && env.HAS_LANGSMITH_API_KEY == 'true' }}
working-directory: libs/cli/uv-examples/monorepo/apps/agent
env:
LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }}
run: |
cp .env.example .env
echo "LANGSMITH_API_KEY=${{ secrets.LANGSMITH_API_KEY }}" >> .env
timeout 60 python ../../../../../../.github/scripts/run_langgraph_cli_test.py -t langgraph-test-uv-monorepo$TAG_SUFFIX
timeout 60 python ../../../../../../.github/scripts/run_langgraph_cli_test.py -t langgraph-test-uv-monorepo
+1 -1
View File
@@ -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@7b450fff21473bca461d4b92ce414b9d0420d706 # v4
- uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4
if: github.event_name != 'workflow_dispatch'
id: filter
with:
@@ -7,7 +7,7 @@ import logging
import re
import threading
from collections import defaultdict
from collections.abc import Callable, Iterable, Iterator, Sequence
from collections.abc import Callable, Iterable, Iterator, Mapping, 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(cast(dict, op.value)),
Jsonb(dict(cast(Mapping[str, Any], 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, Sequence
from collections.abc import Callable, Iterable, Iterator, Mapping, 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(cast(dict, op.value)),
orjson.dumps(dict(cast(Mapping[str, Any], 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
from collections.abc import Iterable, Mapping
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: dict[str, Any] | None
value: Mapping[str, Any] | None
"""The data to store, or `None` to mark the item for deletion.
The value must be a dictionary with string keys and JSON-serializable values.
The value must be a mapping 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: dict[str, Any],
value: Mapping[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: Dictionary containing the item's data. Must contain string keys
value: Mapping 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: dict[str, Any],
value: Mapping[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: Dictionary containing the item's data. Must contain string keys
value: Mapping 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
from collections.abc import Callable, Iterable, Mapping
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: dict[str, Any],
value: Mapping[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: dict[str, Any],
value: Mapping[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, Sequence
from collections.abc import Awaitable, Callable, Mapping, Sequence
from typing import Any
from langchain_core.embeddings import Embeddings
@@ -244,6 +244,9 @@ 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=op.value,
value=dict(op.value),
key=key,
namespace=namespace,
created_at=datetime.now(timezone.utc),
+15 -1
View File
@@ -1,7 +1,9 @@
import asyncio
import json
from collections.abc import Iterable
from collections import UserDict
from collections.abc import Iterable, Mapping
from datetime import datetime
from types import MappingProxyType
from typing import Any
import pytest
@@ -137,6 +139,18 @@ 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()
-8
View File
@@ -408,12 +408,6 @@ For production use, requires a license key in env var LANGGRAPH_CLOUD_LICENSE_KE
"--build-command",
help="Custom build command to run from the langgraph.json directory. If not provided, uses default build process.",
)
@click.option(
"--flat",
is_flag=True,
envvar="LANGGRAPH_CLI_FLAT_IMAGES",
help="Flatten generated build steps into one image layer when supported.",
)
@click.argument("docker_build_args", nargs=-1, type=click.UNPROCESSED)
@cli.command(
help="📦 Build LangGraph API server Docker image.",
@@ -432,7 +426,6 @@ def build(
tag: str,
install_command: str | None,
build_command: str | None,
flat: bool,
):
if install_command and langgraph_cli.config.has_disallowed_build_command_content(
install_command
@@ -468,7 +461,6 @@ def build(
docker_build_args,
install_command,
build_command,
flat=flat,
)
+42 -292
View File
@@ -1162,208 +1162,6 @@ def _build_runtime_env_vars(config: Config) -> list[str]:
return env_vars
def _faux_package_pyproject_command(package_name: str) -> str:
pyproject_path = shlex.quote(f"/deps/outer-{package_name}/pyproject.toml")
return f"""cat > {pyproject_path} <<'PYPROJECT'
[project]
name = {json.dumps(package_name)}
version = "0.1"
[tool.setuptools.package-data]
"*" = ["**/*"]
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
PYPROJECT"""
_FLAT_BUILD_CONTEXT = pathlib.PurePosixPath("/__build_context")
_FLAT_ADDITIONAL_CONTEXTS = pathlib.PurePosixPath("/__additional_contexts")
_DEFAULT_NODE_BUILD_COMMAND = (
'(test ! -f /api/langgraph_api/js/build.mts && echo "Prebuild script not found, '
'skipping") || tsx /api/langgraph_api/js/build.mts'
)
def _render_flat_dockerfile(
*,
image: str,
dockerfile_lines: list[str],
env_vars: list[str],
commands: list[str],
workdir: str | None,
additional_context_names: dict[pathlib.Path, str] | None = None,
) -> str:
mounts = [f"--mount=type=bind,target={_FLAT_BUILD_CONTEXT},readonly"]
mounts.extend(
f"--mount=type=bind,from={name},"
f"target={_FLAT_ADDITIONAL_CONTEXTS / name},readonly"
for name in (additional_context_names or {}).values()
)
return os.linesep.join(
[
"# syntax=docker/dockerfile:1.7",
f"FROM {image}",
*dockerfile_lines,
*env_vars,
f"RUN {' '.join(mounts)} <<'USER_LAYER'",
*commands,
"USER_LAYER",
f"WORKDIR {workdir}" if workdir else "",
]
)
def _flat_context_source(
path: pathlib.Path,
relative_path: str,
additional_context_names: dict[pathlib.Path, str],
*,
additional_relative_path: str = ".",
) -> str:
if name := additional_context_names.get(path):
return str(_FLAT_ADDITIONAL_CONTEXTS / name / additional_relative_path)
return str(_FLAT_BUILD_CONTEXT / relative_path)
def _copy_directory_command(source: str, destination: str) -> str:
return f"""mkdir -p {shlex.quote(destination)}
cp -a {shlex.quote(f"{source}/.")} {shlex.quote(destination)}/"""
def _flat_local_dependency_commands(
*,
config_path: pathlib.Path,
local_deps: LocalDeps,
local_reqs_pip_install: str,
additional_context_names: dict[pathlib.Path, str],
) -> list[str]:
commands = []
for reqpath, destination in local_deps.pip_reqs:
source = _flat_context_source(
reqpath.parent,
str(reqpath.relative_to(config_path.parent)),
additional_context_names,
additional_relative_path=reqpath.name,
)
destination_parent = pathlib.PurePosixPath(destination).parent
commands.append(f"""mkdir -p {shlex.quote(str(destination_parent))}
cp {shlex.quote(source)} {shlex.quote(destination)}""")
if local_deps.pip_reqs:
requirements = " ".join(
f"-r {destination}" for _, destination in local_deps.pip_reqs
)
commands.append(f"{local_reqs_pip_install} {requirements}")
for full_path, (relative_path, name) in local_deps.real_pkgs.items():
source = _flat_context_source(
full_path, relative_path, additional_context_names
)
commands.append(_copy_directory_command(source, f"/deps/{name}"))
for full_path, (relative_path, destination) in local_deps.faux_pkgs.items():
source = _flat_context_source(
full_path, relative_path, additional_context_names
)
pyproject = _faux_package_pyproject_command(full_path.name)
commands.append(f"{_copy_directory_command(source, destination)}\n{pyproject}")
return commands
def _build_flat_python_commands(
*,
config_path: pathlib.Path,
config: Config,
local_deps: LocalDeps,
pypi_deps: list[str],
local_reqs_pip_install: str,
local_deps_install_command: str,
pip_cleanup: str,
additional_context_names: dict[pathlib.Path, str],
js_working_dir: str | None,
) -> list[str]:
commands = ["set -eu"]
if js_working_dir:
commands.append("/storage/install-node.sh")
if pip_config_file := config.get("pip_config_file"):
source = shlex.quote(str(_FLAT_BUILD_CONTEXT / pip_config_file))
commands.append(f"cp {source} /pipconfig.txt")
if pypi_deps:
commands.append(f"{local_reqs_pip_install} {' '.join(pypi_deps)}")
commands.extend(
_flat_local_dependency_commands(
config_path=config_path,
local_deps=local_deps,
local_reqs_pip_install=local_reqs_pip_install,
additional_context_names=additional_context_names,
)
)
commands.append(local_deps_install_command)
if js_working_dir:
working_dir = shlex.quote(js_working_dir)
commands.append(
f"cd {working_dir}\n"
f"{_get_node_pm_install_cmd(config_path.parent)} "
"&& tsx /api/langgraph_api/js/build.mts"
)
commands.extend(
line.removeprefix("RUN ")
for line in pip_cleanup.splitlines()
if line and not line.startswith("#")
)
return commands
def _build_flat_python_dockerfile(
config_path: pathlib.Path,
config: Config,
local_deps: LocalDeps,
pypi_deps: list[str],
local_reqs_pip_install: str,
local_deps_install_command: str,
pip_cleanup: str,
image: str,
env_vars: list[str],
additional_context_names: dict[pathlib.Path, str],
) -> str:
js_working_dir = (
local_deps.working_dir
if config.get("ui") or config.get("node_version")
else None
)
commands = _build_flat_python_commands(
config_path=config_path,
config=config,
local_deps=local_deps,
pypi_deps=pypi_deps,
local_reqs_pip_install=local_reqs_pip_install,
local_deps_install_command=local_deps_install_command,
pip_cleanup=pip_cleanup,
additional_context_names=additional_context_names,
js_working_dir=js_working_dir,
)
if js_working_dir:
env_vars = [
f"ENV NODE_VERSION={config.get('node_version') or DEFAULT_NODE_VERSION}",
*env_vars,
]
return _render_flat_dockerfile(
image=image,
dockerfile_lines=config["dockerfile_lines"],
env_vars=env_vars,
commands=commands,
workdir=local_deps.working_dir,
additional_context_names=additional_context_names,
)
def _get_node_pm_install_cmd(project_dir: pathlib.Path) -> str:
def test_file(file_name):
full_path = project_dir / file_name
@@ -1469,7 +1267,6 @@ def python_config_to_docker(
api_version: str | None = None,
*,
escape_variables: bool = False,
flat: bool = False,
) -> tuple[str, dict[str, str]]:
"""Generate a Dockerfile from the configuration."""
source_kind = _get_source_kind(config)
@@ -1631,36 +1428,6 @@ ADD {relpath} /deps/{name}
]
)
image_str = docker_tag(config, base_image, api_version)
dep_vname = "$$dep" if escape_variables else "$dep"
local_deps_install_command = f"""for dep in /deps/*; do \
echo "Installing {dep_vname}"; \
if [ -d "{dep_vname}" ]; then \
echo "Installing {dep_vname}"; \
(cd "{dep_vname}" && {global_reqs_pip_install} -e .); \
fi; \
done"""
pip_cleanup = _get_pip_cleanup_lines(
install_cmd=install_cmd,
to_uninstall=build_tools_to_uninstall,
pip_installer=pip_installer,
)
if flat:
return (
_build_flat_python_dockerfile(
config_path=config_path,
config=config,
local_deps=local_deps,
pypi_deps=pypi_deps,
local_reqs_pip_install=local_reqs_pip_install,
local_deps_install_command=local_deps_install_command,
pip_cleanup=pip_cleanup,
image=image_str,
env_vars=env_vars,
additional_context_names=additional_context_names,
),
additional_contexts,
)
# Prepare docker file contents
docker_file_contents = []
@@ -1675,6 +1442,14 @@ ADD {relpath} /deps/{name}
)
# Add main dockerfile content
dep_vname = "$$dep" if escape_variables else "$dep"
local_deps_install_str = f"""RUN for dep in /deps/*; do \
echo "Installing {dep_vname}"; \
if [ -d "{dep_vname}" ]; then \
echo "Installing {dep_vname}"; \
(cd "{dep_vname}" && {global_reqs_pip_install} -e .); \
fi; \
done"""
docker_file_contents.extend(
[
f"FROM {image_str}",
@@ -1684,14 +1459,18 @@ ADD {relpath} /deps/{name}
installs,
"",
"# -- Installing all local dependencies --",
f"RUN {local_deps_install_command}",
local_deps_install_str,
"# -- End of local dependencies install --",
os.linesep.join(env_vars),
"",
js_inst_str,
"",
# Add pip cleanup after all installations are complete
pip_cleanup,
_get_pip_cleanup_lines(
install_cmd=install_cmd,
to_uninstall=build_tools_to_uninstall,
pip_installer=pip_installer,
),
"",
f"WORKDIR {local_deps.working_dir}" if local_deps.working_dir else "",
]
@@ -1700,31 +1479,6 @@ ADD {relpath} /deps/{name}
return os.linesep.join(docker_file_contents), additional_contexts
def _build_flat_node_dockerfile(
*,
config: Config,
image: str,
env_vars: list[str],
install_workdir: str,
build_workdir: str,
install_command: str,
build_command: str,
) -> str:
commands = [
"set -eu",
_copy_directory_command(str(_FLAT_BUILD_CONTEXT), install_workdir),
f"cd {shlex.quote(install_workdir)}\n{install_command}",
f"cd {shlex.quote(build_workdir)}\n{build_command}",
]
return _render_flat_dockerfile(
image=image,
dockerfile_lines=config["dockerfile_lines"],
env_vars=env_vars,
commands=commands,
workdir=build_workdir,
)
def node_config_to_docker(
config_path: pathlib.Path,
config: Config,
@@ -1733,7 +1487,6 @@ def node_config_to_docker(
install_command: str | None = None,
build_command: str | None = None,
build_context: str | None = None,
flat: bool = False,
) -> tuple[str, dict[str, str]]:
# Calculate paths for monorepo support
install_root = (
@@ -1743,56 +1496,56 @@ def node_config_to_docker(
if build_context:
relative_workdir = _calculate_relative_workdir(config_path, build_context)
container_name = pathlib.Path(build_context).name
install_workdir = f"/deps/{container_name}"
build_workdir = (
f"{install_workdir}/{relative_workdir}"
if relative_workdir
else install_workdir
)
if relative_workdir:
faux_path = f"/deps/{container_name}/{relative_workdir}"
else:
faux_path = f"/deps/{container_name}"
else:
# Backward compatibility: use the original behavior
install_workdir = build_workdir = f"/deps/{config_path.parent.name}"
faux_path = f"/deps/{config_path.parent.name}"
image_str = docker_tag(config, base_image, api_version)
env_vars = _build_runtime_env_vars(config)
build_cmd = (
build_command
if build_context and build_command
else _DEFAULT_NODE_BUILD_COMMAND
)
# For monorepo support, we need to handle install and build commands differently
if build_context:
# Monorepo case: install from root, build from config directory
container_root = f"/deps/{pathlib.Path(build_context).name}"
install_workdir = container_root
install_step = f"RUN {install_cmd}"
if flat:
return (
_build_flat_node_dockerfile(
config=config,
image=image_str,
env_vars=env_vars,
install_workdir=install_workdir,
build_workdir=build_workdir,
install_command=install_cmd,
build_command=build_cmd,
),
{},
)
if build_command:
build_step = f"RUN {build_command}"
else:
build_step = 'RUN (test ! -f /api/langgraph_api/js/build.mts && echo "Prebuild script not found, skipping") || tsx /api/langgraph_api/js/build.mts'
else:
# Original behavior: everything happens in the same directory
install_workdir = faux_path
install_step = f"RUN {install_cmd}"
build_step = 'RUN (test ! -f /api/langgraph_api/js/build.mts && echo "Prebuild script not found, skipping") || tsx /api/langgraph_api/js/build.mts'
if build_context:
build_workdir = faux_path
else:
build_workdir = faux_path
docker_file_contents = [
f"FROM {image_str}",
"",
os.linesep.join(config["dockerfile_lines"]),
"",
f"ADD . {install_workdir}",
f"ADD . {faux_path if not build_context else container_root}",
"",
f"WORKDIR {install_workdir}",
"",
f"RUN {install_cmd}",
install_step,
"",
os.linesep.join(env_vars),
"",
f"WORKDIR {build_workdir}",
"",
f"RUN {build_cmd}",
build_step,
]
return os.linesep.join(docker_file_contents), {}
@@ -1872,7 +1625,6 @@ def config_to_docker(
build_command: str | None = None,
build_context: str | None = None,
escape_variables: bool = False,
flat: bool = False,
) -> tuple[str, dict[str, str]]:
base_image = base_image or default_base_image(config)
@@ -1885,7 +1637,6 @@ def config_to_docker(
install_command=install_command,
build_command=build_command,
build_context=build_context,
flat=flat,
)
return python_config_to_docker(
@@ -1894,7 +1645,6 @@ def config_to_docker(
base_image=base_image,
api_version=api_version,
escape_variables=escape_variables,
flat=flat,
)
-2
View File
@@ -345,7 +345,6 @@ def build_docker_image(
docker_command: Sequence[str] | None = None,
extra_flags: Sequence[str] = (),
verbose: bool = True,
flat: bool = False,
):
"""Build a Docker image from a LangGraph config."""
# pull latest images
@@ -388,7 +387,6 @@ def build_docker_image(
install_command=install_command,
build_command=build_command,
build_context=build_context,
flat=flat,
)
# add additional_contexts
if additional_contexts:
-106
View File
@@ -723,67 +723,6 @@ WORKDIR /deps/outer-unit_tests/unit_tests\
}
def test_config_to_docker_flat():
dockerfile, additional_contexts = config_to_docker(
PATH_TO_CONFIG,
validate_config(
{"dependencies": ["."], "graphs": {"agent": "./agent.py:graph"}}
),
base_image="langchain/langgraph-api",
flat=True,
)
assert additional_contexts == {}
assert dockerfile.count("\nRUN ") == 1
assert "--mount=type=bind" in dockerfile
assert "cp -a /__build_context/. /deps/outer-unit_tests/unit_tests/" in dockerfile
assert "cat > /deps/outer-unit_tests/pyproject.toml <<'PYPROJECT'" in dockerfile
assert '[tool.setuptools.package-data]\n"*" = ["**/*"]' in dockerfile
assert dockerfile.endswith("WORKDIR /deps/outer-unit_tests/unit_tests")
def test_config_to_docker_flat_with_custom_lines_and_escaped_variables():
dockerfile, _ = config_to_docker(
PATH_TO_CONFIG,
validate_config(
{
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"},
"dockerfile_lines": ["RUN echo custom"],
}
),
base_image="langchain/langgraph-api",
escape_variables=True,
flat=True,
)
assert dockerfile.index("RUN echo custom") < dockerfile.index("USER_LAYER")
assert 'echo "Installing $$dep"' in dockerfile
def test_config_to_docker_flat_with_additional_context():
dockerfile, additional_contexts = config_to_docker(
PATH_TO_CONFIG,
validate_config(
{"dependencies": [".", ".."], "graphs": {"agent": "./agent.py:graph"}}
),
base_image="langchain/langgraph-api",
flat=True,
)
assert additional_contexts == {
"outer-tests": str(pathlib.Path(__file__).parent.parent.absolute()),
}
assert (
"--mount=type=bind,from=outer-tests,"
"target=/__additional_contexts/outer-tests,readonly" in dockerfile
)
assert (
"cp -a /__additional_contexts/outer-tests/. /deps/outer-tests/tests/"
in dockerfile
)
def test_config_to_docker_outside_path():
graphs = {"agent": "./agent.py:graph"}
actual_docker_stdin, additional_contexts = config_to_docker(
@@ -1059,28 +998,6 @@ RUN (test ! -f /api/langgraph_api/js/build.mts && echo "Prebuild script not foun
assert additional_contexts == {}
def test_config_to_docker_nodejs_flat():
dockerfile, additional_contexts = config_to_docker(
PATH_TO_CONFIG,
validate_config(
{
"node_version": "20",
"graphs": {"agent": "./graphs/agent.js:graph"},
"dockerfile_lines": ["RUN echo custom"],
}
),
base_image="langchain/langgraphjs-api",
flat=True,
)
assert additional_contexts == {}
assert dockerfile.index("RUN echo custom") < dockerfile.index("USER_LAYER")
assert "cp -a /__build_context/. /deps/unit_tests/" in dockerfile
assert "cd /deps/unit_tests\nnpm i" in dockerfile
assert "tsx /api/langgraph_api/js/build.mts" in dockerfile
assert dockerfile.endswith("WORKDIR /deps/unit_tests")
def test_config_to_docker_python_encryption():
# Test that encryption config is included in validation
graphs = {"agent": "./agent.py:graph"}
@@ -1297,29 +1214,6 @@ WORKDIR /deps/outer-unit_tests/unit_tests"""
assert additional_contexts == {}
def test_config_to_docker_gen_ui_python_flat():
dockerfile, additional_contexts = config_to_docker(
PATH_TO_CONFIG,
validate_config(
{
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"},
"ui": {"agent": "./graphs/agent.ui.jsx"},
}
),
base_image="langchain/langgraph-api",
flat=True,
)
assert additional_contexts == {}
assert dockerfile.count("\nRUN ") == 1
assert dockerfile.index("ENV NODE_VERSION=20") < dockerfile.index("\nRUN ")
assert dockerfile.index("ENV LANGGRAPH_UI=") < dockerfile.index("\nRUN ")
assert "/storage/install-node.sh" in dockerfile
assert "cd /deps/outer-unit_tests/unit_tests\nnpm i" in dockerfile
assert "tsx /api/langgraph_api/js/build.mts" in dockerfile
def test_config_to_docker_multiplatform():
graphs = {
"python": "./multiplatform/python.py:graph",
+9
View File
@@ -39,6 +39,15 @@
- `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
+1 -1
View File
@@ -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.3"
__version__ = "0.4.4"
__all__ = [
"Auth",
+4 -1
View File
@@ -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 QueryParamTypes
from langgraph_sdk.schema import LangSmithTracing, QueryParamTypes
from langgraph_sdk.stream.controller import _SeenEventIds
from langgraph_sdk.stream.decoders import (
DataDecoder,
@@ -172,6 +172,7 @@ 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}
@@ -181,6 +182,8 @@ 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
+4 -1
View File
@@ -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 QueryParamTypes
from langgraph_sdk.schema import LangSmithTracing, QueryParamTypes
from langgraph_sdk.stream.decoders import (
DataDecoder,
Decoder,
@@ -215,6 +215,7 @@ 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}
@@ -224,6 +225,8 @@ 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
+67 -16
View File
@@ -341,9 +341,15 @@ 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]):
class _ResourceOn(
typing.Generic[VCreate, VRead, VUpdate, VDelete, VSearch, ResourceActionT]
):
"""
Generic base class for resource-specific handlers.
"""
@@ -392,8 +398,8 @@ class _ResourceOn(typing.Generic[VCreate, VRead, VUpdate, VDelete, VSearch]):
def __call__(
self,
*,
resources: str | Sequence[str],
actions: str | Sequence[str] | None = None,
resources: str | Sequence[str] | None = None,
actions: ResourceActionT | Sequence[ResourceActionT] | None = None,
) -> Callable[
[_ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch]],
_ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch],
@@ -408,7 +414,7 @@ class _ResourceOn(typing.Generic[VCreate, VRead, VUpdate, VDelete, VSearch]):
) = None,
*,
resources: str | Sequence[str] | None = None,
actions: str | Sequence[str] | None = None,
actions: ResourceActionT | Sequence[ResourceActionT] | None = None,
) -> (
_ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch]
| Callable[
@@ -416,24 +422,66 @@ class _ResourceOn(typing.Generic[VCreate, VRead, VUpdate, VDelete, VSearch]):
_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)
return typing.cast(
"_ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch]",
_register_handler(self.auth, self.resource, "*", 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 []
)
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
# Accept keyword-only parameters for future filtering behavior; referenced to satisfy linters.
_ = resources, actions
if fn is not None:
return decorator(
typing.cast(
"_ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch]",
fn,
)
)
return decorator
@@ -444,6 +492,7 @@ class _AssistantsOn(
types.AssistantsUpdate,
types.AssistantsDelete,
types.AssistantsSearch,
_ResourceAction,
]
):
value = (
@@ -467,6 +516,7 @@ class _ThreadsOn(
types.ThreadsUpdate,
types.ThreadsDelete,
types.ThreadsSearch,
_ThreadAction,
]
):
value = (
@@ -502,6 +552,7 @@ class _CronsOn(
types.CronsUpdate,
types.CronsDelete,
types.CronsSearch,
_ResourceAction,
]
):
value = type[
@@ -426,11 +426,17 @@ 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})
result = thread.run.start(
input={"x": 1},
langsmith_tracing={"project_name": "replica-project"},
)
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_and_metadata():
async def test_run_start_forwards_config_metadata_and_langsmith_tracing():
fake = FakeServer()
transport = httpx.ASGITransport(app=fake.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as raw:
@@ -297,10 +297,18 @@ async def test_run_start_forwards_config_and_metadata():
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():
+132
View File
@@ -0,0 +1,132 @@
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]}