mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-08-28 10:49:56 +02:00
Compare commits
83
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
530fcabfc3 | ||
|
|
d8b7800183 | ||
|
|
c8c58a0768 | ||
|
|
de9b7c61c3 | ||
|
|
63d861165f | ||
|
|
9c1d65695e | ||
|
|
40ab009c62 | ||
|
|
f95d2309f9 | ||
|
|
4a5765dd23 | ||
|
|
5c18bde0f8 | ||
|
|
a48a045596 | ||
|
|
168674dd2a | ||
|
|
800071d0d4 | ||
|
|
3eb73e8ad2 | ||
|
|
08666353fc | ||
|
|
5af4c5addf | ||
|
|
f4388df77f | ||
|
|
521b4842d3 | ||
|
|
cb328b57f1 | ||
|
|
d177a0db43 | ||
|
|
372d54dc4f | ||
|
|
f4aee546ad | ||
|
|
85cd64ed69 | ||
|
|
53a9806e65 | ||
|
|
219fbbe8d0 | ||
|
|
aeff9549c2 | ||
|
|
1a248cba45 | ||
|
|
45246f6c74 | ||
|
|
8657df80f3 | ||
|
|
a529b9bede | ||
|
|
0a26b471d3 | ||
|
|
b674dd4622 | ||
|
|
8df0a377d0 | ||
|
|
216cf33a54 | ||
|
|
4956134a37 | ||
|
|
aa94790f36 | ||
|
|
e002711ede | ||
|
|
f44b49b33d | ||
|
|
a0a95df2ac | ||
|
|
d194c18c06 | ||
|
|
eae916719f | ||
|
|
f093702e4e | ||
|
|
51cbdbd5cd | ||
|
|
4d64227c13 | ||
|
|
6bcac5d72e | ||
|
|
6177c4311b | ||
|
|
303769904b | ||
|
|
cee7dcd523 | ||
|
|
3413723e5a | ||
|
|
07252d2cda | ||
|
|
47fd42abb2 | ||
|
|
554b2db1f8 | ||
|
|
93a144b404 | ||
|
|
2f4611db8f | ||
|
|
20c5d36efe | ||
|
|
25470ea435 | ||
|
|
7fa49bd550 | ||
|
|
6719d34023 | ||
|
|
96843788d0 | ||
|
|
ba5e3c4a9b | ||
|
|
9e9783b156 | ||
|
|
c27d103e04 | ||
|
|
d189f6551e | ||
|
|
1dd9adf833 | ||
|
|
92c66ca997 | ||
|
|
a7356edf8a | ||
|
|
354dceaac7 | ||
|
|
2ff294af77 | ||
|
|
4c67f84016 | ||
|
|
2c98c59fca | ||
|
|
d27d4b2d98 | ||
|
|
742d165acb | ||
|
|
b442bf802a | ||
|
|
bede0b7acf | ||
|
|
3a5b5c9821 | ||
|
|
1142ebf921 | ||
|
|
37b34bdb1e | ||
|
|
11e8d827eb | ||
|
|
5fcebdd30a | ||
|
|
173ef2ff44 | ||
|
|
98afc106a0 | ||
|
|
80ef3ced0b | ||
|
|
1629794658 |
@@ -9,9 +9,6 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
GO_VERSION: "1.23"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -54,23 +51,12 @@ jobs:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
enable-cache: "false"
|
||||
working-directory: libs/cli
|
||||
- name: Set up Go
|
||||
if: (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch')
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
- name: Install cli globally
|
||||
if: (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch')
|
||||
run: pip install -e .
|
||||
- name: Build Go CLI binary
|
||||
if: (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch')
|
||||
run: make build-go
|
||||
- name: Build service ${{ matrix.example.name }}
|
||||
if: (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch')
|
||||
working-directory: ${{ matrix.example.workdir }}
|
||||
env:
|
||||
LANGGRAPH_USE_GO_CLI: "1"
|
||||
LANGGRAPH_GO_CLI_PATH: ${{ github.workspace }}/libs/cli/langgraph_cli/bin/langgraph
|
||||
run: |
|
||||
langgraph build -t ${{ matrix.example.tag }}
|
||||
- name: Test service ${{ matrix.example.name }}
|
||||
@@ -78,8 +64,6 @@ jobs:
|
||||
working-directory: ${{ matrix.example.workdir }}
|
||||
env:
|
||||
LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }}
|
||||
LANGGRAPH_USE_GO_CLI: "1"
|
||||
LANGGRAPH_GO_CLI_PATH: ${{ github.workspace }}/libs/cli/langgraph_cli/bin/langgraph
|
||||
run: |
|
||||
# Prepare environment file from local or parent example directory
|
||||
if [ -f .env.example ]; then cp .env.example .env; elif [ -f ../.env.example ]; then cp ../.env.example .env && cp ../.env.example ../.env; fi
|
||||
@@ -92,27 +76,18 @@ jobs:
|
||||
- name: Build JS service
|
||||
if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' }}
|
||||
working-directory: libs/cli/js-examples
|
||||
env:
|
||||
LANGGRAPH_USE_GO_CLI: "1"
|
||||
LANGGRAPH_GO_CLI_PATH: ${{ github.workspace }}/libs/cli/langgraph_cli/bin/langgraph
|
||||
run: |
|
||||
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' }}
|
||||
working-directory: libs/cli/js-monorepo-example
|
||||
env:
|
||||
LANGGRAPH_USE_GO_CLI: "1"
|
||||
LANGGRAPH_GO_CLI_PATH: ${{ github.workspace }}/libs/cli/langgraph_cli/bin/langgraph
|
||||
run: |
|
||||
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' }}
|
||||
working-directory: libs/cli/python-monorepo-example
|
||||
env:
|
||||
LANGGRAPH_USE_GO_CLI: "1"
|
||||
LANGGRAPH_GO_CLI_PATH: ${{ github.workspace }}/libs/cli/langgraph_cli/bin/langgraph
|
||||
run: |
|
||||
langgraph build -t langgraph-test-g -c apps/agent/langgraph.json
|
||||
- name: Test Python monorepo service
|
||||
@@ -120,8 +95,6 @@ jobs:
|
||||
working-directory: libs/cli/python-monorepo-example
|
||||
env:
|
||||
LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }}
|
||||
LANGGRAPH_USE_GO_CLI: "1"
|
||||
LANGGRAPH_GO_CLI_PATH: ${{ github.workspace }}/libs/cli/langgraph_cli/bin/langgraph
|
||||
run: |
|
||||
cp apps/agent/.env.example apps/agent/.env
|
||||
echo "LANGSMITH_API_KEY=${{ secrets.LANGSMITH_API_KEY }}" >> apps/agent/.env
|
||||
@@ -130,9 +103,6 @@ jobs:
|
||||
- name: Build prerelease reqs service
|
||||
if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' }}
|
||||
working-directory: libs/cli/examples/graph_prerelease_reqs
|
||||
env:
|
||||
LANGGRAPH_USE_GO_CLI: "1"
|
||||
LANGGRAPH_GO_CLI_PATH: ${{ github.workspace }}/libs/cli/langgraph_cli/bin/langgraph
|
||||
run: |
|
||||
langgraph build -t langgraph-test-h
|
||||
- name: Test prerelease reqs service
|
||||
@@ -140,8 +110,6 @@ jobs:
|
||||
working-directory: libs/cli/examples/graph_prerelease_reqs
|
||||
env:
|
||||
LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }}
|
||||
LANGGRAPH_USE_GO_CLI: "1"
|
||||
LANGGRAPH_GO_CLI_PATH: ${{ github.workspace }}/libs/cli/langgraph_cli/bin/langgraph
|
||||
run: |
|
||||
cp ../.env.example .env
|
||||
echo "LANGSMITH_API_KEY=${{ secrets.LANGSMITH_API_KEY }}" >> .env
|
||||
@@ -153,8 +121,8 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
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.0.1" ]; then
|
||||
echo "LANGCHAIN_OPENAI_VERSION != 1.0.1; $LANGCHAIN_OPENAI_VERSION"
|
||||
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 python -c "import sys; from importlib.metadata import version; v = version('langchain-anthropic'); print(v);")
|
||||
@@ -166,18 +134,12 @@ jobs:
|
||||
- name: Build and test prerelease reqs fail service
|
||||
if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' }}
|
||||
working-directory: libs/cli/examples/graph_prerelease_reqs_fail
|
||||
env:
|
||||
LANGGRAPH_USE_GO_CLI: "1"
|
||||
LANGGRAPH_GO_CLI_PATH: ${{ github.workspace }}/libs/cli/langgraph_cli/bin/langgraph
|
||||
run: |
|
||||
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' }}
|
||||
working-directory: libs/cli/uv-examples/simple
|
||||
env:
|
||||
LANGGRAPH_USE_GO_CLI: "1"
|
||||
LANGGRAPH_GO_CLI_PATH: ${{ github.workspace }}/libs/cli/langgraph_cli/bin/langgraph
|
||||
run: |
|
||||
langgraph build -t langgraph-test-uv-simple
|
||||
- name: Test uv simple service
|
||||
@@ -185,8 +147,6 @@ jobs:
|
||||
working-directory: libs/cli/uv-examples/simple
|
||||
env:
|
||||
LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }}
|
||||
LANGGRAPH_USE_GO_CLI: "1"
|
||||
LANGGRAPH_GO_CLI_PATH: ${{ github.workspace }}/libs/cli/langgraph_cli/bin/langgraph
|
||||
run: |
|
||||
cp .env.example .env
|
||||
echo "LANGSMITH_API_KEY=${{ secrets.LANGSMITH_API_KEY }}" >> .env
|
||||
@@ -195,9 +155,6 @@ jobs:
|
||||
- name: Build uv monorepo service
|
||||
if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' }}
|
||||
working-directory: libs/cli/uv-examples/monorepo/apps/agent
|
||||
env:
|
||||
LANGGRAPH_USE_GO_CLI: "1"
|
||||
LANGGRAPH_GO_CLI_PATH: ${{ github.workspace }}/libs/cli/langgraph_cli/bin/langgraph
|
||||
run: |
|
||||
langgraph build -t langgraph-test-uv-monorepo
|
||||
- name: Test uv monorepo service
|
||||
@@ -205,8 +162,6 @@ jobs:
|
||||
working-directory: libs/cli/uv-examples/monorepo/apps/agent
|
||||
env:
|
||||
LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }}
|
||||
LANGGRAPH_USE_GO_CLI: "1"
|
||||
LANGGRAPH_GO_CLI_PATH: ${{ github.workspace }}/libs/cli/langgraph_cli/bin/langgraph
|
||||
run: |
|
||||
cp .env.example .env
|
||||
echo "LANGSMITH_API_KEY=${{ secrets.LANGSMITH_API_KEY }}" >> .env
|
||||
|
||||
@@ -11,9 +11,6 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
GO_VERSION: "1.23"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -29,23 +26,12 @@ jobs:
|
||||
name: "test #${{ matrix.python-version }}"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
if: ${{ inputs.working-directory == 'libs/cli' && github.event_name != 'workflow_dispatch' }}
|
||||
uses: Ana06/get-changed-files@25f79e676e7ea1868813e21465014798211fad8c # v2.3.0
|
||||
with:
|
||||
filter: "libs/cli/**"
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: ./.github/actions/uv_setup
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache-suffix: test-${{ inputs.working-directory }}
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
- name: Set up Go
|
||||
if: ${{ inputs.working-directory == 'libs/cli' && (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') }}
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4
|
||||
if: ${{ !github.event.pull_request.head.repo.fork }}
|
||||
@@ -58,26 +44,11 @@ jobs:
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
run: uv sync --frozen --group test --no-dev
|
||||
|
||||
- name: Build Go CLI binary
|
||||
if: ${{ inputs.working-directory == 'libs/cli' && (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') }}
|
||||
shell: bash
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
run: make build-go
|
||||
|
||||
- name: Run tests
|
||||
shell: bash
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
run: make test
|
||||
|
||||
- name: Run Go CLI smoke tests
|
||||
if: ${{ inputs.working-directory == 'libs/cli' && (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') }}
|
||||
shell: bash
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
env:
|
||||
LANGGRAPH_USE_GO_CLI: "1"
|
||||
LANGGRAPH_GO_CLI_PATH: ${{ github.workspace }}/libs/cli/langgraph_cli/bin/langgraph
|
||||
run: TEST='tests/unit_tests/test_go_cli_smoke.py' make test
|
||||
|
||||
- name: Ensure the tests did not create any additional files
|
||||
shell: bash
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
|
||||
@@ -10,7 +10,6 @@ on:
|
||||
|
||||
env:
|
||||
PYTHON_VERSION: "3.10"
|
||||
GO_VERSION: "1.23"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -33,17 +32,6 @@ jobs:
|
||||
cache-suffix: "release"
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
|
||||
- name: Set up Go
|
||||
if: inputs.working-directory == 'libs/cli'
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
|
||||
- name: Cross-compile Go binaries
|
||||
if: inputs.working-directory == 'libs/cli'
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
run: make build-go-all GO_BIN_DIR=build/go-bin
|
||||
|
||||
# We want to keep this build stage *separate* from the release stage,
|
||||
# so that there's no sharing of permissions between them.
|
||||
# The release stage has trusted publishing and GitHub repo contents write access,
|
||||
@@ -55,40 +43,7 @@ jobs:
|
||||
# > It is strongly advised to separate jobs for building [...]
|
||||
# > from the publish job.
|
||||
# https://github.com/pypa/gh-action-pypi-publish#non-goals
|
||||
- name: Build CLI platform wheels
|
||||
if: inputs.working-directory == 'libs/cli'
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
run: |
|
||||
TARGETS=(
|
||||
"linux-amd64 manylinux_2_17_x86_64.manylinux2014_x86_64"
|
||||
"linux-arm64 manylinux_2_17_aarch64.manylinux2014_aarch64"
|
||||
"linux-arm manylinux_2_17_armv7l.manylinux2014_armv7l"
|
||||
"linux-386 manylinux_2_17_i686.manylinux2014_i686"
|
||||
"linux-ppc64le manylinux_2_17_ppc64le.manylinux2014_ppc64le"
|
||||
"linux-s390x manylinux_2_17_s390x.manylinux2014_s390x"
|
||||
"linux-amd64 musllinux_1_2_x86_64"
|
||||
"linux-arm64 musllinux_1_2_aarch64"
|
||||
"linux-arm musllinux_1_2_armv7l"
|
||||
"linux-386 musllinux_1_2_i686"
|
||||
"darwin-amd64 macosx_11_0_x86_64"
|
||||
"darwin-arm64 macosx_11_0_arm64"
|
||||
"windows-amd64 win_amd64"
|
||||
"windows-arm64 win_arm64"
|
||||
"windows-386 win32"
|
||||
)
|
||||
for entry in "${TARGETS[@]}"; do
|
||||
key=$(echo "$entry" | awk '{print $1}')
|
||||
plat=$(echo "$entry" | awk '{print $2}')
|
||||
ext=""
|
||||
if [[ "$key" == windows-* ]]; then ext=".exe"; fi
|
||||
binary="build/go-bin/langgraph-${key}${ext}"
|
||||
LANGGRAPH_GO_BINARY="$binary" LANGGRAPH_WHEEL_PLAT="$plat" uv build --wheel
|
||||
done
|
||||
rm -rf langgraph_cli/bin
|
||||
uv build
|
||||
|
||||
- name: Build project for distribution
|
||||
if: inputs.working-directory != 'libs/cli'
|
||||
run: uv build
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ permissions:
|
||||
|
||||
env:
|
||||
PYTHON_VERSION: "3.11"
|
||||
GO_VERSION: "1.23"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -35,71 +34,6 @@ jobs:
|
||||
cache-suffix: "release"
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
|
||||
# -- Go cross-compilation (libs/cli only) --
|
||||
# When releasing the CLI, we cross-compile the Go binary for each
|
||||
# platform and build platform-specific wheels that bundle it.
|
||||
- name: Set up Go
|
||||
if: inputs.working-directory == 'libs/cli'
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
|
||||
- name: Cross-compile Go binaries
|
||||
if: inputs.working-directory == 'libs/cli'
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
run: make build-go-all GO_BIN_DIR=build/go-bin
|
||||
|
||||
- name: Run Go tests
|
||||
if: inputs.working-directory == 'libs/cli'
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
run: go test -count=1 -race ./...
|
||||
|
||||
- name: Build CLI platform wheels
|
||||
if: inputs.working-directory == 'libs/cli'
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
run: |
|
||||
# Each entry: GO_BINARY_KEY WHEEL_PLATFORM_TAG
|
||||
#
|
||||
# Since Go builds are statically linked (CGO_ENABLED=0), the same
|
||||
# linux binary works on both glibc and musl. We produce separate
|
||||
# manylinux and musllinux wheels so pip installs the right one.
|
||||
TARGETS=(
|
||||
# Linux glibc
|
||||
"linux-amd64 manylinux_2_17_x86_64.manylinux2014_x86_64"
|
||||
"linux-arm64 manylinux_2_17_aarch64.manylinux2014_aarch64"
|
||||
"linux-arm manylinux_2_17_armv7l.manylinux2014_armv7l"
|
||||
"linux-386 manylinux_2_17_i686.manylinux2014_i686"
|
||||
"linux-ppc64le manylinux_2_17_ppc64le.manylinux2014_ppc64le"
|
||||
"linux-s390x manylinux_2_17_s390x.manylinux2014_s390x"
|
||||
# Linux musl (same Go binaries, different wheel tag)
|
||||
"linux-amd64 musllinux_1_2_x86_64"
|
||||
"linux-arm64 musllinux_1_2_aarch64"
|
||||
"linux-arm musllinux_1_2_armv7l"
|
||||
"linux-386 musllinux_1_2_i686"
|
||||
# macOS
|
||||
"darwin-amd64 macosx_11_0_x86_64"
|
||||
"darwin-arm64 macosx_11_0_arm64"
|
||||
# Windows
|
||||
"windows-amd64 win_amd64"
|
||||
"windows-arm64 win_arm64"
|
||||
"windows-386 win32"
|
||||
)
|
||||
for entry in "${TARGETS[@]}"; do
|
||||
key=$(echo "$entry" | awk '{print $1}')
|
||||
plat=$(echo "$entry" | awk '{print $2}')
|
||||
ext=""
|
||||
if [[ "$key" == windows-* ]]; then ext=".exe"; fi
|
||||
binary="build/go-bin/langgraph-${key}${ext}"
|
||||
echo "Building wheel for ${key} -> ${plat}..."
|
||||
LANGGRAPH_GO_BINARY="$binary" LANGGRAPH_WHEEL_PLAT="$plat" uv build --wheel
|
||||
done
|
||||
# Also build the sdist and pure-Python fallback wheel
|
||||
rm -rf langgraph_cli/bin
|
||||
uv build
|
||||
echo "All wheels built:"
|
||||
ls -lh dist/
|
||||
|
||||
# -- Standard build (all other packages) --
|
||||
# We want to keep this build stage *separate* from the release stage,
|
||||
# so that there's no sharing of permissions between them.
|
||||
# The release stage has trusted publishing and GitHub repo contents write access,
|
||||
@@ -112,7 +46,6 @@ jobs:
|
||||
# > from the publish job.
|
||||
# https://github.com/pypa/gh-action-pypi-publish#non-goals
|
||||
- name: Build project for distribution
|
||||
if: inputs.working-directory != 'libs/cli'
|
||||
run: uv build
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
|
||||
@@ -308,13 +241,6 @@ jobs:
|
||||
run: make test
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
|
||||
- name: Run Go CLI smoke tests
|
||||
if: inputs.working-directory == 'libs/cli'
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
env:
|
||||
LANGGRAPH_USE_GO_CLI: "1"
|
||||
run: uv run pytest tests/unit_tests/test_go_cli_smoke.py
|
||||
|
||||
publish:
|
||||
needs:
|
||||
- build
|
||||
|
||||
@@ -100,3 +100,4 @@ dmypy.json
|
||||
.turbo
|
||||
.editorconfig
|
||||
.scratch
|
||||
.worktrees/
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<a href="https://opensource.org/licenses/MIT" target="_blank"><img src="https://img.shields.io/pypi/l/langgraph" alt="PyPI - License"></a>
|
||||
<a href="https://pypistats.org/packages/langgraph" target="_blank"><img src="https://img.shields.io/pepy/dt/langgraph" alt="PyPI - Downloads"></a>
|
||||
<a href="https://pypi.org/project/langgraph/" target="_blank"><img src="https://img.shields.io/pypi/v/langgraph.svg?label=%20" alt="Version"></a>
|
||||
<a href="https://x.com/langchain" target="_blank"><img src="https://img.shields.io/twitter/url/https/twitter.com/langchain.svg?style=social&label=Follow%20%40LangChain" alt="Twitter / X"></a>
|
||||
<a href="https://x.com/langchain_oss" target="_blank"><img src="https://img.shields.io/twitter/url/https/twitter.com/langchain_oss.svg?style=social&label=Follow%20%40LangChain" alt="Twitter / X"></a>
|
||||
</div>
|
||||
|
||||
<br>
|
||||
|
||||
@@ -339,7 +339,6 @@ async def test_list_metadata_custom_keys(
|
||||
assert results[0].metadata["run_id"] == "run-abc"
|
||||
|
||||
|
||||
|
||||
ALL_LIST_TESTS = [
|
||||
test_list_all,
|
||||
test_list_by_thread,
|
||||
|
||||
Generated
+10
-10
@@ -231,7 +231,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langchain-core"
|
||||
version = "1.2.22"
|
||||
version = "1.2.28"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jsonpatch" },
|
||||
@@ -243,9 +243,9 @@ dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "uuid-utils" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/a3/c4cd6827a1df46c821e7214b7f7b7a28b189e6c9b84ef15c6d629c5e3179/langchain_core-1.2.22.tar.gz", hash = "sha256:8d8f726d03d3652d403da915126626bb6250747e8ba406537d849e68b9f5d058", size = 842487, upload-time = "2026-03-24T18:48:44.9Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f8/a4/317a1a3ac1df33a64adb3670bf88bbe3b3d5baa274db6863a979db472897/langchain_core-1.2.28.tar.gz", hash = "sha256:271a3d8bd618f795fdeba112b0753980457fc90537c46a0c11998516a74dc2cb", size = 846119, upload-time = "2026-04-08T18:19:34.867Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/a6/2ffacf0f1a3788f250e75d0b52a24896c413be11be3a6d42bcdf46fbea48/langchain_core-1.2.22-py3-none-any.whl", hash = "sha256:7e30d586b75918e828833b9ec1efc25465723566845dd652c277baf751e9c04b", size = 506829, upload-time = "2026-03-24T18:48:43.286Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/92/32f785f077c7e898da97064f113c73fbd9ad55d1e2169cf3a391b183dedb/langchain_core-1.2.28-py3-none-any.whl", hash = "sha256:80764232581eaf8057bcefa71dbf8adc1f6a28d257ebd8b95ba9b8b452e8c6ac", size = 508727, upload-time = "2026-04-08T18:19:32.823Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -263,7 +263,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-checkpoint-conformance"
|
||||
version = "0.0.1"
|
||||
version = "0.0.2"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "langgraph-checkpoint" },
|
||||
@@ -306,7 +306,7 @@ test = [
|
||||
|
||||
[[package]]
|
||||
name = "langsmith"
|
||||
version = "0.7.3"
|
||||
version = "0.7.31"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
@@ -319,9 +319,9 @@ dependencies = [
|
||||
{ name = "xxhash" },
|
||||
{ name = "zstandard" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8d/bc/8172fefad4f2da888a6d564a27d1fb7d4dbf3c640899c2b40c46235cbe98/langsmith-0.7.3.tar.gz", hash = "sha256:0223b97021af62d2cf53c8a378a27bd22e90a7327e45b353e0069ae60d5d6f9e", size = 988575, upload-time = "2026-02-13T23:25:32.916Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e6/11/696019490992db5c87774dc20515529ef42a01e1d770fb754ed6d9b12fb0/langsmith-0.7.31.tar.gz", hash = "sha256:331ee4f7c26bb5be4022b9859b7d7b122cbf8c9d01d9f530114c1914b0349ffb", size = 1178480, upload-time = "2026-04-14T17:55:41.242Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/9d/5a68b6b5e313ffabbb9725d18a71edb48177fd6d3ad329c07801d2a8e862/langsmith-0.7.3-py3-none-any.whl", hash = "sha256:03659bf9274e6efcead361c9c31a7849ea565ae0d6c0d73e1d8b239029eff3be", size = 325718, upload-time = "2026-02-13T23:25:31.52Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/a1/a013cf458c301cda86a213dd153ce0a01c93f1ab5833f951e6a44c9763ce/langsmith-0.7.31-py3-none-any.whl", hash = "sha256:0291d49203f6e80dda011af1afda61eb0595a4d697adb684590a8805e1d61fb6", size = 373276, upload-time = "2026-04-14T17:55:39.677Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -623,7 +623,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.2"
|
||||
version = "9.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
@@ -634,9 +634,9 @@ dependencies = [
|
||||
{ name = "pygments" },
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -6,6 +6,11 @@ Implementation of LangGraph CheckpointSaver that uses Postgres.
|
||||
|
||||
By default `langgraph-checkpoint-postgres` installs `psycopg` (Psycopg 3) without any extras. However, you can choose a specific installation that best suits your needs [here](https://www.psycopg.org/psycopg3/docs/basic/install.html) (for example, `psycopg[binary]`).
|
||||
|
||||
## Security
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Set `LANGGRAPH_STRICT_MSGPACK=true` or pass an explicit `allowed_msgpack_modules` list when creating your checkpointer. This restricts checkpoint deserialization to known-safe types, preventing code execution if the database is compromised. See the [langgraph-checkpoint README](https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint#serde) for details.
|
||||
|
||||
## Usage
|
||||
|
||||
> [!IMPORTANT]
|
||||
|
||||
@@ -4,26 +4,35 @@ import threading
|
||||
from collections import defaultdict
|
||||
from collections.abc import Iterator, Sequence
|
||||
from contextlib import contextmanager
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from langgraph.checkpoint.base import (
|
||||
DELTA_SENTINEL,
|
||||
WRITES_IDX_MAP,
|
||||
ChannelVersions,
|
||||
Checkpoint,
|
||||
CheckpointMetadata,
|
||||
CheckpointTuple,
|
||||
_ChannelWritesHistory,
|
||||
get_checkpoint_id,
|
||||
get_serializable_checkpoint_metadata,
|
||||
)
|
||||
from langgraph.checkpoint.serde.base import SerializerProtocol
|
||||
from langgraph.checkpoint.serde.types import _DeltaSnapshot
|
||||
from psycopg import Capabilities, Connection, Cursor, Pipeline
|
||||
from psycopg.rows import DictRow, dict_row
|
||||
from psycopg.types.json import Jsonb
|
||||
from psycopg_pool import ConnectionPool
|
||||
|
||||
from langgraph.checkpoint.postgres import _internal
|
||||
from langgraph.checkpoint.postgres.base import BasePostgresSaver
|
||||
from langgraph.checkpoint.postgres.base import (
|
||||
SELECT_DELTA_STAGE1_SQL,
|
||||
SELECT_DELTA_STAGE2_SQL,
|
||||
BasePostgresSaver,
|
||||
_DeltaStage1Row,
|
||||
_DeltaStage2Row,
|
||||
)
|
||||
from langgraph.checkpoint.postgres.shallow import ShallowPostgresSaver
|
||||
|
||||
Conn = _internal.Conn # For backward compatibility
|
||||
@@ -302,7 +311,12 @@ class PostgresSaver(BasePostgresSaver):
|
||||
# others are stored in blobs table
|
||||
blob_values = {}
|
||||
for k, v in checkpoint["channel_values"].items():
|
||||
if v is None or isinstance(v, (str, int, float, bool)):
|
||||
if v is DELTA_SENTINEL:
|
||||
copy["channel_values"].pop(k)
|
||||
elif isinstance(v, _DeltaSnapshot):
|
||||
blob_values[k] = copy["channel_values"].pop(k)
|
||||
copy["channel_values"][k] = True
|
||||
elif v is None or isinstance(v, (str, int, float, bool)):
|
||||
pass
|
||||
else:
|
||||
blob_values[k] = copy["channel_values"].pop(k)
|
||||
@@ -430,6 +444,56 @@ class PostgresSaver(BasePostgresSaver):
|
||||
with conn.cursor(binary=True, row_factory=dict_row) as cur:
|
||||
yield cur
|
||||
|
||||
def _get_channel_writes_history(
|
||||
self, config: RunnableConfig, channel: str
|
||||
) -> _ChannelWritesHistory:
|
||||
"""Fast-path override of `BaseCheckpointSaver._get_channel_writes_history`.
|
||||
|
||||
Two-stage query: stage 1 scans checkpoint metadata to walk the parent
|
||||
chain and locate the nearest snapshot; stage 2 fetches only the
|
||||
chain-limited writes and single seed blob.
|
||||
"""
|
||||
thread_id = config["configurable"]["thread_id"]
|
||||
checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
|
||||
checkpoint_id = get_checkpoint_id(config)
|
||||
if checkpoint_id is None:
|
||||
target = self.get_tuple(config)
|
||||
if target is None:
|
||||
return _ChannelWritesHistory(seed=DELTA_SENTINEL, writes=[])
|
||||
checkpoint_id = target.config["configurable"]["checkpoint_id"]
|
||||
|
||||
with self._cursor() as cur:
|
||||
cur.execute(
|
||||
SELECT_DELTA_STAGE1_SQL,
|
||||
(channel, channel, thread_id, checkpoint_ns),
|
||||
)
|
||||
stage1_rows = cur.fetchall()
|
||||
chain_cids, seed_version = self._walk_stage1(
|
||||
cast("list[_DeltaStage1Row]", stage1_rows), checkpoint_id
|
||||
)
|
||||
seed_versions = [seed_version] if seed_version else []
|
||||
with self._cursor() as cur:
|
||||
cur.execute(
|
||||
SELECT_DELTA_STAGE2_SQL,
|
||||
(
|
||||
thread_id,
|
||||
checkpoint_ns,
|
||||
channel,
|
||||
chain_cids,
|
||||
thread_id,
|
||||
checkpoint_ns,
|
||||
channel,
|
||||
seed_versions,
|
||||
),
|
||||
)
|
||||
stage2_rows = cur.fetchall()
|
||||
return self._build_delta_channel_writes_history(
|
||||
channel=channel,
|
||||
chain_cids=chain_cids,
|
||||
seed_version=seed_version,
|
||||
stage2_rows=cast("list[_DeltaStage2Row]", stage2_rows),
|
||||
)
|
||||
|
||||
def _load_checkpoint_tuple(self, value: DictRow) -> CheckpointTuple:
|
||||
"""
|
||||
Convert a database row into a CheckpointTuple object.
|
||||
|
||||
@@ -4,26 +4,35 @@ import asyncio
|
||||
from collections import defaultdict
|
||||
from collections.abc import AsyncIterator, Iterator, Sequence
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from langgraph.checkpoint.base import (
|
||||
DELTA_SENTINEL,
|
||||
WRITES_IDX_MAP,
|
||||
ChannelVersions,
|
||||
Checkpoint,
|
||||
CheckpointMetadata,
|
||||
CheckpointTuple,
|
||||
_ChannelWritesHistory,
|
||||
get_checkpoint_id,
|
||||
get_serializable_checkpoint_metadata,
|
||||
)
|
||||
from langgraph.checkpoint.serde.base import SerializerProtocol
|
||||
from langgraph.checkpoint.serde.types import _DeltaSnapshot
|
||||
from psycopg import AsyncConnection, AsyncCursor, AsyncPipeline, Capabilities
|
||||
from psycopg.rows import DictRow, dict_row
|
||||
from psycopg.types.json import Jsonb
|
||||
from psycopg_pool import AsyncConnectionPool
|
||||
|
||||
from langgraph.checkpoint.postgres import _ainternal
|
||||
from langgraph.checkpoint.postgres.base import BasePostgresSaver
|
||||
from langgraph.checkpoint.postgres.base import (
|
||||
SELECT_DELTA_STAGE1_SQL,
|
||||
SELECT_DELTA_STAGE2_SQL,
|
||||
BasePostgresSaver,
|
||||
_DeltaStage1Row,
|
||||
_DeltaStage2Row,
|
||||
)
|
||||
from langgraph.checkpoint.postgres.shallow import AsyncShallowPostgresSaver
|
||||
|
||||
Conn = _ainternal.Conn # For backward compatibility
|
||||
@@ -261,7 +270,12 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
||||
# others are stored in blobs table
|
||||
blob_values = {}
|
||||
for k, v in checkpoint["channel_values"].items():
|
||||
if v is None or isinstance(v, (str, int, float, bool)):
|
||||
if v is DELTA_SENTINEL:
|
||||
copy["channel_values"].pop(k)
|
||||
elif isinstance(v, _DeltaSnapshot):
|
||||
blob_values[k] = copy["channel_values"].pop(k)
|
||||
copy["channel_values"][k] = True
|
||||
elif v is None or isinstance(v, (str, int, float, bool)):
|
||||
pass
|
||||
else:
|
||||
blob_values[k] = copy["channel_values"].pop(k)
|
||||
@@ -391,6 +405,56 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
||||
async with conn.cursor(binary=True, row_factory=dict_row) as cur:
|
||||
yield cur
|
||||
|
||||
async def _aget_channel_writes_history(
|
||||
self, config: RunnableConfig, channel: str
|
||||
) -> _ChannelWritesHistory:
|
||||
"""Fast-path override of `BaseCheckpointSaver._aget_channel_writes_history`.
|
||||
|
||||
Two-stage query: stage 1 scans checkpoint metadata to walk the parent
|
||||
chain and locate the nearest snapshot; stage 2 fetches only the
|
||||
chain-limited writes and single seed blob.
|
||||
"""
|
||||
thread_id = config["configurable"]["thread_id"]
|
||||
checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
|
||||
checkpoint_id = get_checkpoint_id(config)
|
||||
if checkpoint_id is None:
|
||||
target = await self.aget_tuple(config)
|
||||
if target is None:
|
||||
return _ChannelWritesHistory(seed=DELTA_SENTINEL, writes=[])
|
||||
checkpoint_id = target.config["configurable"]["checkpoint_id"]
|
||||
|
||||
async with self._cursor() as cur:
|
||||
await cur.execute(
|
||||
SELECT_DELTA_STAGE1_SQL,
|
||||
(channel, channel, thread_id, checkpoint_ns),
|
||||
)
|
||||
stage1_rows = await cur.fetchall()
|
||||
chain_cids, seed_version = self._walk_stage1(
|
||||
cast("list[_DeltaStage1Row]", stage1_rows), checkpoint_id
|
||||
)
|
||||
seed_versions = [seed_version] if seed_version else []
|
||||
async with self._cursor() as cur:
|
||||
await cur.execute(
|
||||
SELECT_DELTA_STAGE2_SQL,
|
||||
(
|
||||
thread_id,
|
||||
checkpoint_ns,
|
||||
channel,
|
||||
chain_cids,
|
||||
thread_id,
|
||||
checkpoint_ns,
|
||||
channel,
|
||||
seed_versions,
|
||||
),
|
||||
)
|
||||
stage2_rows = await cur.fetchall()
|
||||
return self._build_delta_channel_writes_history(
|
||||
channel=channel,
|
||||
chain_cids=chain_cids,
|
||||
seed_version=seed_version,
|
||||
stage2_rows=cast("list[_DeltaStage2Row]", stage2_rows),
|
||||
)
|
||||
|
||||
async def _load_checkpoint_tuple(self, value: DictRow) -> CheckpointTuple:
|
||||
"""
|
||||
Convert a database row into a CheckpointTuple object.
|
||||
|
||||
@@ -4,13 +4,16 @@ import random
|
||||
import warnings
|
||||
from collections.abc import Sequence
|
||||
from importlib.metadata import version as get_version
|
||||
from typing import Any, cast
|
||||
from typing import Any, TypedDict, cast
|
||||
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from langgraph.checkpoint.base import (
|
||||
DELTA_SENTINEL,
|
||||
WRITES_IDX_MAP,
|
||||
BaseCheckpointSaver,
|
||||
ChannelVersions,
|
||||
PendingWrite,
|
||||
_ChannelWritesHistory,
|
||||
get_checkpoint_id,
|
||||
)
|
||||
from langgraph.checkpoint.serde.types import TASKS
|
||||
@@ -153,6 +156,62 @@ INSERT_CHECKPOINT_WRITES_SQL = """
|
||||
"""
|
||||
|
||||
|
||||
class _DeltaStage2Row(TypedDict, total=False):
|
||||
"""One row from `SELECT_DELTA_STAGE2_SQL` (a UNION ALL of writes and blobs)."""
|
||||
|
||||
_kind: str # "w" or "b"
|
||||
checkpoint_id: str | None # "w" rows only
|
||||
type: str | None
|
||||
blob: bytes | None
|
||||
task_id: str | None # "w" rows only
|
||||
idx: int | None # "w" rows only
|
||||
version: str | None # "b" rows only
|
||||
|
||||
|
||||
# Two-stage DeltaChannel reconstruction. Stage 1 scans checkpoint
|
||||
# metadata (no blob bytes) to walk the parent chain and locate the
|
||||
# nearest snapshot marker. Stage 2 fetches only the chain-limited
|
||||
# writes and the single seed snapshot blob.
|
||||
#
|
||||
# Parameter order:
|
||||
# stage1: (channel, channel, thread_id, checkpoint_ns)
|
||||
# stage2: (thread_id, checkpoint_ns, channel, chain_cids[],
|
||||
# thread_id, checkpoint_ns, channel, seed_versions[])
|
||||
|
||||
SELECT_DELTA_STAGE1_SQL = """
|
||||
SELECT checkpoint_id,
|
||||
parent_checkpoint_id,
|
||||
checkpoint -> 'channel_versions' ->> %s AS ver,
|
||||
(checkpoint -> 'channel_values' -> %s) IS NOT NULL AS has_snapshot
|
||||
FROM checkpoints
|
||||
WHERE thread_id = %s AND checkpoint_ns = %s
|
||||
"""
|
||||
|
||||
SELECT_DELTA_STAGE2_SQL = """
|
||||
SELECT 'w'::text AS _kind,
|
||||
checkpoint_id,
|
||||
type, blob, task_id, idx, NULL::text AS version
|
||||
FROM checkpoint_writes
|
||||
WHERE thread_id = %s AND checkpoint_ns = %s AND channel = %s
|
||||
AND checkpoint_id = ANY(%s)
|
||||
UNION ALL
|
||||
SELECT 'b', NULL,
|
||||
type, blob, NULL, NULL, version
|
||||
FROM checkpoint_blobs
|
||||
WHERE thread_id = %s AND checkpoint_ns = %s AND channel = %s
|
||||
AND version = ANY(%s)
|
||||
"""
|
||||
|
||||
|
||||
class _DeltaStage1Row(TypedDict):
|
||||
"""One row from `SELECT_DELTA_STAGE1_SQL`."""
|
||||
|
||||
checkpoint_id: str
|
||||
parent_checkpoint_id: str | None
|
||||
ver: str | None
|
||||
has_snapshot: bool
|
||||
|
||||
|
||||
class BasePostgresSaver(BaseCheckpointSaver[str]):
|
||||
SELECT_SQL = SELECT_SQL
|
||||
SELECT_PENDING_SENDS_SQL = SELECT_PENDING_SENDS_SQL
|
||||
@@ -195,6 +254,88 @@ class BasePostgresSaver(BaseCheckpointSaver[str]):
|
||||
if t.decode() != "empty"
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _walk_stage1(
|
||||
stage1_rows: Sequence[_DeltaStage1Row],
|
||||
target_id: str,
|
||||
) -> tuple[list[str], str | None]:
|
||||
"""Walk the parent chain from stage 1 metadata rows.
|
||||
|
||||
Returns (chain_cids, seed_version):
|
||||
chain_cids: ancestor checkpoint IDs from target's parent down to
|
||||
the seed (or root), in newest-first order.
|
||||
seed_version: the channel blob version at the nearest ancestor
|
||||
with has_snapshot=True, or None if pure delta.
|
||||
"""
|
||||
parent_of: dict[str, str | None] = {}
|
||||
ver_of: dict[str, str | None] = {}
|
||||
snapshot_of: dict[str, bool] = {}
|
||||
for r in stage1_rows:
|
||||
cid = r["checkpoint_id"]
|
||||
parent_of[cid] = r["parent_checkpoint_id"]
|
||||
ver_of[cid] = r["ver"]
|
||||
snapshot_of[cid] = r["has_snapshot"]
|
||||
|
||||
chain_cids: list[str] = []
|
||||
seed_version: str | None = None
|
||||
cur_cid: str | None = parent_of.get(target_id)
|
||||
while cur_cid is not None:
|
||||
chain_cids.append(cur_cid)
|
||||
if snapshot_of.get(cur_cid, False):
|
||||
seed_version = ver_of.get(cur_cid)
|
||||
break
|
||||
cur_cid = parent_of.get(cur_cid)
|
||||
return chain_cids, seed_version
|
||||
|
||||
def _build_delta_channel_writes_history(
|
||||
self,
|
||||
*,
|
||||
channel: str,
|
||||
chain_cids: list[str],
|
||||
seed_version: str | None,
|
||||
stage2_rows: Sequence[_DeltaStage2Row],
|
||||
) -> _ChannelWritesHistory:
|
||||
"""Reconstruct delta channel history from two-stage query results.
|
||||
|
||||
chain_cids are in newest-first order (target's parent first).
|
||||
stage2_rows contain only writes for chain_cids and the single
|
||||
seed blob at seed_version.
|
||||
"""
|
||||
writes_by_cid: dict[str, list[tuple[str, bytes, str, int]]] = {}
|
||||
seed_blob: tuple[str, bytes] | None = None
|
||||
|
||||
for r in stage2_rows:
|
||||
kind = r["_kind"]
|
||||
if kind == "w":
|
||||
cid = cast(str, r["checkpoint_id"])
|
||||
writes_by_cid.setdefault(cid, []).append(
|
||||
cast(
|
||||
"tuple[str, bytes, str, int]",
|
||||
(r["type"], r["blob"], r["task_id"], r["idx"]),
|
||||
)
|
||||
)
|
||||
else: # kind == "b"
|
||||
seed_blob = cast("tuple[str, bytes]", (r["type"], r["blob"]))
|
||||
|
||||
for ws in writes_by_cid.values():
|
||||
ws.sort(key=lambda w: (w[2], w[3]), reverse=True)
|
||||
|
||||
if not chain_cids:
|
||||
return _ChannelWritesHistory(seed=DELTA_SENTINEL, writes=[])
|
||||
|
||||
collected: list[PendingWrite] = []
|
||||
for cid in chain_cids:
|
||||
for type_tag, write_blob, task_id, _idx in writes_by_cid.get(cid, []):
|
||||
val = self.serde.loads_typed((type_tag, write_blob))
|
||||
collected.append((task_id, channel, val))
|
||||
|
||||
seed: Any = DELTA_SENTINEL
|
||||
if seed_blob is not None and seed_blob[0] != "empty":
|
||||
seed = self.serde.loads_typed(seed_blob)
|
||||
|
||||
collected.reverse()
|
||||
return _ChannelWritesHistory(seed=seed, writes=collected)
|
||||
|
||||
def _dump_blobs(
|
||||
self,
|
||||
thread_id: str,
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "langgraph-checkpoint-postgres"
|
||||
version = "3.0.5"
|
||||
version = "3.1.0a3"
|
||||
description = "Library with a Postgres implementation of LangGraph checkpoint saver."
|
||||
authors = []
|
||||
requires-python = ">=3.10"
|
||||
@@ -12,7 +12,7 @@ readme = "README.md"
|
||||
license = "MIT"
|
||||
license-files = ['LICENSE']
|
||||
dependencies = [
|
||||
"langgraph-checkpoint>=2.1.2,<5.0.0",
|
||||
"langgraph-checkpoint>=4.1.0a3,<5.0.0",
|
||||
"orjson>=3.11.5",
|
||||
"psycopg>=3.2.0",
|
||||
"psycopg-pool>=3.2.0",
|
||||
@@ -20,7 +20,7 @@ dependencies = [
|
||||
|
||||
[project.urls]
|
||||
Source = "https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint-postgres"
|
||||
Twitter = "https://x.com/LangChain"
|
||||
Twitter = "https://x.com/langchain_oss"
|
||||
Slack = "https://www.langchain.com/join-community"
|
||||
Reddit = "https://www.reddit.com/r/LangChain/"
|
||||
|
||||
|
||||
@@ -361,9 +361,9 @@ async def test_get_checkpoint_no_channel_values(
|
||||
|
||||
load_checkpoint_tuple = saver._load_checkpoint_tuple
|
||||
|
||||
def patched_load_checkpoint_tuple(value):
|
||||
async def patched_load_checkpoint_tuple(value):
|
||||
value["checkpoint"].pop("channel_values", None)
|
||||
return load_checkpoint_tuple(value)
|
||||
return await load_checkpoint_tuple(value)
|
||||
|
||||
monkeypatch.setattr(
|
||||
saver, "_load_checkpoint_tuple", patched_load_checkpoint_tuple
|
||||
@@ -371,3 +371,47 @@ async def test_get_checkpoint_no_channel_values(
|
||||
|
||||
checkpoint = await saver.aget_tuple(config)
|
||||
assert checkpoint.checkpoint["channel_values"] == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("saver_name", ["base", "pool", "pipe"])
|
||||
async def test_delta_channel_chain_reconstruction(saver_name: str) -> None:
|
||||
"""AsyncPostgresSaver reconstructs DeltaChannel chain via point-lookup traversal."""
|
||||
pytest.importorskip(
|
||||
"langgraph.channels.delta", reason="langgraph core not installed"
|
||||
)
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
from langgraph.channels.delta import DeltaChannel
|
||||
from langgraph.graph import START, StateGraph
|
||||
from langgraph.graph.message import _messages_delta_reducer
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
class State(TypedDict):
|
||||
messages: Annotated[list, DeltaChannel(_messages_delta_reducer)]
|
||||
|
||||
def respond(state: State) -> dict:
|
||||
n = len(state["messages"])
|
||||
return {"messages": [AIMessage(content=f"reply-{n}", id=f"ai-{n}")]}
|
||||
|
||||
builder = StateGraph(State)
|
||||
builder.add_node("respond", respond)
|
||||
builder.add_edge(START, "respond")
|
||||
|
||||
async with _saver(saver_name) as saver:
|
||||
graph = builder.compile(checkpointer=saver)
|
||||
config = {"configurable": {"thread_id": "diff-channel-test-1"}}
|
||||
|
||||
await graph.ainvoke({"messages": [HumanMessage(content="hi", id="h1")]}, config)
|
||||
await graph.ainvoke(
|
||||
{"messages": [HumanMessage(content="there", id="h2")]}, config
|
||||
)
|
||||
|
||||
state = await graph.aget_state(config)
|
||||
msgs = state.values["messages"]
|
||||
assert len(msgs) == 4, f"expected 4, got {len(msgs)}: {msgs}"
|
||||
assert msgs[0].content == "hi"
|
||||
assert msgs[1].content == "reply-1"
|
||||
assert msgs[2].content == "there"
|
||||
assert msgs[3].content == "reply-3"
|
||||
|
||||
Generated
+130
-11
@@ -240,7 +240,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langchain-core"
|
||||
version = "1.2.22"
|
||||
version = "1.2.28"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jsonpatch" },
|
||||
@@ -252,14 +252,14 @@ dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "uuid-utils" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/a3/c4cd6827a1df46c821e7214b7f7b7a28b189e6c9b84ef15c6d629c5e3179/langchain_core-1.2.22.tar.gz", hash = "sha256:8d8f726d03d3652d403da915126626bb6250747e8ba406537d849e68b9f5d058", size = 842487, upload-time = "2026-03-24T18:48:44.9Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f8/a4/317a1a3ac1df33a64adb3670bf88bbe3b3d5baa274db6863a979db472897/langchain_core-1.2.28.tar.gz", hash = "sha256:271a3d8bd618f795fdeba112b0753980457fc90537c46a0c11998516a74dc2cb", size = 846119, upload-time = "2026-04-08T18:19:34.867Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/a6/2ffacf0f1a3788f250e75d0b52a24896c413be11be3a6d42bcdf46fbea48/langchain_core-1.2.22-py3-none-any.whl", hash = "sha256:7e30d586b75918e828833b9ec1efc25465723566845dd652c277baf751e9c04b", size = 506829, upload-time = "2026-03-24T18:48:43.286Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/92/32f785f077c7e898da97064f113c73fbd9ad55d1e2169cf3a391b183dedb/langchain_core-1.2.28-py3-none-any.whl", hash = "sha256:80764232581eaf8057bcefa71dbf8adc1f6a28d257ebd8b95ba9b8b452e8c6ac", size = 508727, upload-time = "2026-04-08T18:19:32.823Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-checkpoint"
|
||||
version = "4.0.1"
|
||||
version = "4.1.0a3"
|
||||
source = { editable = "../checkpoint" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -307,7 +307,7 @@ test = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-checkpoint-postgres"
|
||||
version = "3.0.5"
|
||||
version = "3.1.0a3"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "langgraph-checkpoint" },
|
||||
@@ -382,7 +382,7 @@ test = [
|
||||
|
||||
[[package]]
|
||||
name = "langsmith"
|
||||
version = "0.6.4"
|
||||
version = "0.7.31"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
@@ -392,11 +392,12 @@ dependencies = [
|
||||
{ name = "requests" },
|
||||
{ name = "requests-toolbelt" },
|
||||
{ name = "uuid-utils" },
|
||||
{ name = "xxhash" },
|
||||
{ name = "zstandard" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e7/85/9c7933052a997da1b85bc5c774f3865e9b1da1c8d71541ea133178b13229/langsmith-0.6.4.tar.gz", hash = "sha256:36f7223a01c218079fbb17da5e536ebbaf5c1468c028abe070aa3ae59bc99ec8", size = 919964, upload-time = "2026-01-15T20:02:28.873Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e6/11/696019490992db5c87774dc20515529ef42a01e1d770fb754ed6d9b12fb0/langsmith-0.7.31.tar.gz", hash = "sha256:331ee4f7c26bb5be4022b9859b7d7b122cbf8c9d01d9f530114c1914b0349ffb", size = 1178480, upload-time = "2026-04-14T17:55:41.242Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/66/0f/09a6637a7ba777eb307b7c80852d9ee26438e2bdafbad6fcc849ff9d9192/langsmith-0.6.4-py3-none-any.whl", hash = "sha256:ac4835860160be371042c7adbba3cb267bcf8d96a5ea976c33a8a4acad6c5486", size = 283503, upload-time = "2026-01-15T20:02:26.662Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/a1/a013cf458c301cda86a213dd153ce0a01c93f1ab5833f951e6a44c9763ce/langsmith-0.7.31-py3-none-any.whl", hash = "sha256:0291d49203f6e80dda011af1afda61eb0595a4d697adb684590a8805e1d61fb6", size = 373276, upload-time = "2026-04-14T17:55:39.677Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -950,7 +951,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.2"
|
||||
version = "9.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
@@ -961,9 +962,9 @@ dependencies = [
|
||||
{ name = "pygments" },
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1284,6 +1285,124 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xxhash"
|
||||
version = "3.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/02/84/30869e01909fb37a6cc7e18688ee8bf1e42d57e7e0777636bd47524c43c7/xxhash-3.6.0.tar.gz", hash = "sha256:f0162a78b13a0d7617b2845b90c763339d1f1d82bb04a4b07f4ab535cc5e05d6", size = 85160, upload-time = "2025-10-02T14:37:08.097Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/34/ee/f9f1d656ad168681bb0f6b092372c1e533c4416b8069b1896a175c46e484/xxhash-3.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:87ff03d7e35c61435976554477a7f4cd1704c3596a89a8300d5ce7fc83874a71", size = 32845, upload-time = "2025-10-02T14:33:51.573Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/b1/93508d9460b292c74a09b83d16750c52a0ead89c51eea9951cb97a60d959/xxhash-3.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f572dfd3d0e2eb1a57511831cf6341242f5a9f8298a45862d085f5b93394a27d", size = 30807, upload-time = "2025-10-02T14:33:52.964Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/55/28c93a3662f2d200c70704efe74aab9640e824f8ce330d8d3943bf7c9b3c/xxhash-3.6.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:89952ea539566b9fed2bbd94e589672794b4286f342254fad28b149f9615fef8", size = 193786, upload-time = "2025-10-02T14:33:54.272Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/96/fec0be9bb4b8f5d9c57d76380a366f31a1781fb802f76fc7cda6c84893c7/xxhash-3.6.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e6f2ffb07a50b52465a1032c3cf1f4a5683f944acaca8a134a2f23674c2058", size = 212830, upload-time = "2025-10-02T14:33:55.706Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/a0/c706845ba77b9611f81fd2e93fad9859346b026e8445e76f8c6fd057cc6d/xxhash-3.6.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b5b848ad6c16d308c3ac7ad4ba6bede80ed5df2ba8ed382f8932df63158dd4b2", size = 211606, upload-time = "2025-10-02T14:33:57.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/1e/164126a2999e5045f04a69257eea946c0dc3e86541b400d4385d646b53d7/xxhash-3.6.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a034590a727b44dd8ac5914236a7b8504144447a9682586c3327e935f33ec8cc", size = 444872, upload-time = "2025-10-02T14:33:58.446Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/4b/55ab404c56cd70a2cf5ecfe484838865d0fea5627365c6c8ca156bd09c8f/xxhash-3.6.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a8f1972e75ebdd161d7896743122834fe87378160c20e97f8b09166213bf8cc", size = 193217, upload-time = "2025-10-02T14:33:59.724Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/e6/52abf06bac316db33aa269091ae7311bd53cfc6f4b120ae77bac1b348091/xxhash-3.6.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ee34327b187f002a596d7b167ebc59a1b729e963ce645964bbc050d2f1b73d07", size = 210139, upload-time = "2025-10-02T14:34:02.041Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/37/db94d490b8691236d356bc249c08819cbcef9273a1a30acf1254ff9ce157/xxhash-3.6.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:339f518c3c7a850dd033ab416ea25a692759dc7478a71131fe8869010d2b75e4", size = 197669, upload-time = "2025-10-02T14:34:03.664Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/36/c4f219ef4a17a4f7a64ed3569bc2b5a9c8311abdb22249ac96093625b1a4/xxhash-3.6.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:bf48889c9630542d4709192578aebbd836177c9f7a4a2778a7d6340107c65f06", size = 210018, upload-time = "2025-10-02T14:34:05.325Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/06/bfac889a374fc2fc439a69223d1750eed2e18a7db8514737ab630534fa08/xxhash-3.6.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:5576b002a56207f640636056b4160a378fe36a58db73ae5c27a7ec8db35f71d4", size = 413058, upload-time = "2025-10-02T14:34:06.925Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/d1/555d8447e0dd32ad0930a249a522bb2e289f0d08b6b16204cfa42c1f5a0c/xxhash-3.6.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af1f3278bd02814d6dedc5dec397993b549d6f16c19379721e5a1d31e132c49b", size = 190628, upload-time = "2025-10-02T14:34:08.669Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/15/8751330b5186cedc4ed4b597989882ea05e0408b53fa47bcb46a6125bfc6/xxhash-3.6.0-cp310-cp310-win32.whl", hash = "sha256:aed058764db109dc9052720da65fafe84873b05eb8b07e5e653597951af57c3b", size = 30577, upload-time = "2025-10-02T14:34:10.234Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/cc/53f87e8b5871a6eb2ff7e89c48c66093bda2be52315a8161ddc54ea550c4/xxhash-3.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:e82da5670f2d0d98950317f82a0e4a0197150ff19a6df2ba40399c2a3b9ae5fb", size = 31487, upload-time = "2025-10-02T14:34:11.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/00/60f9ea3bb697667a14314d7269956f58bf56bb73864f8f8d52a3c2535e9a/xxhash-3.6.0-cp310-cp310-win_arm64.whl", hash = "sha256:4a082ffff8c6ac07707fb6b671caf7c6e020c75226c561830b73d862060f281d", size = 27863, upload-time = "2025-10-02T14:34:12.619Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/d4/cc2f0400e9154df4b9964249da78ebd72f318e35ccc425e9f403c392f22a/xxhash-3.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b47bbd8cf2d72797f3c2772eaaac0ded3d3af26481a26d7d7d41dc2d3c46b04a", size = 32844, upload-time = "2025-10-02T14:34:14.037Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/ec/1cc11cd13e26ea8bc3cb4af4eaadd8d46d5014aebb67be3f71fb0b68802a/xxhash-3.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2b6821e94346f96db75abaa6e255706fb06ebd530899ed76d32cd99f20dc52fa", size = 30809, upload-time = "2025-10-02T14:34:15.484Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/5f/19fe357ea348d98ca22f456f75a30ac0916b51c753e1f8b2e0e6fb884cce/xxhash-3.6.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d0a9751f71a1a65ce3584e9cae4467651c7e70c9d31017fa57574583a4540248", size = 194665, upload-time = "2025-10-02T14:34:16.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/3b/d1f1a8f5442a5fd8beedae110c5af7604dc37349a8e16519c13c19a9a2de/xxhash-3.6.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b29ee68625ab37b04c0b40c3fafdf24d2f75ccd778333cfb698f65f6c463f62", size = 213550, upload-time = "2025-10-02T14:34:17.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/ef/3a9b05eb527457d5db13a135a2ae1a26c80fecd624d20f3e8dcc4cb170f3/xxhash-3.6.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6812c25fe0d6c36a46ccb002f40f27ac903bf18af9f6dd8f9669cb4d176ab18f", size = 212384, upload-time = "2025-10-02T14:34:19.182Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/18/ccc194ee698c6c623acbf0f8c2969811a8a4b6185af5e824cd27b9e4fd3e/xxhash-3.6.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4ccbff013972390b51a18ef1255ef5ac125c92dc9143b2d1909f59abc765540e", size = 445749, upload-time = "2025-10-02T14:34:20.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/86/cf2c0321dc3940a7aa73076f4fd677a0fb3e405cb297ead7d864fd90847e/xxhash-3.6.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:297b7fbf86c82c550e12e8fb71968b3f033d27b874276ba3624ea868c11165a8", size = 193880, upload-time = "2025-10-02T14:34:22.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/fb/96213c8560e6f948a1ecc9a7613f8032b19ee45f747f4fca4eb31bb6d6ed/xxhash-3.6.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dea26ae1eb293db089798d3973a5fc928a18fdd97cc8801226fae705b02b14b0", size = 210912, upload-time = "2025-10-02T14:34:23.937Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/aa/4395e669b0606a096d6788f40dbdf2b819d6773aa290c19e6e83cbfc312f/xxhash-3.6.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7a0b169aafb98f4284f73635a8e93f0735f9cbde17bd5ec332480484241aaa77", size = 198654, upload-time = "2025-10-02T14:34:25.644Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/74/b044fcd6b3d89e9b1b665924d85d3f400636c23590226feb1eb09e1176ce/xxhash-3.6.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:08d45aef063a4531b785cd72de4887766d01dc8f362a515693df349fdb825e0c", size = 210867, upload-time = "2025-10-02T14:34:27.203Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/fd/3ce73bf753b08cb19daee1eb14aa0d7fe331f8da9c02dd95316ddfe5275e/xxhash-3.6.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:929142361a48ee07f09121fe9e96a84950e8d4df3bb298ca5d88061969f34d7b", size = 414012, upload-time = "2025-10-02T14:34:28.409Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/b3/5a4241309217c5c876f156b10778f3ab3af7ba7e3259e6d5f5c7d0129eb2/xxhash-3.6.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:51312c768403d8540487dbbfb557454cfc55589bbde6424456951f7fcd4facb3", size = 191409, upload-time = "2025-10-02T14:34:29.696Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/01/99bfbc15fb9abb9a72b088c1d95219fc4782b7d01fc835bd5744d66dd0b8/xxhash-3.6.0-cp311-cp311-win32.whl", hash = "sha256:d1927a69feddc24c987b337ce81ac15c4720955b667fe9b588e02254b80446fd", size = 30574, upload-time = "2025-10-02T14:34:31.028Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/79/9d24d7f53819fe301b231044ea362ce64e86c74f6e8c8e51320de248b3e5/xxhash-3.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:26734cdc2d4ffe449b41d186bbeac416f704a482ed835d375a5c0cb02bc63fef", size = 31481, upload-time = "2025-10-02T14:34:32.062Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/4e/15cd0e3e8772071344eab2961ce83f6e485111fed8beb491a3f1ce100270/xxhash-3.6.0-cp311-cp311-win_arm64.whl", hash = "sha256:d72f67ef8bf36e05f5b6c65e8524f265bd61071471cd4cf1d36743ebeeeb06b7", size = 27861, upload-time = "2025-10-02T14:34:33.555Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/07/d9412f3d7d462347e4511181dea65e47e0d0e16e26fbee2ea86a2aefb657/xxhash-3.6.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:01362c4331775398e7bb34e3ab403bc9ee9f7c497bc7dee6272114055277dd3c", size = 32744, upload-time = "2025-10-02T14:34:34.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/35/0429ee11d035fc33abe32dca1b2b69e8c18d236547b9a9b72c1929189b9a/xxhash-3.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b7b2df81a23f8cb99656378e72501b2cb41b1827c0f5a86f87d6b06b69f9f204", size = 30816, upload-time = "2025-10-02T14:34:36.043Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/f2/57eb99aa0f7d98624c0932c5b9a170e1806406cdbcdb510546634a1359e0/xxhash-3.6.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:dc94790144e66b14f67b10ac8ed75b39ca47536bf8800eb7c24b50271ea0c490", size = 194035, upload-time = "2025-10-02T14:34:37.354Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/ed/6224ba353690d73af7a3f1c7cdb1fc1b002e38f783cb991ae338e1eb3d79/xxhash-3.6.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93f107c673bccf0d592cdba077dedaf52fe7f42dcd7676eba1f6d6f0c3efffd2", size = 212914, upload-time = "2025-10-02T14:34:38.6Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/86/fb6b6130d8dd6b8942cc17ab4d90e223653a89aa32ad2776f8af7064ed13/xxhash-3.6.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aa5ee3444c25b69813663c9f8067dcfaa2e126dc55e8dddf40f4d1c25d7effa", size = 212163, upload-time = "2025-10-02T14:34:39.872Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/dc/e84875682b0593e884ad73b2d40767b5790d417bde603cceb6878901d647/xxhash-3.6.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7f99123f0e1194fa59cc69ad46dbae2e07becec5df50a0509a808f90a0f03f0", size = 445411, upload-time = "2025-10-02T14:34:41.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/4f/426f91b96701ec2f37bb2b8cec664eff4f658a11f3fa9d94f0a887ea6d2b/xxhash-3.6.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49e03e6fe2cac4a1bc64952dd250cf0dbc5ef4ebb7b8d96bce82e2de163c82a2", size = 193883, upload-time = "2025-10-02T14:34:43.249Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/5a/ddbb83eee8e28b778eacfc5a85c969673e4023cdeedcfcef61f36731610b/xxhash-3.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bd17fede52a17a4f9a7bc4472a5867cb0b160deeb431795c0e4abe158bc784e9", size = 210392, upload-time = "2025-10-02T14:34:45.042Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/c2/ff69efd07c8c074ccdf0a4f36fcdd3d27363665bcdf4ba399abebe643465/xxhash-3.6.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6fb5f5476bef678f69db04f2bd1efbed3030d2aba305b0fc1773645f187d6a4e", size = 197898, upload-time = "2025-10-02T14:34:46.302Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/ca/faa05ac19b3b622c7c9317ac3e23954187516298a091eb02c976d0d3dd45/xxhash-3.6.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:843b52f6d88071f87eba1631b684fcb4b2068cd2180a0224122fe4ef011a9374", size = 210655, upload-time = "2025-10-02T14:34:47.571Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/7a/06aa7482345480cc0cb597f5c875b11a82c3953f534394f620b0be2f700c/xxhash-3.6.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7d14a6cfaf03b1b6f5f9790f76880601ccc7896aff7ab9cd8978a939c1eb7e0d", size = 414001, upload-time = "2025-10-02T14:34:49.273Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/07/63ffb386cd47029aa2916b3d2f454e6cc5b9f5c5ada3790377d5430084e7/xxhash-3.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:418daf3db71e1413cfe211c2f9a528456936645c17f46b5204705581a45390ae", size = 191431, upload-time = "2025-10-02T14:34:50.798Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/93/14fde614cadb4ddf5e7cebf8918b7e8fac5ae7861c1875964f17e678205c/xxhash-3.6.0-cp312-cp312-win32.whl", hash = "sha256:50fc255f39428a27299c20e280d6193d8b63b8ef8028995323bf834a026b4fbb", size = 30617, upload-time = "2025-10-02T14:34:51.954Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/5d/0d125536cbe7565a83d06e43783389ecae0c0f2ed037b48ede185de477c0/xxhash-3.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:c0f2ab8c715630565ab8991b536ecded9416d615538be8ecddce43ccf26cbc7c", size = 31534, upload-time = "2025-10-02T14:34:53.276Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/85/6ec269b0952ec7e36ba019125982cf11d91256a778c7c3f98a4c5043d283/xxhash-3.6.0-cp312-cp312-win_arm64.whl", hash = "sha256:eae5c13f3bc455a3bbb68bdc513912dc7356de7e2280363ea235f71f54064829", size = 27876, upload-time = "2025-10-02T14:34:54.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/76/35d05267ac82f53ae9b0e554da7c5e281ee61f3cad44c743f0fcd354f211/xxhash-3.6.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:599e64ba7f67472481ceb6ee80fa3bd828fd61ba59fb11475572cc5ee52b89ec", size = 32738, upload-time = "2025-10-02T14:34:55.839Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/a8/3fbce1cd96534a95e35d5120637bf29b0d7f5d8fa2f6374e31b4156dd419/xxhash-3.6.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7d8b8aaa30fca4f16f0c84a5c8d7ddee0e25250ec2796c973775373257dde8f1", size = 30821, upload-time = "2025-10-02T14:34:57.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/ea/d387530ca7ecfa183cb358027f1833297c6ac6098223fd14f9782cd0015c/xxhash-3.6.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d597acf8506d6e7101a4a44a5e428977a51c0fadbbfd3c39650cca9253f6e5a6", size = 194127, upload-time = "2025-10-02T14:34:59.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/0c/71435dcb99874b09a43b8d7c54071e600a7481e42b3e3ce1eb5226a5711a/xxhash-3.6.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:858dc935963a33bc33490128edc1c12b0c14d9c7ebaa4e387a7869ecc4f3e263", size = 212975, upload-time = "2025-10-02T14:35:00.816Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/7a/c2b3d071e4bb4a90b7057228a99b10d51744878f4a8a6dd643c8bd897620/xxhash-3.6.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba284920194615cb8edf73bf52236ce2e1664ccd4a38fdb543506413529cc546", size = 212241, upload-time = "2025-10-02T14:35:02.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/5f/640b6eac0128e215f177df99eadcd0f1b7c42c274ab6a394a05059694c5a/xxhash-3.6.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b54219177f6c6674d5378bd862c6aedf64725f70dd29c472eaae154df1a2e89", size = 445471, upload-time = "2025-10-02T14:35:03.61Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/1e/3c3d3ef071b051cc3abbe3721ffb8365033a172613c04af2da89d5548a87/xxhash-3.6.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42c36dd7dbad2f5238950c377fcbf6811b1cdb1c444fab447960030cea60504d", size = 193936, upload-time = "2025-10-02T14:35:05.013Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/bd/4a5f68381939219abfe1c22a9e3a5854a4f6f6f3c4983a87d255f21f2e5d/xxhash-3.6.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f22927652cba98c44639ffdc7aaf35828dccf679b10b31c4ad72a5b530a18eb7", size = 210440, upload-time = "2025-10-02T14:35:06.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/37/b80fe3d5cfb9faff01a02121a0f4d565eb7237e9e5fc66e73017e74dcd36/xxhash-3.6.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b45fad44d9c5c119e9c6fbf2e1c656a46dc68e280275007bbfd3d572b21426db", size = 197990, upload-time = "2025-10-02T14:35:07.735Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/fd/2c0a00c97b9e18f72e1f240ad4e8f8a90fd9d408289ba9c7c495ed7dc05c/xxhash-3.6.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6f2580ffab1a8b68ef2b901cde7e55fa8da5e4be0977c68f78fc80f3c143de42", size = 210689, upload-time = "2025-10-02T14:35:09.438Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/86/5dd8076a926b9a95db3206aba20d89a7fc14dd5aac16e5c4de4b56033140/xxhash-3.6.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40c391dd3cd041ebc3ffe6f2c862f402e306eb571422e0aa918d8070ba31da11", size = 414068, upload-time = "2025-10-02T14:35:11.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/3c/0bb129170ee8f3650f08e993baee550a09593462a5cddd8e44d0011102b1/xxhash-3.6.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f205badabde7aafd1a31e8ca2a3e5a763107a71c397c4481d6a804eb5063d8bd", size = 191495, upload-time = "2025-10-02T14:35:12.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/3a/6797e0114c21d1725e2577508e24006fd7ff1d8c0c502d3b52e45c1771d8/xxhash-3.6.0-cp313-cp313-win32.whl", hash = "sha256:2577b276e060b73b73a53042ea5bd5203d3e6347ce0d09f98500f418a9fcf799", size = 30620, upload-time = "2025-10-02T14:35:14.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/15/9bc32671e9a38b413a76d24722a2bf8784a132c043063a8f5152d390b0f9/xxhash-3.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:757320d45d2fbcce8f30c42a6b2f47862967aea7bf458b9625b4bbe7ee390392", size = 31542, upload-time = "2025-10-02T14:35:15.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/c5/cc01e4f6188656e56112d6a8e0dfe298a16934b8c47a247236549a3f7695/xxhash-3.6.0-cp313-cp313-win_arm64.whl", hash = "sha256:457b8f85dec5825eed7b69c11ae86834a018b8e3df5e77783c999663da2f96d6", size = 27880, upload-time = "2025-10-02T14:35:16.315Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/30/25e5321c8732759e930c555176d37e24ab84365482d257c3b16362235212/xxhash-3.6.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a42e633d75cdad6d625434e3468126c73f13f7584545a9cf34e883aa1710e702", size = 32956, upload-time = "2025-10-02T14:35:17.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/3c/0573299560d7d9f8ab1838f1efc021a280b5ae5ae2e849034ef3dee18810/xxhash-3.6.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:568a6d743219e717b07b4e03b0a828ce593833e498c3b64752e0f5df6bfe84db", size = 31072, upload-time = "2025-10-02T14:35:18.844Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/1c/52d83a06e417cd9d4137722693424885cc9878249beb3a7c829e74bf7ce9/xxhash-3.6.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bec91b562d8012dae276af8025a55811b875baace6af510412a5e58e3121bc54", size = 196409, upload-time = "2025-10-02T14:35:20.31Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/8e/c6d158d12a79bbd0b878f8355432075fc82759e356ab5a111463422a239b/xxhash-3.6.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78e7f2f4c521c30ad5e786fdd6bae89d47a32672a80195467b5de0480aa97b1f", size = 215736, upload-time = "2025-10-02T14:35:21.616Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/68/c4c80614716345d55071a396cf03d06e34b5f4917a467faf43083c995155/xxhash-3.6.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ed0df1b11a79856df5ffcab572cbd6b9627034c1c748c5566fa79df9048a7c5", size = 214833, upload-time = "2025-10-02T14:35:23.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/e9/ae27c8ffec8b953efa84c7c4a6c6802c263d587b9fc0d6e7cea64e08c3af/xxhash-3.6.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0e4edbfc7d420925b0dd5e792478ed393d6e75ff8fc219a6546fb446b6a417b1", size = 448348, upload-time = "2025-10-02T14:35:25.111Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/6b/33e21afb1b5b3f46b74b6bd1913639066af218d704cc0941404ca717fc57/xxhash-3.6.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fba27a198363a7ef87f8c0f6b171ec36b674fe9053742c58dd7e3201c1ab30ee", size = 196070, upload-time = "2025-10-02T14:35:26.586Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/b6/fcabd337bc5fa624e7203aa0fa7d0c49eed22f72e93229431752bddc83d9/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:794fe9145fe60191c6532fa95063765529770edcdd67b3d537793e8004cabbfd", size = 212907, upload-time = "2025-10-02T14:35:28.087Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/d3/9ee6160e644d660fcf176c5825e61411c7f62648728f69c79ba237250143/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:6105ef7e62b5ac73a837778efc331a591d8442f8ef5c7e102376506cb4ae2729", size = 200839, upload-time = "2025-10-02T14:35:29.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/98/e8de5baa5109394baf5118f5e72ab21a86387c4f89b0e77ef3e2f6b0327b/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f01375c0e55395b814a679b3eea205db7919ac2af213f4a6682e01220e5fe292", size = 213304, upload-time = "2025-10-02T14:35:31.222Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/1d/71056535dec5c3177eeb53e38e3d367dd1d16e024e63b1cee208d572a033/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d706dca2d24d834a4661619dcacf51a75c16d65985718d6a7d73c1eeeb903ddf", size = 416930, upload-time = "2025-10-02T14:35:32.517Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/6c/5cbde9de2cd967c322e651c65c543700b19e7ae3e0aae8ece3469bf9683d/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5f059d9faeacd49c0215d66f4056e1326c80503f51a1532ca336a385edadd033", size = 193787, upload-time = "2025-10-02T14:35:33.827Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/fa/0172e350361d61febcea941b0cc541d6e6c8d65d153e85f850a7b256ff8a/xxhash-3.6.0-cp313-cp313t-win32.whl", hash = "sha256:1244460adc3a9be84731d72b8e80625788e5815b68da3da8b83f78115a40a7ec", size = 30916, upload-time = "2025-10-02T14:35:35.107Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/e6/e8cf858a2b19d6d45820f072eff1bea413910592ff17157cabc5f1227a16/xxhash-3.6.0-cp313-cp313t-win_amd64.whl", hash = "sha256:b1e420ef35c503869c4064f4a2f2b08ad6431ab7b229a05cce39d74268bca6b8", size = 31799, upload-time = "2025-10-02T14:35:36.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/15/064b197e855bfb7b343210e82490ae672f8bc7cdf3ddb02e92f64304ee8a/xxhash-3.6.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ec44b73a4220623235f67a996c862049f375df3b1052d9899f40a6382c32d746", size = 28044, upload-time = "2025-10-02T14:35:37.195Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/5e/0138bc4484ea9b897864d59fce9be9086030825bc778b76cb5a33a906d37/xxhash-3.6.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a40a3d35b204b7cc7643cbcf8c9976d818cb47befcfac8bbefec8038ac363f3e", size = 32754, upload-time = "2025-10-02T14:35:38.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/d7/5dac2eb2ec75fd771957a13e5dda560efb2176d5203f39502a5fc571f899/xxhash-3.6.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a54844be970d3fc22630b32d515e79a90d0a3ddb2644d8d7402e3c4c8da61405", size = 30846, upload-time = "2025-10-02T14:35:39.6Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/71/8bc5be2bb00deb5682e92e8da955ebe5fa982da13a69da5a40a4c8db12fb/xxhash-3.6.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:016e9190af8f0a4e3741343777710e3d5717427f175adfdc3e72508f59e2a7f3", size = 194343, upload-time = "2025-10-02T14:35:40.69Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/3b/52badfb2aecec2c377ddf1ae75f55db3ba2d321c5e164f14461c90837ef3/xxhash-3.6.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f6f72232f849eb9d0141e2ebe2677ece15adfd0fa599bc058aad83c714bb2c6", size = 213074, upload-time = "2025-10-02T14:35:42.29Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/2b/ae46b4e9b92e537fa30d03dbc19cdae57ed407e9c26d163895e968e3de85/xxhash-3.6.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:63275a8aba7865e44b1813d2177e0f5ea7eadad3dd063a21f7cf9afdc7054063", size = 212388, upload-time = "2025-10-02T14:35:43.929Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/80/49f88d3afc724b4ac7fbd664c8452d6db51b49915be48c6982659e0e7942/xxhash-3.6.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cd01fa2aa00d8b017c97eb46b9a794fbdca53fc14f845f5a328c71254b0abb7", size = 445614, upload-time = "2025-10-02T14:35:45.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/ba/603ce3961e339413543d8cd44f21f2c80e2a7c5cfe692a7b1f2cccf58f3c/xxhash-3.6.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0226aa89035b62b6a86d3c68df4d7c1f47a342b8683da2b60cedcddb46c4d95b", size = 194024, upload-time = "2025-10-02T14:35:46.959Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/d1/8e225ff7113bf81545cfdcd79eef124a7b7064a0bba53605ff39590b95c2/xxhash-3.6.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c6e193e9f56e4ca4923c61238cdaced324f0feac782544eb4c6d55ad5cc99ddd", size = 210541, upload-time = "2025-10-02T14:35:48.301Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/58/0f89d149f0bad89def1a8dd38feb50ccdeb643d9797ec84707091d4cb494/xxhash-3.6.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9176dcaddf4ca963d4deb93866d739a343c01c969231dbe21680e13a5d1a5bf0", size = 198305, upload-time = "2025-10-02T14:35:49.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/38/5eab81580703c4df93feb5f32ff8fa7fe1e2c51c1f183ee4e48d4bb9d3d7/xxhash-3.6.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c1ce4009c97a752e682b897aa99aef84191077a9433eb237774689f14f8ec152", size = 210848, upload-time = "2025-10-02T14:35:50.877Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/6b/953dc4b05c3ce678abca756416e4c130d2382f877a9c30a20d08ee6a77c0/xxhash-3.6.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:8cb2f4f679b01513b7adbb9b1b2f0f9cdc31b70007eaf9d59d0878809f385b11", size = 414142, upload-time = "2025-10-02T14:35:52.15Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/a9/238ec0d4e81a10eb5026d4a6972677cbc898ba6c8b9dbaec12ae001b1b35/xxhash-3.6.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:653a91d7c2ab54a92c19ccf43508b6a555440b9be1bc8be553376778be7f20b5", size = 191547, upload-time = "2025-10-02T14:35:53.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/ee/3cf8589e06c2164ac77c3bf0aa127012801128f1feebf2a079272da5737c/xxhash-3.6.0-cp314-cp314-win32.whl", hash = "sha256:a756fe893389483ee8c394d06b5ab765d96e68fbbfe6fde7aa17e11f5720559f", size = 31214, upload-time = "2025-10-02T14:35:54.746Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/5d/a19552fbc6ad4cb54ff953c3908bbc095f4a921bc569433d791f755186f1/xxhash-3.6.0-cp314-cp314-win_amd64.whl", hash = "sha256:39be8e4e142550ef69629c9cd71b88c90e9a5db703fecbcf265546d9536ca4ad", size = 32290, upload-time = "2025-10-02T14:35:55.791Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/11/dafa0643bc30442c887b55baf8e73353a344ee89c1901b5a5c54a6c17d39/xxhash-3.6.0-cp314-cp314-win_arm64.whl", hash = "sha256:25915e6000338999236f1eb68a02a32c3275ac338628a7eaa5a269c401995679", size = 28795, upload-time = "2025-10-02T14:35:57.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/db/0e99732ed7f64182aef4a6fb145e1a295558deec2a746265dcdec12d191e/xxhash-3.6.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c5294f596a9017ca5a3e3f8884c00b91ab2ad2933cf288f4923c3fd4346cf3d4", size = 32955, upload-time = "2025-10-02T14:35:58.267Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/f4/2a7c3c68e564a099becfa44bb3d398810cc0ff6749b0d3cb8ccb93f23c14/xxhash-3.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1cf9dcc4ab9cff01dfbba78544297a3a01dafd60f3bde4e2bfd016cf7e4ddc67", size = 31072, upload-time = "2025-10-02T14:35:59.382Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/d9/72a29cddc7250e8a5819dad5d466facb5dc4c802ce120645630149127e73/xxhash-3.6.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:01262da8798422d0685f7cef03b2bd3f4f46511b02830861df548d7def4402ad", size = 196579, upload-time = "2025-10-02T14:36:00.838Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/93/b21590e1e381040e2ca305a884d89e1c345b347404f7780f07f2cdd47ef4/xxhash-3.6.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51a73fb7cb3a3ead9f7a8b583ffd9b8038e277cdb8cb87cf890e88b3456afa0b", size = 215854, upload-time = "2025-10-02T14:36:02.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/b8/edab8a7d4fa14e924b29be877d54155dcbd8b80be85ea00d2be3413a9ed4/xxhash-3.6.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b9c6df83594f7df8f7f708ce5ebeacfc69f72c9fbaaababf6cf4758eaada0c9b", size = 214965, upload-time = "2025-10-02T14:36:03.507Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/67/dfa980ac7f0d509d54ea0d5a486d2bb4b80c3f1bb22b66e6a05d3efaf6c0/xxhash-3.6.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:627f0af069b0ea56f312fd5189001c24578868643203bca1abbc2c52d3a6f3ca", size = 448484, upload-time = "2025-10-02T14:36:04.828Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/63/8ffc2cc97e811c0ca5d00ab36604b3ea6f4254f20b7bc658ca825ce6c954/xxhash-3.6.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa912c62f842dfd013c5f21a642c9c10cd9f4c4e943e0af83618b4a404d9091a", size = 196162, upload-time = "2025-10-02T14:36:06.182Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/77/07f0e7a3edd11a6097e990f6e5b815b6592459cb16dae990d967693e6ea9/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b465afd7909db30168ab62afe40b2fcf79eedc0b89a6c0ab3123515dc0df8b99", size = 213007, upload-time = "2025-10-02T14:36:07.733Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/d8/bc5fa0d152837117eb0bef6f83f956c509332ce133c91c63ce07ee7c4873/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a881851cf38b0a70e7c4d3ce81fc7afd86fbc2a024f4cfb2a97cf49ce04b75d3", size = 200956, upload-time = "2025-10-02T14:36:09.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/a5/d749334130de9411783873e9b98ecc46688dad5db64ca6e04b02acc8b473/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9b3222c686a919a0f3253cfc12bb118b8b103506612253b5baeaac10d8027cf6", size = 213401, upload-time = "2025-10-02T14:36:10.585Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/72/abed959c956a4bfc72b58c0384bb7940663c678127538634d896b1195c10/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:c5aa639bc113e9286137cec8fadc20e9cd732b2cc385c0b7fa673b84fc1f2a93", size = 417083, upload-time = "2025-10-02T14:36:12.276Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/b3/62fd2b586283b7d7d665fb98e266decadf31f058f1cf6c478741f68af0cb/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5c1343d49ac102799905e115aee590183c3921d475356cb24b4de29a4bc56518", size = 193913, upload-time = "2025-10-02T14:36:14.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/9a/c19c42c5b3f5a4aad748a6d5b4f23df3bed7ee5445accc65a0fb3ff03953/xxhash-3.6.0-cp314-cp314t-win32.whl", hash = "sha256:5851f033c3030dd95c086b4a36a2683c2ff4a799b23af60977188b057e467119", size = 31586, upload-time = "2025-10-02T14:36:15.603Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/d6/4cc450345be9924fd5dc8c590ceda1db5b43a0a889587b0ae81a95511360/xxhash-3.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0444e7967dac37569052d2409b00a8860c2135cff05502df4da80267d384849f", size = 32526, upload-time = "2025-10-02T14:36:16.708Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/c9/7243eb3f9eaabd1a88a5a5acadf06df2d83b100c62684b7425c6a11bcaa8/xxhash-3.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:bb79b1e63f6fd84ec778a4b1916dfe0a7c3fdb986c06addd5db3a0d413819d95", size = 28898, upload-time = "2025-10-02T14:36:17.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/1e/8aec23647a34a249f62e2398c42955acd9b4c6ed5cf08cbea94dc46f78d2/xxhash-3.6.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0f7b7e2ec26c1666ad5fc9dbfa426a6a3367ceaf79db5dd76264659d509d73b0", size = 30662, upload-time = "2025-10-02T14:37:01.743Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/0b/b14510b38ba91caf43006209db846a696ceea6a847a0c9ba0a5b1adc53d6/xxhash-3.6.0-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5dc1e14d14fa0f5789ec29a7062004b5933964bb9b02aae6622b8f530dc40296", size = 41056, upload-time = "2025-10-02T14:37:02.879Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/55/15a7b8a56590e66ccd374bbfa3f9ffc45b810886c8c3b614e3f90bd2367c/xxhash-3.6.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:881b47fc47e051b37d94d13e7455131054b56749b91b508b0907eb07900d1c13", size = 36251, upload-time = "2025-10-02T14:37:04.44Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/b2/5ac99a041a29e58e95f907876b04f7067a0242cb85b5f39e726153981503/xxhash-3.6.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6dc31591899f5e5666f04cc2e529e69b4072827085c1ef15294d91a004bc1bd", size = 32481, upload-time = "2025-10-02T14:37:05.869Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/d9/8d95e906764a386a3d3b596f3c68bb63687dfca806373509f51ce8eea81f/xxhash-3.6.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:15e0dac10eb9309508bfc41f7f9deaa7755c69e35af835db9cb10751adebc35d", size = 31565, upload-time = "2025-10-02T14:37:06.966Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstandard"
|
||||
version = "0.25.0"
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
Implementation of LangGraph CheckpointSaver that uses SQLite DB (both sync and async, via `aiosqlite`)
|
||||
|
||||
## Security
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Set `LANGGRAPH_STRICT_MSGPACK=true` or pass an explicit `allowed_msgpack_modules` list when creating your checkpointer. This restricts checkpoint deserialization to known-safe types, preventing code execution if the database is compromised. See the [langgraph-checkpoint README](https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint#serde) for details.
|
||||
|
||||
## Usage
|
||||
|
||||
```python
|
||||
|
||||
@@ -19,7 +19,7 @@ dependencies = [
|
||||
|
||||
[project.urls]
|
||||
Source = "https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint-sqlite"
|
||||
Twitter = "https://x.com/LangChain"
|
||||
Twitter = "https://x.com/langchain_oss"
|
||||
Slack = "https://www.langchain.com/join-community"
|
||||
Reddit = "https://www.reddit.com/r/LangChain/"
|
||||
|
||||
|
||||
Generated
+129
-10
@@ -249,7 +249,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langchain-core"
|
||||
version = "1.2.22"
|
||||
version = "1.2.28"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jsonpatch" },
|
||||
@@ -261,14 +261,14 @@ dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "uuid-utils" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/a3/c4cd6827a1df46c821e7214b7f7b7a28b189e6c9b84ef15c6d629c5e3179/langchain_core-1.2.22.tar.gz", hash = "sha256:8d8f726d03d3652d403da915126626bb6250747e8ba406537d849e68b9f5d058", size = 842487, upload-time = "2026-03-24T18:48:44.9Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f8/a4/317a1a3ac1df33a64adb3670bf88bbe3b3d5baa274db6863a979db472897/langchain_core-1.2.28.tar.gz", hash = "sha256:271a3d8bd618f795fdeba112b0753980457fc90537c46a0c11998516a74dc2cb", size = 846119, upload-time = "2026-04-08T18:19:34.867Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/a6/2ffacf0f1a3788f250e75d0b52a24896c413be11be3a6d42bcdf46fbea48/langchain_core-1.2.22-py3-none-any.whl", hash = "sha256:7e30d586b75918e828833b9ec1efc25465723566845dd652c277baf751e9c04b", size = 506829, upload-time = "2026-03-24T18:48:43.286Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/92/32f785f077c7e898da97064f113c73fbd9ad55d1e2169cf3a391b183dedb/langchain_core-1.2.28-py3-none-any.whl", hash = "sha256:80764232581eaf8057bcefa71dbf8adc1f6a28d257ebd8b95ba9b8b452e8c6ac", size = 508727, upload-time = "2026-04-08T18:19:32.823Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-checkpoint"
|
||||
version = "4.0.1"
|
||||
version = "4.1.0a3"
|
||||
source = { editable = "../checkpoint" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -385,7 +385,7 @@ test = [
|
||||
|
||||
[[package]]
|
||||
name = "langsmith"
|
||||
version = "0.6.4"
|
||||
version = "0.7.31"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
@@ -395,11 +395,12 @@ dependencies = [
|
||||
{ name = "requests" },
|
||||
{ name = "requests-toolbelt" },
|
||||
{ name = "uuid-utils" },
|
||||
{ name = "xxhash" },
|
||||
{ name = "zstandard" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e7/85/9c7933052a997da1b85bc5c774f3865e9b1da1c8d71541ea133178b13229/langsmith-0.6.4.tar.gz", hash = "sha256:36f7223a01c218079fbb17da5e536ebbaf5c1468c028abe070aa3ae59bc99ec8", size = 919964, upload-time = "2026-01-15T20:02:28.873Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e6/11/696019490992db5c87774dc20515529ef42a01e1d770fb754ed6d9b12fb0/langsmith-0.7.31.tar.gz", hash = "sha256:331ee4f7c26bb5be4022b9859b7d7b122cbf8c9d01d9f530114c1914b0349ffb", size = 1178480, upload-time = "2026-04-14T17:55:41.242Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/66/0f/09a6637a7ba777eb307b7c80852d9ee26438e2bdafbad6fcc849ff9d9192/langsmith-0.6.4-py3-none-any.whl", hash = "sha256:ac4835860160be371042c7adbba3cb267bcf8d96a5ea976c33a8a4acad6c5486", size = 283503, upload-time = "2026-01-15T20:02:26.662Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/a1/a013cf458c301cda86a213dd153ce0a01c93f1ab5833f951e6a44c9763ce/langsmith-0.7.31-py3-none-any.whl", hash = "sha256:0291d49203f6e80dda011af1afda61eb0595a4d697adb684590a8805e1d61fb6", size = 373276, upload-time = "2026-04-14T17:55:39.677Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -862,7 +863,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.2"
|
||||
version = "9.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
@@ -873,9 +874,9 @@ dependencies = [
|
||||
{ name = "pygments" },
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1211,6 +1212,124 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xxhash"
|
||||
version = "3.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/02/84/30869e01909fb37a6cc7e18688ee8bf1e42d57e7e0777636bd47524c43c7/xxhash-3.6.0.tar.gz", hash = "sha256:f0162a78b13a0d7617b2845b90c763339d1f1d82bb04a4b07f4ab535cc5e05d6", size = 85160, upload-time = "2025-10-02T14:37:08.097Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/34/ee/f9f1d656ad168681bb0f6b092372c1e533c4416b8069b1896a175c46e484/xxhash-3.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:87ff03d7e35c61435976554477a7f4cd1704c3596a89a8300d5ce7fc83874a71", size = 32845, upload-time = "2025-10-02T14:33:51.573Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/b1/93508d9460b292c74a09b83d16750c52a0ead89c51eea9951cb97a60d959/xxhash-3.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f572dfd3d0e2eb1a57511831cf6341242f5a9f8298a45862d085f5b93394a27d", size = 30807, upload-time = "2025-10-02T14:33:52.964Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/55/28c93a3662f2d200c70704efe74aab9640e824f8ce330d8d3943bf7c9b3c/xxhash-3.6.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:89952ea539566b9fed2bbd94e589672794b4286f342254fad28b149f9615fef8", size = 193786, upload-time = "2025-10-02T14:33:54.272Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/96/fec0be9bb4b8f5d9c57d76380a366f31a1781fb802f76fc7cda6c84893c7/xxhash-3.6.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e6f2ffb07a50b52465a1032c3cf1f4a5683f944acaca8a134a2f23674c2058", size = 212830, upload-time = "2025-10-02T14:33:55.706Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/a0/c706845ba77b9611f81fd2e93fad9859346b026e8445e76f8c6fd057cc6d/xxhash-3.6.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b5b848ad6c16d308c3ac7ad4ba6bede80ed5df2ba8ed382f8932df63158dd4b2", size = 211606, upload-time = "2025-10-02T14:33:57.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/1e/164126a2999e5045f04a69257eea946c0dc3e86541b400d4385d646b53d7/xxhash-3.6.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a034590a727b44dd8ac5914236a7b8504144447a9682586c3327e935f33ec8cc", size = 444872, upload-time = "2025-10-02T14:33:58.446Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/4b/55ab404c56cd70a2cf5ecfe484838865d0fea5627365c6c8ca156bd09c8f/xxhash-3.6.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a8f1972e75ebdd161d7896743122834fe87378160c20e97f8b09166213bf8cc", size = 193217, upload-time = "2025-10-02T14:33:59.724Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/e6/52abf06bac316db33aa269091ae7311bd53cfc6f4b120ae77bac1b348091/xxhash-3.6.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ee34327b187f002a596d7b167ebc59a1b729e963ce645964bbc050d2f1b73d07", size = 210139, upload-time = "2025-10-02T14:34:02.041Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/37/db94d490b8691236d356bc249c08819cbcef9273a1a30acf1254ff9ce157/xxhash-3.6.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:339f518c3c7a850dd033ab416ea25a692759dc7478a71131fe8869010d2b75e4", size = 197669, upload-time = "2025-10-02T14:34:03.664Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/36/c4f219ef4a17a4f7a64ed3569bc2b5a9c8311abdb22249ac96093625b1a4/xxhash-3.6.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:bf48889c9630542d4709192578aebbd836177c9f7a4a2778a7d6340107c65f06", size = 210018, upload-time = "2025-10-02T14:34:05.325Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/06/bfac889a374fc2fc439a69223d1750eed2e18a7db8514737ab630534fa08/xxhash-3.6.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:5576b002a56207f640636056b4160a378fe36a58db73ae5c27a7ec8db35f71d4", size = 413058, upload-time = "2025-10-02T14:34:06.925Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/d1/555d8447e0dd32ad0930a249a522bb2e289f0d08b6b16204cfa42c1f5a0c/xxhash-3.6.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af1f3278bd02814d6dedc5dec397993b549d6f16c19379721e5a1d31e132c49b", size = 190628, upload-time = "2025-10-02T14:34:08.669Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/15/8751330b5186cedc4ed4b597989882ea05e0408b53fa47bcb46a6125bfc6/xxhash-3.6.0-cp310-cp310-win32.whl", hash = "sha256:aed058764db109dc9052720da65fafe84873b05eb8b07e5e653597951af57c3b", size = 30577, upload-time = "2025-10-02T14:34:10.234Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/cc/53f87e8b5871a6eb2ff7e89c48c66093bda2be52315a8161ddc54ea550c4/xxhash-3.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:e82da5670f2d0d98950317f82a0e4a0197150ff19a6df2ba40399c2a3b9ae5fb", size = 31487, upload-time = "2025-10-02T14:34:11.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/00/60f9ea3bb697667a14314d7269956f58bf56bb73864f8f8d52a3c2535e9a/xxhash-3.6.0-cp310-cp310-win_arm64.whl", hash = "sha256:4a082ffff8c6ac07707fb6b671caf7c6e020c75226c561830b73d862060f281d", size = 27863, upload-time = "2025-10-02T14:34:12.619Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/d4/cc2f0400e9154df4b9964249da78ebd72f318e35ccc425e9f403c392f22a/xxhash-3.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b47bbd8cf2d72797f3c2772eaaac0ded3d3af26481a26d7d7d41dc2d3c46b04a", size = 32844, upload-time = "2025-10-02T14:34:14.037Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/ec/1cc11cd13e26ea8bc3cb4af4eaadd8d46d5014aebb67be3f71fb0b68802a/xxhash-3.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2b6821e94346f96db75abaa6e255706fb06ebd530899ed76d32cd99f20dc52fa", size = 30809, upload-time = "2025-10-02T14:34:15.484Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/5f/19fe357ea348d98ca22f456f75a30ac0916b51c753e1f8b2e0e6fb884cce/xxhash-3.6.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d0a9751f71a1a65ce3584e9cae4467651c7e70c9d31017fa57574583a4540248", size = 194665, upload-time = "2025-10-02T14:34:16.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/3b/d1f1a8f5442a5fd8beedae110c5af7604dc37349a8e16519c13c19a9a2de/xxhash-3.6.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b29ee68625ab37b04c0b40c3fafdf24d2f75ccd778333cfb698f65f6c463f62", size = 213550, upload-time = "2025-10-02T14:34:17.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/ef/3a9b05eb527457d5db13a135a2ae1a26c80fecd624d20f3e8dcc4cb170f3/xxhash-3.6.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6812c25fe0d6c36a46ccb002f40f27ac903bf18af9f6dd8f9669cb4d176ab18f", size = 212384, upload-time = "2025-10-02T14:34:19.182Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/18/ccc194ee698c6c623acbf0f8c2969811a8a4b6185af5e824cd27b9e4fd3e/xxhash-3.6.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4ccbff013972390b51a18ef1255ef5ac125c92dc9143b2d1909f59abc765540e", size = 445749, upload-time = "2025-10-02T14:34:20.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/86/cf2c0321dc3940a7aa73076f4fd677a0fb3e405cb297ead7d864fd90847e/xxhash-3.6.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:297b7fbf86c82c550e12e8fb71968b3f033d27b874276ba3624ea868c11165a8", size = 193880, upload-time = "2025-10-02T14:34:22.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/fb/96213c8560e6f948a1ecc9a7613f8032b19ee45f747f4fca4eb31bb6d6ed/xxhash-3.6.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dea26ae1eb293db089798d3973a5fc928a18fdd97cc8801226fae705b02b14b0", size = 210912, upload-time = "2025-10-02T14:34:23.937Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/aa/4395e669b0606a096d6788f40dbdf2b819d6773aa290c19e6e83cbfc312f/xxhash-3.6.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7a0b169aafb98f4284f73635a8e93f0735f9cbde17bd5ec332480484241aaa77", size = 198654, upload-time = "2025-10-02T14:34:25.644Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/74/b044fcd6b3d89e9b1b665924d85d3f400636c23590226feb1eb09e1176ce/xxhash-3.6.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:08d45aef063a4531b785cd72de4887766d01dc8f362a515693df349fdb825e0c", size = 210867, upload-time = "2025-10-02T14:34:27.203Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/fd/3ce73bf753b08cb19daee1eb14aa0d7fe331f8da9c02dd95316ddfe5275e/xxhash-3.6.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:929142361a48ee07f09121fe9e96a84950e8d4df3bb298ca5d88061969f34d7b", size = 414012, upload-time = "2025-10-02T14:34:28.409Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/b3/5a4241309217c5c876f156b10778f3ab3af7ba7e3259e6d5f5c7d0129eb2/xxhash-3.6.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:51312c768403d8540487dbbfb557454cfc55589bbde6424456951f7fcd4facb3", size = 191409, upload-time = "2025-10-02T14:34:29.696Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/01/99bfbc15fb9abb9a72b088c1d95219fc4782b7d01fc835bd5744d66dd0b8/xxhash-3.6.0-cp311-cp311-win32.whl", hash = "sha256:d1927a69feddc24c987b337ce81ac15c4720955b667fe9b588e02254b80446fd", size = 30574, upload-time = "2025-10-02T14:34:31.028Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/79/9d24d7f53819fe301b231044ea362ce64e86c74f6e8c8e51320de248b3e5/xxhash-3.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:26734cdc2d4ffe449b41d186bbeac416f704a482ed835d375a5c0cb02bc63fef", size = 31481, upload-time = "2025-10-02T14:34:32.062Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/4e/15cd0e3e8772071344eab2961ce83f6e485111fed8beb491a3f1ce100270/xxhash-3.6.0-cp311-cp311-win_arm64.whl", hash = "sha256:d72f67ef8bf36e05f5b6c65e8524f265bd61071471cd4cf1d36743ebeeeb06b7", size = 27861, upload-time = "2025-10-02T14:34:33.555Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/07/d9412f3d7d462347e4511181dea65e47e0d0e16e26fbee2ea86a2aefb657/xxhash-3.6.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:01362c4331775398e7bb34e3ab403bc9ee9f7c497bc7dee6272114055277dd3c", size = 32744, upload-time = "2025-10-02T14:34:34.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/35/0429ee11d035fc33abe32dca1b2b69e8c18d236547b9a9b72c1929189b9a/xxhash-3.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b7b2df81a23f8cb99656378e72501b2cb41b1827c0f5a86f87d6b06b69f9f204", size = 30816, upload-time = "2025-10-02T14:34:36.043Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/f2/57eb99aa0f7d98624c0932c5b9a170e1806406cdbcdb510546634a1359e0/xxhash-3.6.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:dc94790144e66b14f67b10ac8ed75b39ca47536bf8800eb7c24b50271ea0c490", size = 194035, upload-time = "2025-10-02T14:34:37.354Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/ed/6224ba353690d73af7a3f1c7cdb1fc1b002e38f783cb991ae338e1eb3d79/xxhash-3.6.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93f107c673bccf0d592cdba077dedaf52fe7f42dcd7676eba1f6d6f0c3efffd2", size = 212914, upload-time = "2025-10-02T14:34:38.6Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/86/fb6b6130d8dd6b8942cc17ab4d90e223653a89aa32ad2776f8af7064ed13/xxhash-3.6.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aa5ee3444c25b69813663c9f8067dcfaa2e126dc55e8dddf40f4d1c25d7effa", size = 212163, upload-time = "2025-10-02T14:34:39.872Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/dc/e84875682b0593e884ad73b2d40767b5790d417bde603cceb6878901d647/xxhash-3.6.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7f99123f0e1194fa59cc69ad46dbae2e07becec5df50a0509a808f90a0f03f0", size = 445411, upload-time = "2025-10-02T14:34:41.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/4f/426f91b96701ec2f37bb2b8cec664eff4f658a11f3fa9d94f0a887ea6d2b/xxhash-3.6.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49e03e6fe2cac4a1bc64952dd250cf0dbc5ef4ebb7b8d96bce82e2de163c82a2", size = 193883, upload-time = "2025-10-02T14:34:43.249Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/5a/ddbb83eee8e28b778eacfc5a85c969673e4023cdeedcfcef61f36731610b/xxhash-3.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bd17fede52a17a4f9a7bc4472a5867cb0b160deeb431795c0e4abe158bc784e9", size = 210392, upload-time = "2025-10-02T14:34:45.042Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/c2/ff69efd07c8c074ccdf0a4f36fcdd3d27363665bcdf4ba399abebe643465/xxhash-3.6.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6fb5f5476bef678f69db04f2bd1efbed3030d2aba305b0fc1773645f187d6a4e", size = 197898, upload-time = "2025-10-02T14:34:46.302Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/ca/faa05ac19b3b622c7c9317ac3e23954187516298a091eb02c976d0d3dd45/xxhash-3.6.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:843b52f6d88071f87eba1631b684fcb4b2068cd2180a0224122fe4ef011a9374", size = 210655, upload-time = "2025-10-02T14:34:47.571Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/7a/06aa7482345480cc0cb597f5c875b11a82c3953f534394f620b0be2f700c/xxhash-3.6.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7d14a6cfaf03b1b6f5f9790f76880601ccc7896aff7ab9cd8978a939c1eb7e0d", size = 414001, upload-time = "2025-10-02T14:34:49.273Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/07/63ffb386cd47029aa2916b3d2f454e6cc5b9f5c5ada3790377d5430084e7/xxhash-3.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:418daf3db71e1413cfe211c2f9a528456936645c17f46b5204705581a45390ae", size = 191431, upload-time = "2025-10-02T14:34:50.798Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/93/14fde614cadb4ddf5e7cebf8918b7e8fac5ae7861c1875964f17e678205c/xxhash-3.6.0-cp312-cp312-win32.whl", hash = "sha256:50fc255f39428a27299c20e280d6193d8b63b8ef8028995323bf834a026b4fbb", size = 30617, upload-time = "2025-10-02T14:34:51.954Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/5d/0d125536cbe7565a83d06e43783389ecae0c0f2ed037b48ede185de477c0/xxhash-3.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:c0f2ab8c715630565ab8991b536ecded9416d615538be8ecddce43ccf26cbc7c", size = 31534, upload-time = "2025-10-02T14:34:53.276Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/85/6ec269b0952ec7e36ba019125982cf11d91256a778c7c3f98a4c5043d283/xxhash-3.6.0-cp312-cp312-win_arm64.whl", hash = "sha256:eae5c13f3bc455a3bbb68bdc513912dc7356de7e2280363ea235f71f54064829", size = 27876, upload-time = "2025-10-02T14:34:54.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/76/35d05267ac82f53ae9b0e554da7c5e281ee61f3cad44c743f0fcd354f211/xxhash-3.6.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:599e64ba7f67472481ceb6ee80fa3bd828fd61ba59fb11475572cc5ee52b89ec", size = 32738, upload-time = "2025-10-02T14:34:55.839Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/a8/3fbce1cd96534a95e35d5120637bf29b0d7f5d8fa2f6374e31b4156dd419/xxhash-3.6.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7d8b8aaa30fca4f16f0c84a5c8d7ddee0e25250ec2796c973775373257dde8f1", size = 30821, upload-time = "2025-10-02T14:34:57.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/ea/d387530ca7ecfa183cb358027f1833297c6ac6098223fd14f9782cd0015c/xxhash-3.6.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d597acf8506d6e7101a4a44a5e428977a51c0fadbbfd3c39650cca9253f6e5a6", size = 194127, upload-time = "2025-10-02T14:34:59.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/0c/71435dcb99874b09a43b8d7c54071e600a7481e42b3e3ce1eb5226a5711a/xxhash-3.6.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:858dc935963a33bc33490128edc1c12b0c14d9c7ebaa4e387a7869ecc4f3e263", size = 212975, upload-time = "2025-10-02T14:35:00.816Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/7a/c2b3d071e4bb4a90b7057228a99b10d51744878f4a8a6dd643c8bd897620/xxhash-3.6.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba284920194615cb8edf73bf52236ce2e1664ccd4a38fdb543506413529cc546", size = 212241, upload-time = "2025-10-02T14:35:02.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/5f/640b6eac0128e215f177df99eadcd0f1b7c42c274ab6a394a05059694c5a/xxhash-3.6.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b54219177f6c6674d5378bd862c6aedf64725f70dd29c472eaae154df1a2e89", size = 445471, upload-time = "2025-10-02T14:35:03.61Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/1e/3c3d3ef071b051cc3abbe3721ffb8365033a172613c04af2da89d5548a87/xxhash-3.6.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42c36dd7dbad2f5238950c377fcbf6811b1cdb1c444fab447960030cea60504d", size = 193936, upload-time = "2025-10-02T14:35:05.013Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/bd/4a5f68381939219abfe1c22a9e3a5854a4f6f6f3c4983a87d255f21f2e5d/xxhash-3.6.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f22927652cba98c44639ffdc7aaf35828dccf679b10b31c4ad72a5b530a18eb7", size = 210440, upload-time = "2025-10-02T14:35:06.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/37/b80fe3d5cfb9faff01a02121a0f4d565eb7237e9e5fc66e73017e74dcd36/xxhash-3.6.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b45fad44d9c5c119e9c6fbf2e1c656a46dc68e280275007bbfd3d572b21426db", size = 197990, upload-time = "2025-10-02T14:35:07.735Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/fd/2c0a00c97b9e18f72e1f240ad4e8f8a90fd9d408289ba9c7c495ed7dc05c/xxhash-3.6.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6f2580ffab1a8b68ef2b901cde7e55fa8da5e4be0977c68f78fc80f3c143de42", size = 210689, upload-time = "2025-10-02T14:35:09.438Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/86/5dd8076a926b9a95db3206aba20d89a7fc14dd5aac16e5c4de4b56033140/xxhash-3.6.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40c391dd3cd041ebc3ffe6f2c862f402e306eb571422e0aa918d8070ba31da11", size = 414068, upload-time = "2025-10-02T14:35:11.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/3c/0bb129170ee8f3650f08e993baee550a09593462a5cddd8e44d0011102b1/xxhash-3.6.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f205badabde7aafd1a31e8ca2a3e5a763107a71c397c4481d6a804eb5063d8bd", size = 191495, upload-time = "2025-10-02T14:35:12.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/3a/6797e0114c21d1725e2577508e24006fd7ff1d8c0c502d3b52e45c1771d8/xxhash-3.6.0-cp313-cp313-win32.whl", hash = "sha256:2577b276e060b73b73a53042ea5bd5203d3e6347ce0d09f98500f418a9fcf799", size = 30620, upload-time = "2025-10-02T14:35:14.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/15/9bc32671e9a38b413a76d24722a2bf8784a132c043063a8f5152d390b0f9/xxhash-3.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:757320d45d2fbcce8f30c42a6b2f47862967aea7bf458b9625b4bbe7ee390392", size = 31542, upload-time = "2025-10-02T14:35:15.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/c5/cc01e4f6188656e56112d6a8e0dfe298a16934b8c47a247236549a3f7695/xxhash-3.6.0-cp313-cp313-win_arm64.whl", hash = "sha256:457b8f85dec5825eed7b69c11ae86834a018b8e3df5e77783c999663da2f96d6", size = 27880, upload-time = "2025-10-02T14:35:16.315Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/30/25e5321c8732759e930c555176d37e24ab84365482d257c3b16362235212/xxhash-3.6.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a42e633d75cdad6d625434e3468126c73f13f7584545a9cf34e883aa1710e702", size = 32956, upload-time = "2025-10-02T14:35:17.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/3c/0573299560d7d9f8ab1838f1efc021a280b5ae5ae2e849034ef3dee18810/xxhash-3.6.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:568a6d743219e717b07b4e03b0a828ce593833e498c3b64752e0f5df6bfe84db", size = 31072, upload-time = "2025-10-02T14:35:18.844Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/1c/52d83a06e417cd9d4137722693424885cc9878249beb3a7c829e74bf7ce9/xxhash-3.6.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bec91b562d8012dae276af8025a55811b875baace6af510412a5e58e3121bc54", size = 196409, upload-time = "2025-10-02T14:35:20.31Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/8e/c6d158d12a79bbd0b878f8355432075fc82759e356ab5a111463422a239b/xxhash-3.6.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78e7f2f4c521c30ad5e786fdd6bae89d47a32672a80195467b5de0480aa97b1f", size = 215736, upload-time = "2025-10-02T14:35:21.616Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/68/c4c80614716345d55071a396cf03d06e34b5f4917a467faf43083c995155/xxhash-3.6.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ed0df1b11a79856df5ffcab572cbd6b9627034c1c748c5566fa79df9048a7c5", size = 214833, upload-time = "2025-10-02T14:35:23.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/e9/ae27c8ffec8b953efa84c7c4a6c6802c263d587b9fc0d6e7cea64e08c3af/xxhash-3.6.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0e4edbfc7d420925b0dd5e792478ed393d6e75ff8fc219a6546fb446b6a417b1", size = 448348, upload-time = "2025-10-02T14:35:25.111Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/6b/33e21afb1b5b3f46b74b6bd1913639066af218d704cc0941404ca717fc57/xxhash-3.6.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fba27a198363a7ef87f8c0f6b171ec36b674fe9053742c58dd7e3201c1ab30ee", size = 196070, upload-time = "2025-10-02T14:35:26.586Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/b6/fcabd337bc5fa624e7203aa0fa7d0c49eed22f72e93229431752bddc83d9/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:794fe9145fe60191c6532fa95063765529770edcdd67b3d537793e8004cabbfd", size = 212907, upload-time = "2025-10-02T14:35:28.087Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/d3/9ee6160e644d660fcf176c5825e61411c7f62648728f69c79ba237250143/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:6105ef7e62b5ac73a837778efc331a591d8442f8ef5c7e102376506cb4ae2729", size = 200839, upload-time = "2025-10-02T14:35:29.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/98/e8de5baa5109394baf5118f5e72ab21a86387c4f89b0e77ef3e2f6b0327b/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f01375c0e55395b814a679b3eea205db7919ac2af213f4a6682e01220e5fe292", size = 213304, upload-time = "2025-10-02T14:35:31.222Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/1d/71056535dec5c3177eeb53e38e3d367dd1d16e024e63b1cee208d572a033/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d706dca2d24d834a4661619dcacf51a75c16d65985718d6a7d73c1eeeb903ddf", size = 416930, upload-time = "2025-10-02T14:35:32.517Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/6c/5cbde9de2cd967c322e651c65c543700b19e7ae3e0aae8ece3469bf9683d/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5f059d9faeacd49c0215d66f4056e1326c80503f51a1532ca336a385edadd033", size = 193787, upload-time = "2025-10-02T14:35:33.827Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/fa/0172e350361d61febcea941b0cc541d6e6c8d65d153e85f850a7b256ff8a/xxhash-3.6.0-cp313-cp313t-win32.whl", hash = "sha256:1244460adc3a9be84731d72b8e80625788e5815b68da3da8b83f78115a40a7ec", size = 30916, upload-time = "2025-10-02T14:35:35.107Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/e6/e8cf858a2b19d6d45820f072eff1bea413910592ff17157cabc5f1227a16/xxhash-3.6.0-cp313-cp313t-win_amd64.whl", hash = "sha256:b1e420ef35c503869c4064f4a2f2b08ad6431ab7b229a05cce39d74268bca6b8", size = 31799, upload-time = "2025-10-02T14:35:36.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/15/064b197e855bfb7b343210e82490ae672f8bc7cdf3ddb02e92f64304ee8a/xxhash-3.6.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ec44b73a4220623235f67a996c862049f375df3b1052d9899f40a6382c32d746", size = 28044, upload-time = "2025-10-02T14:35:37.195Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/5e/0138bc4484ea9b897864d59fce9be9086030825bc778b76cb5a33a906d37/xxhash-3.6.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a40a3d35b204b7cc7643cbcf8c9976d818cb47befcfac8bbefec8038ac363f3e", size = 32754, upload-time = "2025-10-02T14:35:38.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/d7/5dac2eb2ec75fd771957a13e5dda560efb2176d5203f39502a5fc571f899/xxhash-3.6.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a54844be970d3fc22630b32d515e79a90d0a3ddb2644d8d7402e3c4c8da61405", size = 30846, upload-time = "2025-10-02T14:35:39.6Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/71/8bc5be2bb00deb5682e92e8da955ebe5fa982da13a69da5a40a4c8db12fb/xxhash-3.6.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:016e9190af8f0a4e3741343777710e3d5717427f175adfdc3e72508f59e2a7f3", size = 194343, upload-time = "2025-10-02T14:35:40.69Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/3b/52badfb2aecec2c377ddf1ae75f55db3ba2d321c5e164f14461c90837ef3/xxhash-3.6.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f6f72232f849eb9d0141e2ebe2677ece15adfd0fa599bc058aad83c714bb2c6", size = 213074, upload-time = "2025-10-02T14:35:42.29Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/2b/ae46b4e9b92e537fa30d03dbc19cdae57ed407e9c26d163895e968e3de85/xxhash-3.6.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:63275a8aba7865e44b1813d2177e0f5ea7eadad3dd063a21f7cf9afdc7054063", size = 212388, upload-time = "2025-10-02T14:35:43.929Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/80/49f88d3afc724b4ac7fbd664c8452d6db51b49915be48c6982659e0e7942/xxhash-3.6.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cd01fa2aa00d8b017c97eb46b9a794fbdca53fc14f845f5a328c71254b0abb7", size = 445614, upload-time = "2025-10-02T14:35:45.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/ba/603ce3961e339413543d8cd44f21f2c80e2a7c5cfe692a7b1f2cccf58f3c/xxhash-3.6.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0226aa89035b62b6a86d3c68df4d7c1f47a342b8683da2b60cedcddb46c4d95b", size = 194024, upload-time = "2025-10-02T14:35:46.959Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/d1/8e225ff7113bf81545cfdcd79eef124a7b7064a0bba53605ff39590b95c2/xxhash-3.6.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c6e193e9f56e4ca4923c61238cdaced324f0feac782544eb4c6d55ad5cc99ddd", size = 210541, upload-time = "2025-10-02T14:35:48.301Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/58/0f89d149f0bad89def1a8dd38feb50ccdeb643d9797ec84707091d4cb494/xxhash-3.6.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9176dcaddf4ca963d4deb93866d739a343c01c969231dbe21680e13a5d1a5bf0", size = 198305, upload-time = "2025-10-02T14:35:49.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/38/5eab81580703c4df93feb5f32ff8fa7fe1e2c51c1f183ee4e48d4bb9d3d7/xxhash-3.6.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c1ce4009c97a752e682b897aa99aef84191077a9433eb237774689f14f8ec152", size = 210848, upload-time = "2025-10-02T14:35:50.877Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/6b/953dc4b05c3ce678abca756416e4c130d2382f877a9c30a20d08ee6a77c0/xxhash-3.6.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:8cb2f4f679b01513b7adbb9b1b2f0f9cdc31b70007eaf9d59d0878809f385b11", size = 414142, upload-time = "2025-10-02T14:35:52.15Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/a9/238ec0d4e81a10eb5026d4a6972677cbc898ba6c8b9dbaec12ae001b1b35/xxhash-3.6.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:653a91d7c2ab54a92c19ccf43508b6a555440b9be1bc8be553376778be7f20b5", size = 191547, upload-time = "2025-10-02T14:35:53.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/ee/3cf8589e06c2164ac77c3bf0aa127012801128f1feebf2a079272da5737c/xxhash-3.6.0-cp314-cp314-win32.whl", hash = "sha256:a756fe893389483ee8c394d06b5ab765d96e68fbbfe6fde7aa17e11f5720559f", size = 31214, upload-time = "2025-10-02T14:35:54.746Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/5d/a19552fbc6ad4cb54ff953c3908bbc095f4a921bc569433d791f755186f1/xxhash-3.6.0-cp314-cp314-win_amd64.whl", hash = "sha256:39be8e4e142550ef69629c9cd71b88c90e9a5db703fecbcf265546d9536ca4ad", size = 32290, upload-time = "2025-10-02T14:35:55.791Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/11/dafa0643bc30442c887b55baf8e73353a344ee89c1901b5a5c54a6c17d39/xxhash-3.6.0-cp314-cp314-win_arm64.whl", hash = "sha256:25915e6000338999236f1eb68a02a32c3275ac338628a7eaa5a269c401995679", size = 28795, upload-time = "2025-10-02T14:35:57.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/db/0e99732ed7f64182aef4a6fb145e1a295558deec2a746265dcdec12d191e/xxhash-3.6.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c5294f596a9017ca5a3e3f8884c00b91ab2ad2933cf288f4923c3fd4346cf3d4", size = 32955, upload-time = "2025-10-02T14:35:58.267Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/f4/2a7c3c68e564a099becfa44bb3d398810cc0ff6749b0d3cb8ccb93f23c14/xxhash-3.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1cf9dcc4ab9cff01dfbba78544297a3a01dafd60f3bde4e2bfd016cf7e4ddc67", size = 31072, upload-time = "2025-10-02T14:35:59.382Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/d9/72a29cddc7250e8a5819dad5d466facb5dc4c802ce120645630149127e73/xxhash-3.6.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:01262da8798422d0685f7cef03b2bd3f4f46511b02830861df548d7def4402ad", size = 196579, upload-time = "2025-10-02T14:36:00.838Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/93/b21590e1e381040e2ca305a884d89e1c345b347404f7780f07f2cdd47ef4/xxhash-3.6.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51a73fb7cb3a3ead9f7a8b583ffd9b8038e277cdb8cb87cf890e88b3456afa0b", size = 215854, upload-time = "2025-10-02T14:36:02.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/b8/edab8a7d4fa14e924b29be877d54155dcbd8b80be85ea00d2be3413a9ed4/xxhash-3.6.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b9c6df83594f7df8f7f708ce5ebeacfc69f72c9fbaaababf6cf4758eaada0c9b", size = 214965, upload-time = "2025-10-02T14:36:03.507Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/67/dfa980ac7f0d509d54ea0d5a486d2bb4b80c3f1bb22b66e6a05d3efaf6c0/xxhash-3.6.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:627f0af069b0ea56f312fd5189001c24578868643203bca1abbc2c52d3a6f3ca", size = 448484, upload-time = "2025-10-02T14:36:04.828Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/63/8ffc2cc97e811c0ca5d00ab36604b3ea6f4254f20b7bc658ca825ce6c954/xxhash-3.6.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa912c62f842dfd013c5f21a642c9c10cd9f4c4e943e0af83618b4a404d9091a", size = 196162, upload-time = "2025-10-02T14:36:06.182Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/77/07f0e7a3edd11a6097e990f6e5b815b6592459cb16dae990d967693e6ea9/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b465afd7909db30168ab62afe40b2fcf79eedc0b89a6c0ab3123515dc0df8b99", size = 213007, upload-time = "2025-10-02T14:36:07.733Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/d8/bc5fa0d152837117eb0bef6f83f956c509332ce133c91c63ce07ee7c4873/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a881851cf38b0a70e7c4d3ce81fc7afd86fbc2a024f4cfb2a97cf49ce04b75d3", size = 200956, upload-time = "2025-10-02T14:36:09.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/a5/d749334130de9411783873e9b98ecc46688dad5db64ca6e04b02acc8b473/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9b3222c686a919a0f3253cfc12bb118b8b103506612253b5baeaac10d8027cf6", size = 213401, upload-time = "2025-10-02T14:36:10.585Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/72/abed959c956a4bfc72b58c0384bb7940663c678127538634d896b1195c10/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:c5aa639bc113e9286137cec8fadc20e9cd732b2cc385c0b7fa673b84fc1f2a93", size = 417083, upload-time = "2025-10-02T14:36:12.276Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/b3/62fd2b586283b7d7d665fb98e266decadf31f058f1cf6c478741f68af0cb/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5c1343d49ac102799905e115aee590183c3921d475356cb24b4de29a4bc56518", size = 193913, upload-time = "2025-10-02T14:36:14.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/9a/c19c42c5b3f5a4aad748a6d5b4f23df3bed7ee5445accc65a0fb3ff03953/xxhash-3.6.0-cp314-cp314t-win32.whl", hash = "sha256:5851f033c3030dd95c086b4a36a2683c2ff4a799b23af60977188b057e467119", size = 31586, upload-time = "2025-10-02T14:36:15.603Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/d6/4cc450345be9924fd5dc8c590ceda1db5b43a0a889587b0ae81a95511360/xxhash-3.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0444e7967dac37569052d2409b00a8860c2135cff05502df4da80267d384849f", size = 32526, upload-time = "2025-10-02T14:36:16.708Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/c9/7243eb3f9eaabd1a88a5a5acadf06df2d83b100c62684b7425c6a11bcaa8/xxhash-3.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:bb79b1e63f6fd84ec778a4b1916dfe0a7c3fdb986c06addd5db3a0d413819d95", size = 28898, upload-time = "2025-10-02T14:36:17.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/1e/8aec23647a34a249f62e2398c42955acd9b4c6ed5cf08cbea94dc46f78d2/xxhash-3.6.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0f7b7e2ec26c1666ad5fc9dbfa426a6a3367ceaf79db5dd76264659d509d73b0", size = 30662, upload-time = "2025-10-02T14:37:01.743Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/0b/b14510b38ba91caf43006209db846a696ceea6a847a0c9ba0a5b1adc53d6/xxhash-3.6.0-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5dc1e14d14fa0f5789ec29a7062004b5933964bb9b02aae6622b8f530dc40296", size = 41056, upload-time = "2025-10-02T14:37:02.879Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/55/15a7b8a56590e66ccd374bbfa3f9ffc45b810886c8c3b614e3f90bd2367c/xxhash-3.6.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:881b47fc47e051b37d94d13e7455131054b56749b91b508b0907eb07900d1c13", size = 36251, upload-time = "2025-10-02T14:37:04.44Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/b2/5ac99a041a29e58e95f907876b04f7067a0242cb85b5f39e726153981503/xxhash-3.6.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6dc31591899f5e5666f04cc2e529e69b4072827085c1ef15294d91a004bc1bd", size = 32481, upload-time = "2025-10-02T14:37:05.869Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/d9/8d95e906764a386a3d3b596f3c68bb63687dfca806373509f51ce8eea81f/xxhash-3.6.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:15e0dac10eb9309508bfc41f7f9deaa7755c69e35af835db9cb10751adebc35d", size = 31565, upload-time = "2025-10-02T14:37:06.966Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstandard"
|
||||
version = "0.25.0"
|
||||
|
||||
@@ -26,6 +26,9 @@ You must pass these when invoking the graph as part of the configurable part of
|
||||
|
||||
`langgraph_checkpoint` also defines protocol for serialization/deserialization (serde) and provides an default implementation (`langgraph.checkpoint.serde.jsonplus.JsonPlusSerializer`) that handles a wide variety of types, including LangChain and LangGraph primitives, datetimes, enums and more.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Checkpoint deserialization security:** By default the serializer allows any Python type found in checkpoint data. New applications should set the environment variable `LANGGRAPH_STRICT_MSGPACK=true` or pass an explicit `allowed_msgpack_modules` list to `JsonPlusSerializer` to restrict deserialization to known-safe types.
|
||||
|
||||
### Pending writes
|
||||
|
||||
When a graph node fails mid-execution at a given superstep, LangGraph stores pending checkpoint writes from any other nodes that completed successfully at that superstep, so that whenever we resume graph execution from that superstep we don't re-run the successful nodes.
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
import copy
|
||||
import logging
|
||||
from collections.abc import AsyncIterator, Collection, Iterator, Mapping, Sequence
|
||||
from typing import ( # noqa: UP035
|
||||
from typing import (
|
||||
Any,
|
||||
Generic,
|
||||
Literal,
|
||||
@@ -18,6 +18,9 @@ from langgraph.checkpoint.base.id import uuid6
|
||||
from langgraph.checkpoint.serde.base import SerializerProtocol, maybe_add_typed_methods
|
||||
from langgraph.checkpoint.serde.encrypted import EncryptedSerializer
|
||||
from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
|
||||
from langgraph.checkpoint.serde.types import (
|
||||
DELTA_SENTINEL as DELTA_SENTINEL,
|
||||
)
|
||||
from langgraph.checkpoint.serde.types import (
|
||||
ERROR,
|
||||
INTERRUPT,
|
||||
@@ -28,6 +31,8 @@ from langgraph.checkpoint.serde.types import (
|
||||
|
||||
V = TypeVar("V", int, float, str)
|
||||
PendingWrite = tuple[str, str, Any]
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -119,6 +124,30 @@ class CheckpointTuple(NamedTuple):
|
||||
pending_writes: list[PendingWrite] | None = None
|
||||
|
||||
|
||||
class _ChannelWritesHistory(NamedTuple):
|
||||
"""Result of `BaseCheckpointSaver._get_channel_writes_history`.
|
||||
|
||||
Storage-level view of what one channel wrote across the ancestor chain
|
||||
of a target checkpoint:
|
||||
|
||||
* `seed` — the nearest ancestor's stored blob value for this channel,
|
||||
or `DELTA_SENTINEL` if the walk reached the root without finding a
|
||||
stored value. A non-sentinel seed typically indicates a pre-delta
|
||||
snapshot preserved across a channel-type migration (e.g.
|
||||
`BinaryOperatorAggregate` storage extended under `DeltaChannel`).
|
||||
* `writes` — on-path deltas oldest→newest, one `PendingWrite` per
|
||||
step that wrote to this channel. Writes stored at the target
|
||||
checkpoint itself are pending for the next super-step and are
|
||||
excluded.
|
||||
|
||||
Experimental: method surface may change; the NamedTuple shape is the
|
||||
contract.
|
||||
"""
|
||||
|
||||
seed: Any
|
||||
writes: list[PendingWrite]
|
||||
|
||||
|
||||
class BaseCheckpointSaver(Generic[V]):
|
||||
"""Base class for creating a graph checkpointer.
|
||||
|
||||
@@ -457,6 +486,104 @@ class BaseCheckpointSaver(Generic[V]):
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def _get_tuple_raw(self, config: RunnableConfig) -> CheckpointTuple | None:
|
||||
"""Pure storage read used by `_get_channel_writes_history`.
|
||||
|
||||
Must return the same value as `get_tuple` but must NOT trigger channel
|
||||
reconstruction; otherwise the channel-hydration path would re-enter
|
||||
`_get_channel_writes_history`. Override only if `get_tuple` itself
|
||||
performs channel hydration.
|
||||
"""
|
||||
return self.get_tuple(config)
|
||||
|
||||
async def _aget_tuple_raw(self, config: RunnableConfig) -> CheckpointTuple | None:
|
||||
"""Async version of `_get_tuple_raw`. See docstring there."""
|
||||
return await self.aget_tuple(config)
|
||||
|
||||
def _get_channel_writes_history(
|
||||
self, config: RunnableConfig, channel: str
|
||||
) -> _ChannelWritesHistory:
|
||||
"""**Experimental.** Query one channel's writes along the parent chain.
|
||||
|
||||
Storage-level query, not channel semantics: returns `(seed, writes)`
|
||||
reflecting what storage knows about a single channel across the
|
||||
ancestor chain of the target checkpoint identified by `config`.
|
||||
|
||||
* `writes` — on-path deltas oldest→newest as `PendingWrite` tuples.
|
||||
Writes stored at the target `checkpoint_id` itself are pending
|
||||
for the next super-step and are excluded.
|
||||
* `seed` — the nearest ancestor's stored blob value for this
|
||||
channel; `DELTA_SENTINEL` if the walk reached the root without
|
||||
finding a stored value. A non-sentinel seed typically indicates
|
||||
a pre-delta snapshot preserved across a channel-type migration.
|
||||
|
||||
Walks the **parent chain** (not `list(before=...)`): for forked
|
||||
threads, only on-path ancestors contribute.
|
||||
|
||||
Reference implementation walks `get_tuple` + `parent_config`,
|
||||
inspecting each ancestor's `channel_values[channel]` for the seed
|
||||
terminator. Savers with direct storage access (`InMemorySaver`,
|
||||
`PostgresSaver`) override for performance; the return contract is
|
||||
fixed here.
|
||||
|
||||
Underscore-prefixed because the method surface is experimental.
|
||||
"""
|
||||
collected: list[PendingWrite] = [] # newest first; reversed at the end
|
||||
target_tuple = self._get_tuple_raw(config)
|
||||
cursor_config: RunnableConfig | None = (
|
||||
target_tuple.parent_config if target_tuple else None
|
||||
)
|
||||
while cursor_config is not None:
|
||||
tup = self._get_tuple_raw(cursor_config)
|
||||
if tup is None:
|
||||
break
|
||||
# Collect this ancestor's writes FIRST — they encode the
|
||||
# transition from this ancestor's state to its child's, so
|
||||
# they must be included whether or not this ancestor is the
|
||||
# seed terminator.
|
||||
if tup.pending_writes:
|
||||
# Within a superstep, pending_writes are oldest→newest;
|
||||
# reverse to scan newest-first.
|
||||
for write in reversed(tup.pending_writes):
|
||||
if write[1] != channel:
|
||||
continue
|
||||
collected.append(write)
|
||||
# Seed terminator: any non-sentinel blob on an ancestor
|
||||
# establishes the reconstruction base. Stop here.
|
||||
ancestor_value = tup.checkpoint["channel_values"].get(channel)
|
||||
if ancestor_value is not None and ancestor_value is not DELTA_SENTINEL:
|
||||
collected.reverse()
|
||||
return _ChannelWritesHistory(seed=ancestor_value, writes=collected)
|
||||
cursor_config = tup.parent_config
|
||||
collected.reverse()
|
||||
return _ChannelWritesHistory(seed=DELTA_SENTINEL, writes=collected)
|
||||
|
||||
async def _aget_channel_writes_history(
|
||||
self, config: RunnableConfig, channel: str
|
||||
) -> _ChannelWritesHistory:
|
||||
"""Async version of `_get_channel_writes_history`. See docstring there."""
|
||||
collected: list[PendingWrite] = []
|
||||
target_tuple = await self._aget_tuple_raw(config)
|
||||
cursor_config: RunnableConfig | None = (
|
||||
target_tuple.parent_config if target_tuple else None
|
||||
)
|
||||
while cursor_config is not None:
|
||||
tup = await self._aget_tuple_raw(cursor_config)
|
||||
if tup is None:
|
||||
break
|
||||
if tup.pending_writes:
|
||||
for write in reversed(tup.pending_writes):
|
||||
if write[1] != channel:
|
||||
continue
|
||||
collected.append(write)
|
||||
ancestor_value = tup.checkpoint["channel_values"].get(channel)
|
||||
if ancestor_value is not None and ancestor_value is not DELTA_SENTINEL:
|
||||
collected.reverse()
|
||||
return _ChannelWritesHistory(seed=ancestor_value, writes=collected)
|
||||
cursor_config = tup.parent_config
|
||||
collected.reverse()
|
||||
return _ChannelWritesHistory(seed=DELTA_SENTINEL, writes=collected)
|
||||
|
||||
def get_next_version(self, current: V | None, channel: None) -> V:
|
||||
"""Generate the next version ID for a channel.
|
||||
|
||||
|
||||
@@ -14,16 +14,20 @@ from typing import Any
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
|
||||
from langgraph.checkpoint.base import (
|
||||
DELTA_SENTINEL,
|
||||
WRITES_IDX_MAP,
|
||||
BaseCheckpointSaver,
|
||||
ChannelVersions,
|
||||
Checkpoint,
|
||||
CheckpointMetadata,
|
||||
CheckpointTuple,
|
||||
PendingWrite,
|
||||
SerializerProtocol,
|
||||
_ChannelWritesHistory,
|
||||
get_checkpoint_id,
|
||||
get_checkpoint_metadata,
|
||||
)
|
||||
from langgraph.checkpoint.serde.types import _DeltaSnapshot
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -121,16 +125,114 @@ class InMemorySaver(
|
||||
return self.stack.__exit__(__exc_type, __exc_value, __traceback)
|
||||
|
||||
def _load_blobs(
|
||||
self, thread_id: str, checkpoint_ns: str, versions: ChannelVersions
|
||||
self,
|
||||
thread_id: str,
|
||||
checkpoint_ns: str,
|
||||
versions: ChannelVersions,
|
||||
) -> dict[str, Any]:
|
||||
channel_values: dict[str, Any] = {}
|
||||
for k, v in versions.items():
|
||||
kk = (thread_id, checkpoint_ns, k, v)
|
||||
if kk in self.blobs:
|
||||
vv = self.blobs[kk]
|
||||
if vv[0] != "empty":
|
||||
channel_values[k] = self.serde.loads_typed(vv)
|
||||
return channel_values
|
||||
result: dict[str, Any] = {}
|
||||
for k, ver in versions.items():
|
||||
kk = (thread_id, checkpoint_ns, k, ver)
|
||||
if kk not in self.blobs:
|
||||
continue
|
||||
vv = self.blobs[kk]
|
||||
if vv[0] == "empty":
|
||||
continue
|
||||
result[k] = self.serde.loads_typed(vv)
|
||||
return result
|
||||
|
||||
def _get_channel_writes_history(
|
||||
self, config: RunnableConfig, channel: str
|
||||
) -> _ChannelWritesHistory:
|
||||
thread_id = config["configurable"]["thread_id"]
|
||||
checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
|
||||
checkpoint_id = config["configurable"].get("checkpoint_id", "")
|
||||
ns_storage = self.storage.get(thread_id, {}).get(checkpoint_ns, {})
|
||||
# Walk the parent chain newest→oldest. Skip the target itself —
|
||||
# writes stored AT `checkpoint_id` are pending for the next step
|
||||
# (pregel applies them via `apply_writes`; they aren't part of the
|
||||
# snapshot value AT `checkpoint_id`).
|
||||
chain: list[str] = []
|
||||
target_entry = ns_storage.get(checkpoint_id)
|
||||
current: str | None = target_entry[2] if target_entry is not None else None
|
||||
while current is not None:
|
||||
entry = ns_storage.get(current)
|
||||
if entry is None:
|
||||
break
|
||||
chain.append(current)
|
||||
_, _, parent = entry
|
||||
current = parent
|
||||
# Scan newest→oldest. A pre-delta blob on an ancestor terminates the
|
||||
# walk and is bound as `seed`; without this, a thread migrated from
|
||||
# pre-delta storage would replay ancestor writes all the way to the
|
||||
# root AND miss any value that lived only in the old blob (e.g. from
|
||||
# `update_state`).
|
||||
#
|
||||
# At each ancestor, check the blob BEFORE processing its pending
|
||||
# writes: a pre-delta blob represents the state AT that ancestor,
|
||||
# which already subsumes any writes stored under it. Processing
|
||||
# those writes first would fold them into the reconstructed value
|
||||
# twice (once via the blob, once via replay).
|
||||
collected: list[PendingWrite] = [] # newest first
|
||||
for cp_id in chain: # newest → oldest
|
||||
entry = ns_storage.get(cp_id)
|
||||
if entry is not None:
|
||||
ckpt = self.serde.loads_typed(entry[0])
|
||||
ver = ckpt.get("channel_versions", {}).get(channel)
|
||||
if ver is not None:
|
||||
blob_entry = self.blobs.get(
|
||||
(thread_id, checkpoint_ns, channel, ver)
|
||||
)
|
||||
if blob_entry is not None and blob_entry[0] != "empty":
|
||||
blob_value = self.serde.loads_typed(blob_entry)
|
||||
if blob_value is not DELTA_SENTINEL:
|
||||
if isinstance(blob_value, _DeltaSnapshot):
|
||||
# Step-based snapshot: the blob is state AT this
|
||||
# ancestor, but the ancestor's pending_writes
|
||||
# encode the NEXT step's transition and are NOT
|
||||
# subsumed by the snapshot — collect them first.
|
||||
step_writes = self.writes.get(
|
||||
(thread_id, checkpoint_ns, cp_id), {}
|
||||
)
|
||||
for (_task_id, _idx), (
|
||||
tid,
|
||||
ch,
|
||||
serialized,
|
||||
_,
|
||||
) in sorted(step_writes.items(), reverse=True):
|
||||
if ch != channel:
|
||||
continue
|
||||
collected.append(
|
||||
(tid, ch, self.serde.loads_typed(serialized))
|
||||
)
|
||||
collected.reverse()
|
||||
return _ChannelWritesHistory(
|
||||
seed=blob_value, writes=collected
|
||||
)
|
||||
# Pre-delta blob: state AT this ancestor already
|
||||
# subsumes its pending_writes — skip them.
|
||||
collected.reverse()
|
||||
return _ChannelWritesHistory(
|
||||
seed=blob_value, writes=collected
|
||||
)
|
||||
|
||||
step_writes = self.writes.get((thread_id, checkpoint_ns, cp_id), {})
|
||||
# Within a superstep, sorted by (task_id, idx) = oldest → newest;
|
||||
# reverse for newest-first scan.
|
||||
for (_task_id, _idx), (tid, ch, serialized, _) in sorted(
|
||||
step_writes.items(), reverse=True
|
||||
):
|
||||
if ch != channel:
|
||||
continue
|
||||
val = self.serde.loads_typed(serialized)
|
||||
collected.append((tid, ch, val))
|
||||
collected.reverse()
|
||||
return _ChannelWritesHistory(seed=DELTA_SENTINEL, writes=collected)
|
||||
|
||||
async def _aget_channel_writes_history(
|
||||
self, config: RunnableConfig, channel: str
|
||||
) -> _ChannelWritesHistory:
|
||||
return self._get_channel_writes_history(config, channel)
|
||||
|
||||
def get_tuple(self, config: RunnableConfig) -> CheckpointTuple | None:
|
||||
"""Get a checkpoint tuple from the in-memory storage.
|
||||
@@ -350,7 +452,9 @@ class InMemorySaver(
|
||||
values: dict[str, Any] = c.pop("channel_values") # type: ignore[misc]
|
||||
for k, v in new_versions.items():
|
||||
self.blobs[(thread_id, checkpoint_ns, k, v)] = (
|
||||
self.serde.dumps_typed(values[k]) if k in values else ("empty", b"")
|
||||
self.serde.dumps_typed(values[k])
|
||||
if k in values and values[k] is not DELTA_SENTINEL
|
||||
else ("empty", b"")
|
||||
)
|
||||
self.storage[thread_id][checkpoint_ns].update(
|
||||
{
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
"""Msgpack deserialization safety controls.
|
||||
|
||||
Set ``LANGGRAPH_STRICT_MSGPACK=true`` to restrict checkpoint deserialization
|
||||
to the types listed in ``SAFE_MSGPACK_TYPES``. Without this, any Python
|
||||
callable stored in checkpoint data will be imported and executed on load.
|
||||
"""
|
||||
|
||||
import os
|
||||
from collections.abc import Iterable
|
||||
from typing import cast
|
||||
@@ -66,6 +73,7 @@ SAFE_MSGPACK_TYPES: frozenset[tuple[str, ...]] = frozenset(
|
||||
("langchain_core.documents.base", "Document"),
|
||||
# langgraph
|
||||
("langgraph.types", "Send"),
|
||||
("langgraph.types", "TimeoutPolicy"),
|
||||
("langgraph.types", "Interrupt"),
|
||||
("langgraph.types", "Command"),
|
||||
("langgraph.types", "StateSnapshot"),
|
||||
|
||||
@@ -33,19 +33,51 @@ from langchain_core.load.load import Reviver
|
||||
from langgraph.checkpoint.serde import _msgpack as _lg_msgpack
|
||||
from langgraph.checkpoint.serde.base import SerializerProtocol
|
||||
from langgraph.checkpoint.serde.event_hooks import emit_serde_event
|
||||
from langgraph.checkpoint.serde.types import SendProtocol
|
||||
from langgraph.checkpoint.serde.types import (
|
||||
SendProtocol,
|
||||
_DeltaSnapshot,
|
||||
)
|
||||
from langgraph.store.base import Item
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from langgraph.checkpoint.serde._msgpack import (
|
||||
AllowedMsgpackModules,
|
||||
)
|
||||
from langgraph.checkpoint.serde.types import SendProtocol
|
||||
|
||||
LC_REVIVER = Reviver()
|
||||
EMPTY_BYTES = b""
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Dedup log warnings across process lifetime; cap bounds state if types are
|
||||
# dynamically generated (also acts as a circuit breaker on warning volume).
|
||||
# Dedup is best-effort: racing threads may each emit once for the same key,
|
||||
# and warnings are silently dropped once _MAX_WARNED_TYPES is reached.
|
||||
_MAX_WARNED_TYPES = 1000
|
||||
_warned_unregistered_types: set[tuple[str, str]] = set()
|
||||
_warned_blocked_types: set[tuple[str, str]] = set()
|
||||
|
||||
|
||||
def _is_safe_json_type(id_list: list[str]) -> bool:
|
||||
"""Return True if an lc=2 id refers to a type in SAFE_MSGPACK_TYPES.
|
||||
|
||||
Safe types bypass the ``allowed_json_modules`` gate so that old "json" format
|
||||
checkpoints (written before the msgpack migration) can be resumed without
|
||||
requiring users to configure an explicit allowlist.
|
||||
"""
|
||||
if len(id_list) < 2:
|
||||
return False
|
||||
module_name = ".".join(id_list[:-1])
|
||||
return (module_name, id_list[-1]) in _lg_msgpack.SAFE_MSGPACK_TYPES
|
||||
|
||||
|
||||
def _warn_once(
|
||||
seen: set[tuple[str, str]], key: tuple[str, str], msg: str, *args: object
|
||||
) -> None:
|
||||
if key in seen or len(seen) >= _MAX_WARNED_TYPES:
|
||||
return
|
||||
seen.add(key)
|
||||
logger.warning(msg, *args)
|
||||
|
||||
|
||||
class JsonPlusSerializer(SerializerProtocol):
|
||||
"""Serializer that uses ormsgpack, with optional fallbacks.
|
||||
@@ -56,6 +88,10 @@ class JsonPlusSerializer(SerializerProtocol):
|
||||
class and called within the Pregel loop. It should not be used on untrusted
|
||||
python objects. If an attacker can write directly to your checkpoint database,
|
||||
they may be able to trigger code execution when data is deserialized.
|
||||
|
||||
Set the environment variable ``LANGGRAPH_STRICT_MSGPACK=true`` to restrict
|
||||
deserialization to a built-in allowlist of safe types. You can also pass
|
||||
an explicit ``allowed_msgpack_modules`` to the constructor.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -70,8 +106,11 @@ class JsonPlusSerializer(SerializerProtocol):
|
||||
) -> None:
|
||||
if allowed_msgpack_modules is _lg_msgpack._SENTINEL:
|
||||
if _lg_msgpack.STRICT_MSGPACK_ENABLED:
|
||||
# Strict: only SAFE_MSGPACK_TYPES are allowed.
|
||||
allowed_msgpack_modules = None
|
||||
else:
|
||||
# Permissive (default): all types allowed with a warning.
|
||||
# Set LANGGRAPH_STRICT_MSGPACK=true to lock this down.
|
||||
allowed_msgpack_modules = True
|
||||
self.pickle_fallback = pickle_fallback
|
||||
self._allowed_json_modules: set[tuple[str, ...]] | Literal[True] | None = (
|
||||
@@ -140,19 +179,23 @@ class JsonPlusSerializer(SerializerProtocol):
|
||||
return out
|
||||
|
||||
def _reviver(self, value: dict[str, Any]) -> Any:
|
||||
if self._allowed_json_modules and (
|
||||
if (
|
||||
value.get("lc", None) == 2
|
||||
and value.get("type", None) == "constructor"
|
||||
and value.get("id", None) is not None
|
||||
):
|
||||
try:
|
||||
return self._revive_lc2(value)
|
||||
except InvalidModuleError as e:
|
||||
logger.warning(
|
||||
"Object %s is not in the deserialization allowlist.\n%s",
|
||||
value["id"],
|
||||
e.message,
|
||||
)
|
||||
id_list = value["id"]
|
||||
is_safe = _is_safe_json_type(id_list)
|
||||
if self._allowed_json_modules or is_safe:
|
||||
try:
|
||||
return self._revive_lc2(value)
|
||||
except InvalidModuleError as e:
|
||||
if not is_safe:
|
||||
logger.warning(
|
||||
"Object %s is not in the deserialization allowlist.\n%s",
|
||||
value["id"],
|
||||
e.message,
|
||||
)
|
||||
|
||||
return LC_REVIVER(value)
|
||||
|
||||
@@ -200,6 +243,13 @@ class JsonPlusSerializer(SerializerProtocol):
|
||||
method_display = "<init>"
|
||||
|
||||
dotted = ".".join(needed)
|
||||
# Safe types (the same set already allowed for msgpack deserialization) are
|
||||
# permitted without an explicit allowlist — they are known-safe LangGraph and
|
||||
# LangChain types. This restores backwards-compat for old "json" checkpoints
|
||||
# that pre-date the msgpack migration without reopening the broader security gate.
|
||||
if _is_safe_json_type(list(needed)):
|
||||
return
|
||||
|
||||
if not self._allowed_json_modules:
|
||||
raise InvalidModuleError(
|
||||
f"Refused to deserialize JSON constructor: {dotted} (method: {method_display}). "
|
||||
@@ -269,10 +319,13 @@ EXT_METHOD_SINGLE_ARG = 3
|
||||
EXT_PYDANTIC_V1 = 4
|
||||
EXT_PYDANTIC_V2 = 5
|
||||
EXT_NUMPY_ARRAY = 6
|
||||
EXT_DELTA_SNAPSHOT = 7
|
||||
|
||||
|
||||
def _msgpack_default(obj: Any) -> str | ormsgpack.Ext:
|
||||
if hasattr(obj, "model_dump") and callable(obj.model_dump): # pydantic v2
|
||||
if isinstance(obj, _DeltaSnapshot):
|
||||
return ormsgpack.Ext(EXT_DELTA_SNAPSHOT, _msgpack_enc(obj.value))
|
||||
elif hasattr(obj, "model_dump") and callable(obj.model_dump): # pydantic v2
|
||||
return ormsgpack.Ext(
|
||||
EXT_PYDANTIC_V2,
|
||||
_msgpack_enc(
|
||||
@@ -444,10 +497,13 @@ def _msgpack_default(obj: Any) -> str | ormsgpack.Ext:
|
||||
),
|
||||
)
|
||||
elif isinstance(obj, SendProtocol):
|
||||
args: tuple[Any, ...] = (obj.node, obj.arg)
|
||||
if (timeout := getattr(obj, "timeout", None)) is not None:
|
||||
args = (obj.node, obj.arg, timeout)
|
||||
return ormsgpack.Ext(
|
||||
EXT_CONSTRUCTOR_POS_ARGS,
|
||||
_msgpack_enc(
|
||||
(obj.__class__.__module__, obj.__class__.__name__, (obj.node, obj.arg)),
|
||||
(obj.__class__.__module__, obj.__class__.__name__, args),
|
||||
),
|
||||
)
|
||||
elif dataclasses.is_dataclass(obj):
|
||||
@@ -498,6 +554,15 @@ def _msgpack_default(obj: Any) -> str | ormsgpack.Ext:
|
||||
raise TypeError(f"Object of type {obj.__class__.__name__} is not serializable")
|
||||
|
||||
|
||||
def _send_from_args(args: Sequence[Any]) -> Any:
|
||||
# ya we have a cyclic import here ¯\_(ツ)_/¯
|
||||
from langgraph.types import Send # type: ignore
|
||||
|
||||
if len(args) == 2:
|
||||
return Send(*args)
|
||||
return Send(args[0], args[1], timeout=args[2])
|
||||
|
||||
|
||||
def _create_msgpack_ext_hook(
|
||||
allowed_modules: set[tuple[str, ...]] | Literal[True] | None,
|
||||
) -> Callable[[int, bytes], Any]:
|
||||
@@ -527,10 +592,13 @@ def _create_msgpack_ext_hook(
|
||||
"name": name,
|
||||
}
|
||||
)
|
||||
logger.warning(
|
||||
_warn_once(
|
||||
_warned_unregistered_types,
|
||||
key,
|
||||
"Deserializing unregistered type %s.%s from checkpoint. "
|
||||
"This will be blocked in a future version. "
|
||||
"Add to allowed_msgpack_modules to silence: [(%r, %r)]",
|
||||
"Set LANGGRAPH_STRICT_MSGPACK=true to block now, or add "
|
||||
"to allowed_msgpack_modules to allow explicitly: [(%r, %r)]",
|
||||
module,
|
||||
name,
|
||||
module,
|
||||
@@ -548,7 +616,9 @@ def _create_msgpack_ext_hook(
|
||||
"name": name,
|
||||
}
|
||||
)
|
||||
logger.warning(
|
||||
_warn_once(
|
||||
_warned_blocked_types,
|
||||
key,
|
||||
"Blocked deserialization of %s.%s - not in allowed_msgpack_modules. "
|
||||
"Add to allowed_msgpack_modules to allow: [(%r, %r)]",
|
||||
module,
|
||||
@@ -581,7 +651,13 @@ def _create_msgpack_ext_hook(
|
||||
return False
|
||||
|
||||
def ext_hook(code: int, data: bytes) -> Any:
|
||||
if code == EXT_CONSTRUCTOR_SINGLE_ARG:
|
||||
if code == EXT_DELTA_SNAPSHOT:
|
||||
return _DeltaSnapshot(
|
||||
ormsgpack.unpackb(
|
||||
data, ext_hook=ext_hook, option=ormsgpack.OPT_NON_STR_KEYS
|
||||
)
|
||||
)
|
||||
elif code == EXT_CONSTRUCTOR_SINGLE_ARG:
|
||||
try:
|
||||
tup = ormsgpack.unpackb(
|
||||
data, ext_hook=ext_hook, option=ormsgpack.OPT_NON_STR_KEYS
|
||||
@@ -602,6 +678,8 @@ def _create_msgpack_ext_hook(
|
||||
)
|
||||
if not _check_allowed(tup[0], tup[1]):
|
||||
return tup[2]
|
||||
if tup[0] == "langgraph.types" and tup[1] == "Send":
|
||||
return _send_from_args(tup[2])
|
||||
# module, name, args
|
||||
return getattr(importlib.import_module(tup[0]), tup[1])(*tup[2])
|
||||
except Exception:
|
||||
@@ -715,9 +793,7 @@ def _msgpack_ext_hook_to_json(code: int, data: bytes) -> Any:
|
||||
option=ormsgpack.OPT_NON_STR_KEYS,
|
||||
)
|
||||
if tup[0] == "langgraph.types" and tup[1] == "Send":
|
||||
from langgraph.types import Send # type: ignore
|
||||
|
||||
return Send(*tup[2])
|
||||
return _send_from_args(tup[2])
|
||||
# module, name, args
|
||||
return tup[2]
|
||||
except Exception:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from collections.abc import Sequence
|
||||
from typing import (
|
||||
Any,
|
||||
NamedTuple,
|
||||
Protocol,
|
||||
TypeVar,
|
||||
runtime_checkable,
|
||||
@@ -14,6 +15,37 @@ INTERRUPT = "__interrupt__"
|
||||
RESUME = "__resume__"
|
||||
TASKS = "__pregel_tasks"
|
||||
|
||||
|
||||
class _DeltaSentinel:
|
||||
"""In-memory marker for a DeltaChannel field with no snapshot.
|
||||
|
||||
Never serialized to storage — checkpointers strip it before writing.
|
||||
Compare with `is DELTA_SENTINEL`; always the same module-level instance.
|
||||
"""
|
||||
|
||||
__slots__ = ()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return "DELTA_SENTINEL"
|
||||
|
||||
|
||||
DELTA_SENTINEL = _DeltaSentinel()
|
||||
|
||||
|
||||
class _DeltaSnapshot(NamedTuple):
|
||||
"""Snapshot blob for a DeltaChannel with finite snapshot_frequency.
|
||||
|
||||
Stored in checkpoint_blobs via the `EXT_DELTA_SNAPSHOT` msgpack ext code.
|
||||
The ancestor walk in `_get_channel_writes_history` terminates when it
|
||||
encounters this type (any non-sentinel blob stops the walk).
|
||||
|
||||
`from_checkpoint` reconstructs the channel value directly from `.value`
|
||||
without replaying writes — the snapshot IS the accumulated state.
|
||||
"""
|
||||
|
||||
value: Any
|
||||
|
||||
|
||||
Value = TypeVar("Value", covariant=True)
|
||||
Update = TypeVar("Update", contravariant=True)
|
||||
C = TypeVar("C")
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "langgraph-checkpoint"
|
||||
version = "4.0.1"
|
||||
version = "4.1.0a3"
|
||||
description = "Library with base interfaces for LangGraph checkpoint savers."
|
||||
authors = []
|
||||
requires-python = ">=3.10"
|
||||
@@ -18,7 +18,7 @@ dependencies = [
|
||||
|
||||
[project.urls]
|
||||
Source = "https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint"
|
||||
Twitter = "https://x.com/LangChain"
|
||||
Twitter = "https://x.com/langchain_oss"
|
||||
Slack = "https://www.langchain.com/join-community"
|
||||
Reddit = "https://www.reddit.com/r/LangChain/"
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@ from langgraph.checkpoint.serde.jsonplus import (
|
||||
EXT_METHOD_SINGLE_ARG,
|
||||
JsonPlusSerializer,
|
||||
_msgpack_enc,
|
||||
_warned_blocked_types,
|
||||
_warned_unregistered_types,
|
||||
)
|
||||
|
||||
|
||||
@@ -102,6 +104,13 @@ def test_msgpack_method_pathlib_blocked_encrypted_strict(
|
||||
class TestEncryptedSerializerMsgpackAllowlist:
|
||||
"""Test msgpack allowlist behavior through EncryptedSerializer."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_warned_types(self) -> None:
|
||||
# Warning dedup state is process-global; reset per-test so each case
|
||||
# sees a fresh slate and assertions about warning emission are stable.
|
||||
_warned_unregistered_types.clear()
|
||||
_warned_blocked_types.clear()
|
||||
|
||||
def test_safe_types_no_warning(self, caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""Test safe types deserialize without warnings through encryption."""
|
||||
serde = _make_encrypted_serde()
|
||||
|
||||
@@ -35,6 +35,8 @@ from langgraph.checkpoint.serde.jsonplus import (
|
||||
JsonPlusSerializer,
|
||||
_msgpack_enc,
|
||||
_msgpack_ext_hook_to_json,
|
||||
_warned_blocked_types,
|
||||
_warned_unregistered_types,
|
||||
)
|
||||
from langgraph.store.base import Item
|
||||
|
||||
@@ -331,6 +333,57 @@ def test_serde_jsonplus_bytes() -> None:
|
||||
assert serde.loads_typed(dumped) == some_bytes
|
||||
|
||||
|
||||
def test_lc2_json_safe_type_revives_without_allowlist() -> None:
|
||||
"""Old 'json' blobs with lc=2 for safe types must revive without an explicit allowlist.
|
||||
|
||||
Regression test for: https://github.com/langchain-ai/langgraph/issues/7498
|
||||
Threads checkpointed before v1.0.1 (pre-msgpack) stored messages as lc=2 JSON
|
||||
constructor dicts. Resuming those threads must reconstruct proper BaseMessage objects
|
||||
rather than returning raw dicts that cause MESSAGE_COERCION_FAILURE in add_messages.
|
||||
"""
|
||||
from langchain_core.messages import AIMessage
|
||||
|
||||
serde = JsonPlusSerializer() # default: _allowed_json_modules=None
|
||||
|
||||
human_blob = {
|
||||
"lc": 2,
|
||||
"type": "constructor",
|
||||
"id": ["langchain_core", "messages", "human", "HumanMessage"],
|
||||
"kwargs": {"content": "hello", "type": "human"},
|
||||
}
|
||||
ai_blob = {
|
||||
"lc": 2,
|
||||
"type": "constructor",
|
||||
"id": ["langchain_core", "messages", "ai", "AIMessage"],
|
||||
"kwargs": {"content": "hi there", "type": "ai"},
|
||||
}
|
||||
result = serde.loads_typed(("json", json.dumps([human_blob, ai_blob]).encode()))
|
||||
|
||||
assert len(result) == 2
|
||||
assert isinstance(result[0], HumanMessage), (
|
||||
f"Expected HumanMessage, got {type(result[0])}: {result[0]!r}\n"
|
||||
"lc=2 JSON blobs for safe types must deserialize without an explicit allowlist"
|
||||
)
|
||||
assert result[0].content == "hello"
|
||||
assert isinstance(result[1], AIMessage)
|
||||
assert result[1].content == "hi there"
|
||||
|
||||
|
||||
def test_lc2_json_unknown_type_stays_blocked_without_allowlist() -> None:
|
||||
"""lc=2 JSON blobs for types NOT in SAFE_MSGPACK_TYPES still require an allowlist."""
|
||||
serde = JsonPlusSerializer()
|
||||
load = {
|
||||
"lc": 2,
|
||||
"type": "constructor",
|
||||
"id": ["pprint", "pprint"],
|
||||
"kwargs": {"object": "HELLO"},
|
||||
}
|
||||
# No allowlist configured → raw dict returned (not raised, not reconstructed)
|
||||
result = serde.loads_typed(("json", json.dumps(load).encode()))
|
||||
assert isinstance(result, dict), "Unknown lc=2 type must stay as raw dict"
|
||||
assert result.get("lc") == 2
|
||||
|
||||
|
||||
def test_deserde_invalid_module() -> None:
|
||||
serde = JsonPlusSerializer()
|
||||
load = {
|
||||
@@ -580,6 +633,14 @@ def test_msgpack_safe_types_no_warning(caplog: pytest.LogCaptureFixture) -> None
|
||||
assert result is not None
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_warned_types() -> None:
|
||||
# Warning dedup state is process-global; reset per-test so each case sees
|
||||
# a fresh slate and assertions about warning emission are stable.
|
||||
_warned_unregistered_types.clear()
|
||||
_warned_blocked_types.clear()
|
||||
|
||||
|
||||
def test_msgpack_pydantic_warns_by_default(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""Pydantic models not in allowlist should log warning but still deserialize."""
|
||||
current = _lg_msgpack.STRICT_MSGPACK_ENABLED
|
||||
@@ -595,6 +656,12 @@ def test_msgpack_pydantic_warns_by_default(caplog: pytest.LogCaptureFixture) ->
|
||||
assert "unregistered type" in caplog.text.lower()
|
||||
assert "allowed_msgpack_modules" in caplog.text
|
||||
assert result == obj
|
||||
|
||||
# Second deserialization of the same type should NOT produce another warning
|
||||
caplog.clear()
|
||||
result2 = serde.loads_typed(dumped)
|
||||
assert "unregistered type" not in caplog.text.lower()
|
||||
assert result2 == obj
|
||||
_lg_msgpack.STRICT_MSGPACK_ENABLED = current
|
||||
|
||||
|
||||
@@ -639,7 +706,6 @@ def test_msgpack_allowlist_silences_warning(caplog: pytest.LogCaptureFixture) ->
|
||||
|
||||
def test_msgpack_none_blocks_unregistered(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""allowed_msgpack_modules=None should block unregistered types."""
|
||||
|
||||
serde = JsonPlusSerializer(allowed_msgpack_modules=None)
|
||||
|
||||
obj = MyPydantic(foo="test", bar=42, inner=InnerPydantic(hello="world"))
|
||||
@@ -657,7 +723,6 @@ def test_msgpack_allowlist_blocks_non_listed(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Allowlists should block unregistered types even if msgpack is enabled."""
|
||||
|
||||
serde = JsonPlusSerializer(
|
||||
allowed_msgpack_modules=[("tests.test_jsonplus", "MyPydantic")]
|
||||
)
|
||||
|
||||
@@ -6,19 +6,32 @@ from langchain_core.runnables import RunnableConfig
|
||||
from pydantic import BaseModel
|
||||
|
||||
from langgraph.checkpoint.base import (
|
||||
DELTA_SENTINEL,
|
||||
Checkpoint,
|
||||
CheckpointMetadata,
|
||||
create_checkpoint,
|
||||
empty_checkpoint,
|
||||
)
|
||||
from langgraph.checkpoint.memory import InMemorySaver
|
||||
from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
|
||||
from langgraph.checkpoint.serde.jsonplus import (
|
||||
JsonPlusSerializer,
|
||||
_warned_blocked_types,
|
||||
_warned_unregistered_types,
|
||||
)
|
||||
|
||||
|
||||
class MemoryPydantic(BaseModel):
|
||||
foo: str
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_warned_types() -> None:
|
||||
# Warning dedup state is process-global; reset per-test so each case sees
|
||||
# a fresh slate and assertions about warning emission are stable.
|
||||
_warned_unregistered_types.clear()
|
||||
_warned_blocked_types.clear()
|
||||
|
||||
|
||||
class TestMemorySaver:
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup(self) -> None:
|
||||
@@ -196,8 +209,6 @@ class TestMemorySaver:
|
||||
|
||||
|
||||
async def test_memory_saver() -> None:
|
||||
from langgraph.checkpoint.memory import InMemorySaver
|
||||
|
||||
memory_saver = InMemorySaver()
|
||||
assert isinstance(memory_saver, InMemorySaver)
|
||||
|
||||
@@ -308,3 +319,338 @@ def test_memory_saver_with_allowlist_proxy_isolated() -> None:
|
||||
assert direct is not None
|
||||
expected = obj.model_dump() if hasattr(obj, "model_dump") else obj.dict()
|
||||
assert direct.checkpoint["channel_values"]["foo"] == expected
|
||||
|
||||
|
||||
class TestInMemorySaverDeltaChannel:
|
||||
def test_load_blobs_omits_delta_channel(self) -> None:
|
||||
"""_load_blobs omits delta channels (stored as 'empty'); reconstruction deferred."""
|
||||
saver = InMemorySaver()
|
||||
|
||||
thread_id, ns, channel = "t1", "", "messages"
|
||||
v1 = "00000000000000000000000000000001.0000000000000000"
|
||||
|
||||
saver.blobs[(thread_id, ns, channel, v1)] = ("empty", b"")
|
||||
|
||||
result = saver._load_blobs(thread_id, ns, {channel: v1})
|
||||
assert channel not in result
|
||||
|
||||
def test_get_channel_writes_collects_ancestor_writes_only(self) -> None:
|
||||
"""_get_channel_writes_history collects ancestor writes oldest→newest,
|
||||
and excludes writes stored at the target checkpoint itself (those are
|
||||
pending writes for the next step, applied separately by pregel)."""
|
||||
saver = InMemorySaver()
|
||||
serde = JsonPlusSerializer()
|
||||
|
||||
thread_id, ns, channel = "t1", "", "messages"
|
||||
|
||||
cp1 = empty_checkpoint()
|
||||
cp1["id"] = "cp1"
|
||||
cp2 = empty_checkpoint()
|
||||
cp2["id"] = "cp2"
|
||||
saver.storage[thread_id][ns] = {
|
||||
"cp1": (serde.dumps_typed(cp1), serde.dumps_typed({}), None),
|
||||
"cp2": (serde.dumps_typed(cp2), serde.dumps_typed({}), "cp1"),
|
||||
}
|
||||
# Writes stored at cp1 produced the cp1 snapshot; part of history.
|
||||
saver.writes[(thread_id, ns, "cp1")][("task1", 0)] = (
|
||||
"task1",
|
||||
channel,
|
||||
serde.dumps_typed({"content": "hi"}),
|
||||
"",
|
||||
)
|
||||
# Writes stored at cp2 are pending — they will produce cp3 when the
|
||||
# step that loaded cp2 completes. They MUST NOT appear in the
|
||||
# reconstructed snapshot value at cp2.
|
||||
saver.writes[(thread_id, ns, "cp2")][("task2", 0)] = (
|
||||
"task2",
|
||||
channel,
|
||||
serde.dumps_typed({"content": "pending"}),
|
||||
"",
|
||||
)
|
||||
|
||||
config: RunnableConfig = {
|
||||
"configurable": {
|
||||
"thread_id": thread_id,
|
||||
"checkpoint_ns": ns,
|
||||
"checkpoint_id": "cp2",
|
||||
}
|
||||
}
|
||||
result = saver._get_channel_writes_history(config, channel)
|
||||
assert result.seed is DELTA_SENTINEL
|
||||
values = [v for _, _, v in result.writes]
|
||||
assert values == [{"content": "hi"}]
|
||||
|
||||
def test_get_channel_writes_at_root_returns_empty(self) -> None:
|
||||
"""Reconstructing the root checkpoint's state: no ancestors → []."""
|
||||
saver = InMemorySaver()
|
||||
serde = JsonPlusSerializer()
|
||||
thread_id, ns, channel = "t1", "", "messages"
|
||||
|
||||
cp1 = empty_checkpoint()
|
||||
cp1["id"] = "cp1"
|
||||
saver.storage[thread_id][ns] = {
|
||||
"cp1": (serde.dumps_typed(cp1), serde.dumps_typed({}), None),
|
||||
}
|
||||
saver.writes[(thread_id, ns, "cp1")][("task1", 0)] = (
|
||||
"task1",
|
||||
channel,
|
||||
serde.dumps_typed({"content": "pending"}),
|
||||
"",
|
||||
)
|
||||
|
||||
config: RunnableConfig = {
|
||||
"configurable": {
|
||||
"thread_id": thread_id,
|
||||
"checkpoint_ns": ns,
|
||||
"checkpoint_id": "cp1",
|
||||
}
|
||||
}
|
||||
result = saver._get_channel_writes_history(config, channel)
|
||||
assert result.seed is DELTA_SENTINEL
|
||||
assert result.writes == []
|
||||
|
||||
|
||||
class TestBaseFallbackGetChannelWrites:
|
||||
"""Exercises the `BaseCheckpointSaver._get_channel_writes_history` default
|
||||
implementation — the path third-party savers inherit when they don't
|
||||
override `_get_channel_writes_history` themselves.
|
||||
|
||||
Regression guard for a bug where the fallback passed the caller's config
|
||||
(with `checkpoint_id`) straight to `self.list()`, which most savers
|
||||
collapse to a single row — causing the fallback to return `[]`.
|
||||
"""
|
||||
|
||||
def _build_saver_with_chain(self) -> tuple[InMemorySaver, str, str]:
|
||||
"""Build an InMemorySaver with a 3-checkpoint chain and per-step writes
|
||||
for a `messages` channel.
|
||||
|
||||
Returns `(saver, thread_id, namespace)`. The saver subclass deletes the
|
||||
InMemorySaver override so the base class fallback is exercised.
|
||||
"""
|
||||
|
||||
class _ThirdPartyStyleSaver(InMemorySaver):
|
||||
_get_channel_writes_history = (
|
||||
InMemorySaver.__mro__[1]._get_channel_writes_history # type: ignore[attr-defined]
|
||||
)
|
||||
_aget_channel_writes_history = (
|
||||
InMemorySaver.__mro__[1]._aget_channel_writes_history # type: ignore[attr-defined]
|
||||
)
|
||||
|
||||
saver = _ThirdPartyStyleSaver()
|
||||
serde = JsonPlusSerializer()
|
||||
thread_id, ns, channel = "t1", "", "messages"
|
||||
|
||||
cp0 = empty_checkpoint()
|
||||
cp0["id"] = "00000000000000000000000000000001.0000000000000000"
|
||||
cp1 = empty_checkpoint()
|
||||
cp1["id"] = "00000000000000000000000000000002.0000000000000000"
|
||||
cp2 = empty_checkpoint()
|
||||
cp2["id"] = "00000000000000000000000000000003.0000000000000000"
|
||||
saver.storage[thread_id][ns] = {
|
||||
cp0["id"]: (serde.dumps_typed(cp0), serde.dumps_typed({}), None),
|
||||
cp1["id"]: (serde.dumps_typed(cp1), serde.dumps_typed({}), cp0["id"]),
|
||||
cp2["id"]: (serde.dumps_typed(cp2), serde.dumps_typed({}), cp1["id"]),
|
||||
}
|
||||
# Writes under cp0 produced cp1's state; writes under cp1 produced cp2's.
|
||||
saver.writes[(thread_id, ns, cp0["id"])][("task1", 0)] = (
|
||||
"task1",
|
||||
channel,
|
||||
serde.dumps_typed({"content": "first"}),
|
||||
"",
|
||||
)
|
||||
saver.writes[(thread_id, ns, cp1["id"])][("task2", 0)] = (
|
||||
"task2",
|
||||
channel,
|
||||
serde.dumps_typed({"content": "second"}),
|
||||
"",
|
||||
)
|
||||
return saver, thread_id, ns
|
||||
|
||||
def test_fallback_returns_ancestor_writes_oldest_first(self) -> None:
|
||||
saver, thread_id, ns = self._build_saver_with_chain()
|
||||
target_id = "00000000000000000000000000000003.0000000000000000"
|
||||
config: RunnableConfig = {
|
||||
"configurable": {
|
||||
"thread_id": thread_id,
|
||||
"checkpoint_ns": ns,
|
||||
"checkpoint_id": target_id,
|
||||
}
|
||||
}
|
||||
|
||||
result = saver._get_channel_writes_history(config, "messages")
|
||||
|
||||
assert result.seed is DELTA_SENTINEL
|
||||
values = [v for _, _, v in result.writes]
|
||||
assert values == [{"content": "first"}, {"content": "second"}]
|
||||
|
||||
async def test_async_fallback_returns_ancestor_writes_oldest_first(self) -> None:
|
||||
saver, thread_id, ns = self._build_saver_with_chain()
|
||||
target_id = "00000000000000000000000000000003.0000000000000000"
|
||||
config: RunnableConfig = {
|
||||
"configurable": {
|
||||
"thread_id": thread_id,
|
||||
"checkpoint_ns": ns,
|
||||
"checkpoint_id": target_id,
|
||||
}
|
||||
}
|
||||
|
||||
result = await saver._aget_channel_writes_history(config, "messages")
|
||||
|
||||
assert result.seed is DELTA_SENTINEL
|
||||
values = [v for _, _, v in result.writes]
|
||||
assert values == [{"content": "first"}, {"content": "second"}]
|
||||
|
||||
async def test_async_fallback_concurrent_tasks_do_not_interfere(self) -> None:
|
||||
"""Regression: the re-entrancy guard must be task-local, not thread-local.
|
||||
|
||||
Two concurrent `_aget_channel_writes_history` calls on the same
|
||||
event-loop thread must each see their full reconstructed writes. A
|
||||
`threading.local()` guard would let whichever task set it first
|
||||
short-circuit the other to `writes=[]`.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
saver, thread_id, ns = self._build_saver_with_chain()
|
||||
|
||||
# Force the two tasks to interleave across the `set(True)` boundary:
|
||||
# each `aget_tuple` yields control, so if the guard were thread-local
|
||||
# the second task would observe `active=True` set by the first.
|
||||
orig_aget_tuple = saver.aget_tuple
|
||||
|
||||
async def slow_aget_tuple(config: RunnableConfig) -> Any:
|
||||
await asyncio.sleep(0)
|
||||
return await orig_aget_tuple(config)
|
||||
|
||||
saver.aget_tuple = slow_aget_tuple # type: ignore[method-assign]
|
||||
|
||||
target_id = "00000000000000000000000000000003.0000000000000000"
|
||||
config: RunnableConfig = {
|
||||
"configurable": {
|
||||
"thread_id": thread_id,
|
||||
"checkpoint_ns": ns,
|
||||
"checkpoint_id": target_id,
|
||||
}
|
||||
}
|
||||
|
||||
results = await asyncio.gather(
|
||||
saver._aget_channel_writes_history(config, "messages"),
|
||||
saver._aget_channel_writes_history(config, "messages"),
|
||||
)
|
||||
|
||||
expected_values = [{"content": "first"}, {"content": "second"}]
|
||||
for result in results:
|
||||
assert result.seed is DELTA_SENTINEL
|
||||
values = [v for _, _, v in result.writes]
|
||||
assert values == expected_values
|
||||
|
||||
|
||||
class TestPreDeltaBlobTerminator:
|
||||
"""Verify the pre-delta blob terminator: when the ancestor walk hits a
|
||||
checkpoint whose blob for the channel is a real value (not
|
||||
DELTA_SENTINEL), reconstruction seeds from it and stops. This guards
|
||||
|
||||
* back-compat: a thread written by pre-delta code, then extended under
|
||||
delta — reconstruction must return the correct value without walking
|
||||
past the last pre-delta ancestor;
|
||||
* perf: without the terminator, every reconstruct-after-migration would
|
||||
walk all the way to the thread root.
|
||||
"""
|
||||
|
||||
def _build_mixed_thread(self) -> tuple[InMemorySaver, str, str, str, str]:
|
||||
"""Three-checkpoint chain: cp1 (pre-delta, blob=[A]), cp2 (delta,
|
||||
write=B), cp3 (delta, write=C). Reconstructing at cp3 must yield
|
||||
seed=[A] + writes=[B, C].
|
||||
|
||||
Returns `(saver, thread_id, ns, channel, cp3_id)`.
|
||||
"""
|
||||
saver = InMemorySaver()
|
||||
serde = JsonPlusSerializer()
|
||||
thread_id, ns, channel = "t1", "", "messages"
|
||||
|
||||
v1 = "00000000000000000000000000000001.0"
|
||||
v2 = "00000000000000000000000000000002.0"
|
||||
v3 = "00000000000000000000000000000003.0"
|
||||
|
||||
# Pre-delta: cp1 stored a real blob for the channel.
|
||||
saver.blobs[(thread_id, ns, channel, v1)] = serde.dumps_typed(["A"])
|
||||
# Delta-era: cp2 and cp3 store "empty"; real writes in checkpoint_writes.
|
||||
saver.blobs[(thread_id, ns, channel, v2)] = ("empty", b"")
|
||||
saver.blobs[(thread_id, ns, channel, v3)] = ("empty", b"")
|
||||
|
||||
cp1 = empty_checkpoint()
|
||||
cp1["id"] = "cp1"
|
||||
cp1["channel_versions"][channel] = v1
|
||||
cp2 = empty_checkpoint()
|
||||
cp2["id"] = "cp2"
|
||||
cp2["channel_versions"][channel] = v2
|
||||
cp3 = empty_checkpoint()
|
||||
cp3["id"] = "cp3"
|
||||
cp3["channel_versions"][channel] = v3
|
||||
|
||||
saver.storage[thread_id][ns] = {
|
||||
"cp1": (serde.dumps_typed(cp1), serde.dumps_typed({}), None),
|
||||
"cp2": (serde.dumps_typed(cp2), serde.dumps_typed({}), "cp1"),
|
||||
"cp3": (serde.dumps_typed(cp3), serde.dumps_typed({}), "cp2"),
|
||||
}
|
||||
# Write under cp1 would be from the pre-delta era and MUST be ignored
|
||||
# (the blob already captures it). We add one and assert it is not
|
||||
# folded into the reconstructed result.
|
||||
saver.writes[(thread_id, ns, "cp1")][("task0", 0)] = (
|
||||
"task0",
|
||||
channel,
|
||||
serde.dumps_typed("PRE-DELTA-WRITE"),
|
||||
"",
|
||||
)
|
||||
saver.writes[(thread_id, ns, "cp2")][("task2", 0)] = (
|
||||
"task2",
|
||||
channel,
|
||||
serde.dumps_typed("B"),
|
||||
"",
|
||||
)
|
||||
saver.writes[(thread_id, ns, "cp3")][("task3", 0)] = (
|
||||
"task3",
|
||||
channel,
|
||||
serde.dumps_typed("PENDING-AT-TARGET"),
|
||||
"",
|
||||
)
|
||||
return saver, thread_id, ns, channel, "cp3"
|
||||
|
||||
def test_seed_from_pre_delta_ancestor_blob(self) -> None:
|
||||
saver, thread_id, ns, channel, target = self._build_mixed_thread()
|
||||
config: RunnableConfig = {
|
||||
"configurable": {
|
||||
"thread_id": thread_id,
|
||||
"checkpoint_ns": ns,
|
||||
"checkpoint_id": target,
|
||||
}
|
||||
}
|
||||
|
||||
result = saver._get_channel_writes_history(config, channel)
|
||||
|
||||
# Seed came from the pre-delta blob at cp1.
|
||||
assert result.seed == ["A"]
|
||||
# Delta-era writes from cp2 replay through the reducer on top of seed.
|
||||
# cp3 is the target — its own write is pending for the NEXT step and
|
||||
# must be excluded.
|
||||
values = [v for _, _, v in result.writes]
|
||||
assert values == ["B"]
|
||||
|
||||
def test_pre_delta_blob_terminates_walk_before_older_writes(self) -> None:
|
||||
"""Writes stored at the pre-delta ancestor itself must not be replayed
|
||||
(the blob subsumes them)."""
|
||||
saver, thread_id, ns, channel, target = self._build_mixed_thread()
|
||||
config: RunnableConfig = {
|
||||
"configurable": {
|
||||
"thread_id": thread_id,
|
||||
"checkpoint_ns": ns,
|
||||
"checkpoint_id": target,
|
||||
}
|
||||
}
|
||||
|
||||
result = saver._get_channel_writes_history(config, channel)
|
||||
|
||||
values = [v for _, _, v in result.writes]
|
||||
# The pre-delta write under cp1 must not appear (the blob subsumes it).
|
||||
assert "PRE-DELTA-WRITE" not in values
|
||||
# And the pending write at the target is never folded in.
|
||||
assert "PENDING-AT-TARGET" not in values
|
||||
|
||||
Generated
+129
-10
@@ -267,7 +267,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langchain-core"
|
||||
version = "1.2.23"
|
||||
version = "1.2.28"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jsonpatch" },
|
||||
@@ -279,14 +279,14 @@ dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "uuid-utils" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1d/47/a5f21b651e9cbd7a26c3e5809336d10a0be94ef7bdf6bea47f2ad9fff1a8/langchain_core-1.2.23.tar.gz", hash = "sha256:fdec64f90cfea25317e88d9803c44684af1f4e30dec4e58320dd7393bb0f0785", size = 841684, upload-time = "2026-03-27T23:28:14.6Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f8/a4/317a1a3ac1df33a64adb3670bf88bbe3b3d5baa274db6863a979db472897/langchain_core-1.2.28.tar.gz", hash = "sha256:271a3d8bd618f795fdeba112b0753980457fc90537c46a0c11998516a74dc2cb", size = 846119, upload-time = "2026-04-08T18:19:34.867Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/5a/6ff2d76618e4cac531ea51d4ef44c6add36575a84c3f0f8877aee68c951a/langchain_core-1.2.23-py3-none-any.whl", hash = "sha256:70866dfc5275b7840ce272ff70f0ff216c8666ab25dc1b41964a4ef58c02a3ff", size = 506709, upload-time = "2026-03-27T23:28:13.372Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/92/32f785f077c7e898da97064f113c73fbd9ad55d1e2169cf3a391b183dedb/langchain_core-1.2.28-py3-none-any.whl", hash = "sha256:80764232581eaf8057bcefa71dbf8adc1f6a28d257ebd8b95ba9b8b452e8c6ac", size = 508727, upload-time = "2026-04-08T18:19:32.823Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-checkpoint"
|
||||
version = "4.0.1"
|
||||
version = "4.1.0a3"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -369,7 +369,7 @@ test = [
|
||||
|
||||
[[package]]
|
||||
name = "langsmith"
|
||||
version = "0.6.4"
|
||||
version = "0.7.31"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
@@ -379,11 +379,12 @@ dependencies = [
|
||||
{ name = "requests" },
|
||||
{ name = "requests-toolbelt" },
|
||||
{ name = "uuid-utils" },
|
||||
{ name = "xxhash" },
|
||||
{ name = "zstandard" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e7/85/9c7933052a997da1b85bc5c774f3865e9b1da1c8d71541ea133178b13229/langsmith-0.6.4.tar.gz", hash = "sha256:36f7223a01c218079fbb17da5e536ebbaf5c1468c028abe070aa3ae59bc99ec8", size = 919964, upload-time = "2026-01-15T20:02:28.873Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e6/11/696019490992db5c87774dc20515529ef42a01e1d770fb754ed6d9b12fb0/langsmith-0.7.31.tar.gz", hash = "sha256:331ee4f7c26bb5be4022b9859b7d7b122cbf8c9d01d9f530114c1914b0349ffb", size = 1178480, upload-time = "2026-04-14T17:55:41.242Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/66/0f/09a6637a7ba777eb307b7c80852d9ee26438e2bdafbad6fcc849ff9d9192/langsmith-0.6.4-py3-none-any.whl", hash = "sha256:ac4835860160be371042c7adbba3cb267bcf8d96a5ea976c33a8a4acad6c5486", size = 283503, upload-time = "2026-01-15T20:02:26.662Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/a1/a013cf458c301cda86a213dd153ce0a01c93f1ab5833f951e6a44c9763ce/langsmith-0.7.31-py3-none-any.whl", hash = "sha256:0291d49203f6e80dda011af1afda61eb0595a4d697adb684590a8805e1d61fb6", size = 373276, upload-time = "2026-04-14T17:55:39.677Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1117,7 +1118,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.2"
|
||||
version = "9.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
@@ -1128,9 +1129,9 @@ dependencies = [
|
||||
{ name = "pygments" },
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1515,6 +1516,124 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xxhash"
|
||||
version = "3.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/02/84/30869e01909fb37a6cc7e18688ee8bf1e42d57e7e0777636bd47524c43c7/xxhash-3.6.0.tar.gz", hash = "sha256:f0162a78b13a0d7617b2845b90c763339d1f1d82bb04a4b07f4ab535cc5e05d6", size = 85160, upload-time = "2025-10-02T14:37:08.097Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/34/ee/f9f1d656ad168681bb0f6b092372c1e533c4416b8069b1896a175c46e484/xxhash-3.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:87ff03d7e35c61435976554477a7f4cd1704c3596a89a8300d5ce7fc83874a71", size = 32845, upload-time = "2025-10-02T14:33:51.573Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/b1/93508d9460b292c74a09b83d16750c52a0ead89c51eea9951cb97a60d959/xxhash-3.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f572dfd3d0e2eb1a57511831cf6341242f5a9f8298a45862d085f5b93394a27d", size = 30807, upload-time = "2025-10-02T14:33:52.964Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/55/28c93a3662f2d200c70704efe74aab9640e824f8ce330d8d3943bf7c9b3c/xxhash-3.6.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:89952ea539566b9fed2bbd94e589672794b4286f342254fad28b149f9615fef8", size = 193786, upload-time = "2025-10-02T14:33:54.272Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/96/fec0be9bb4b8f5d9c57d76380a366f31a1781fb802f76fc7cda6c84893c7/xxhash-3.6.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e6f2ffb07a50b52465a1032c3cf1f4a5683f944acaca8a134a2f23674c2058", size = 212830, upload-time = "2025-10-02T14:33:55.706Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/a0/c706845ba77b9611f81fd2e93fad9859346b026e8445e76f8c6fd057cc6d/xxhash-3.6.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b5b848ad6c16d308c3ac7ad4ba6bede80ed5df2ba8ed382f8932df63158dd4b2", size = 211606, upload-time = "2025-10-02T14:33:57.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/1e/164126a2999e5045f04a69257eea946c0dc3e86541b400d4385d646b53d7/xxhash-3.6.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a034590a727b44dd8ac5914236a7b8504144447a9682586c3327e935f33ec8cc", size = 444872, upload-time = "2025-10-02T14:33:58.446Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/4b/55ab404c56cd70a2cf5ecfe484838865d0fea5627365c6c8ca156bd09c8f/xxhash-3.6.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a8f1972e75ebdd161d7896743122834fe87378160c20e97f8b09166213bf8cc", size = 193217, upload-time = "2025-10-02T14:33:59.724Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/e6/52abf06bac316db33aa269091ae7311bd53cfc6f4b120ae77bac1b348091/xxhash-3.6.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ee34327b187f002a596d7b167ebc59a1b729e963ce645964bbc050d2f1b73d07", size = 210139, upload-time = "2025-10-02T14:34:02.041Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/37/db94d490b8691236d356bc249c08819cbcef9273a1a30acf1254ff9ce157/xxhash-3.6.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:339f518c3c7a850dd033ab416ea25a692759dc7478a71131fe8869010d2b75e4", size = 197669, upload-time = "2025-10-02T14:34:03.664Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/36/c4f219ef4a17a4f7a64ed3569bc2b5a9c8311abdb22249ac96093625b1a4/xxhash-3.6.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:bf48889c9630542d4709192578aebbd836177c9f7a4a2778a7d6340107c65f06", size = 210018, upload-time = "2025-10-02T14:34:05.325Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/06/bfac889a374fc2fc439a69223d1750eed2e18a7db8514737ab630534fa08/xxhash-3.6.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:5576b002a56207f640636056b4160a378fe36a58db73ae5c27a7ec8db35f71d4", size = 413058, upload-time = "2025-10-02T14:34:06.925Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/d1/555d8447e0dd32ad0930a249a522bb2e289f0d08b6b16204cfa42c1f5a0c/xxhash-3.6.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af1f3278bd02814d6dedc5dec397993b549d6f16c19379721e5a1d31e132c49b", size = 190628, upload-time = "2025-10-02T14:34:08.669Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/15/8751330b5186cedc4ed4b597989882ea05e0408b53fa47bcb46a6125bfc6/xxhash-3.6.0-cp310-cp310-win32.whl", hash = "sha256:aed058764db109dc9052720da65fafe84873b05eb8b07e5e653597951af57c3b", size = 30577, upload-time = "2025-10-02T14:34:10.234Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/cc/53f87e8b5871a6eb2ff7e89c48c66093bda2be52315a8161ddc54ea550c4/xxhash-3.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:e82da5670f2d0d98950317f82a0e4a0197150ff19a6df2ba40399c2a3b9ae5fb", size = 31487, upload-time = "2025-10-02T14:34:11.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/00/60f9ea3bb697667a14314d7269956f58bf56bb73864f8f8d52a3c2535e9a/xxhash-3.6.0-cp310-cp310-win_arm64.whl", hash = "sha256:4a082ffff8c6ac07707fb6b671caf7c6e020c75226c561830b73d862060f281d", size = 27863, upload-time = "2025-10-02T14:34:12.619Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/d4/cc2f0400e9154df4b9964249da78ebd72f318e35ccc425e9f403c392f22a/xxhash-3.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b47bbd8cf2d72797f3c2772eaaac0ded3d3af26481a26d7d7d41dc2d3c46b04a", size = 32844, upload-time = "2025-10-02T14:34:14.037Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/ec/1cc11cd13e26ea8bc3cb4af4eaadd8d46d5014aebb67be3f71fb0b68802a/xxhash-3.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2b6821e94346f96db75abaa6e255706fb06ebd530899ed76d32cd99f20dc52fa", size = 30809, upload-time = "2025-10-02T14:34:15.484Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/5f/19fe357ea348d98ca22f456f75a30ac0916b51c753e1f8b2e0e6fb884cce/xxhash-3.6.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d0a9751f71a1a65ce3584e9cae4467651c7e70c9d31017fa57574583a4540248", size = 194665, upload-time = "2025-10-02T14:34:16.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/3b/d1f1a8f5442a5fd8beedae110c5af7604dc37349a8e16519c13c19a9a2de/xxhash-3.6.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b29ee68625ab37b04c0b40c3fafdf24d2f75ccd778333cfb698f65f6c463f62", size = 213550, upload-time = "2025-10-02T14:34:17.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/ef/3a9b05eb527457d5db13a135a2ae1a26c80fecd624d20f3e8dcc4cb170f3/xxhash-3.6.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6812c25fe0d6c36a46ccb002f40f27ac903bf18af9f6dd8f9669cb4d176ab18f", size = 212384, upload-time = "2025-10-02T14:34:19.182Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/18/ccc194ee698c6c623acbf0f8c2969811a8a4b6185af5e824cd27b9e4fd3e/xxhash-3.6.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4ccbff013972390b51a18ef1255ef5ac125c92dc9143b2d1909f59abc765540e", size = 445749, upload-time = "2025-10-02T14:34:20.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/86/cf2c0321dc3940a7aa73076f4fd677a0fb3e405cb297ead7d864fd90847e/xxhash-3.6.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:297b7fbf86c82c550e12e8fb71968b3f033d27b874276ba3624ea868c11165a8", size = 193880, upload-time = "2025-10-02T14:34:22.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/fb/96213c8560e6f948a1ecc9a7613f8032b19ee45f747f4fca4eb31bb6d6ed/xxhash-3.6.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dea26ae1eb293db089798d3973a5fc928a18fdd97cc8801226fae705b02b14b0", size = 210912, upload-time = "2025-10-02T14:34:23.937Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/aa/4395e669b0606a096d6788f40dbdf2b819d6773aa290c19e6e83cbfc312f/xxhash-3.6.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7a0b169aafb98f4284f73635a8e93f0735f9cbde17bd5ec332480484241aaa77", size = 198654, upload-time = "2025-10-02T14:34:25.644Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/74/b044fcd6b3d89e9b1b665924d85d3f400636c23590226feb1eb09e1176ce/xxhash-3.6.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:08d45aef063a4531b785cd72de4887766d01dc8f362a515693df349fdb825e0c", size = 210867, upload-time = "2025-10-02T14:34:27.203Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/fd/3ce73bf753b08cb19daee1eb14aa0d7fe331f8da9c02dd95316ddfe5275e/xxhash-3.6.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:929142361a48ee07f09121fe9e96a84950e8d4df3bb298ca5d88061969f34d7b", size = 414012, upload-time = "2025-10-02T14:34:28.409Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/b3/5a4241309217c5c876f156b10778f3ab3af7ba7e3259e6d5f5c7d0129eb2/xxhash-3.6.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:51312c768403d8540487dbbfb557454cfc55589bbde6424456951f7fcd4facb3", size = 191409, upload-time = "2025-10-02T14:34:29.696Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/01/99bfbc15fb9abb9a72b088c1d95219fc4782b7d01fc835bd5744d66dd0b8/xxhash-3.6.0-cp311-cp311-win32.whl", hash = "sha256:d1927a69feddc24c987b337ce81ac15c4720955b667fe9b588e02254b80446fd", size = 30574, upload-time = "2025-10-02T14:34:31.028Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/79/9d24d7f53819fe301b231044ea362ce64e86c74f6e8c8e51320de248b3e5/xxhash-3.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:26734cdc2d4ffe449b41d186bbeac416f704a482ed835d375a5c0cb02bc63fef", size = 31481, upload-time = "2025-10-02T14:34:32.062Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/4e/15cd0e3e8772071344eab2961ce83f6e485111fed8beb491a3f1ce100270/xxhash-3.6.0-cp311-cp311-win_arm64.whl", hash = "sha256:d72f67ef8bf36e05f5b6c65e8524f265bd61071471cd4cf1d36743ebeeeb06b7", size = 27861, upload-time = "2025-10-02T14:34:33.555Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/07/d9412f3d7d462347e4511181dea65e47e0d0e16e26fbee2ea86a2aefb657/xxhash-3.6.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:01362c4331775398e7bb34e3ab403bc9ee9f7c497bc7dee6272114055277dd3c", size = 32744, upload-time = "2025-10-02T14:34:34.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/35/0429ee11d035fc33abe32dca1b2b69e8c18d236547b9a9b72c1929189b9a/xxhash-3.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b7b2df81a23f8cb99656378e72501b2cb41b1827c0f5a86f87d6b06b69f9f204", size = 30816, upload-time = "2025-10-02T14:34:36.043Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/f2/57eb99aa0f7d98624c0932c5b9a170e1806406cdbcdb510546634a1359e0/xxhash-3.6.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:dc94790144e66b14f67b10ac8ed75b39ca47536bf8800eb7c24b50271ea0c490", size = 194035, upload-time = "2025-10-02T14:34:37.354Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/ed/6224ba353690d73af7a3f1c7cdb1fc1b002e38f783cb991ae338e1eb3d79/xxhash-3.6.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93f107c673bccf0d592cdba077dedaf52fe7f42dcd7676eba1f6d6f0c3efffd2", size = 212914, upload-time = "2025-10-02T14:34:38.6Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/86/fb6b6130d8dd6b8942cc17ab4d90e223653a89aa32ad2776f8af7064ed13/xxhash-3.6.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aa5ee3444c25b69813663c9f8067dcfaa2e126dc55e8dddf40f4d1c25d7effa", size = 212163, upload-time = "2025-10-02T14:34:39.872Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/dc/e84875682b0593e884ad73b2d40767b5790d417bde603cceb6878901d647/xxhash-3.6.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7f99123f0e1194fa59cc69ad46dbae2e07becec5df50a0509a808f90a0f03f0", size = 445411, upload-time = "2025-10-02T14:34:41.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/4f/426f91b96701ec2f37bb2b8cec664eff4f658a11f3fa9d94f0a887ea6d2b/xxhash-3.6.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49e03e6fe2cac4a1bc64952dd250cf0dbc5ef4ebb7b8d96bce82e2de163c82a2", size = 193883, upload-time = "2025-10-02T14:34:43.249Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/5a/ddbb83eee8e28b778eacfc5a85c969673e4023cdeedcfcef61f36731610b/xxhash-3.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bd17fede52a17a4f9a7bc4472a5867cb0b160deeb431795c0e4abe158bc784e9", size = 210392, upload-time = "2025-10-02T14:34:45.042Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/c2/ff69efd07c8c074ccdf0a4f36fcdd3d27363665bcdf4ba399abebe643465/xxhash-3.6.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6fb5f5476bef678f69db04f2bd1efbed3030d2aba305b0fc1773645f187d6a4e", size = 197898, upload-time = "2025-10-02T14:34:46.302Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/ca/faa05ac19b3b622c7c9317ac3e23954187516298a091eb02c976d0d3dd45/xxhash-3.6.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:843b52f6d88071f87eba1631b684fcb4b2068cd2180a0224122fe4ef011a9374", size = 210655, upload-time = "2025-10-02T14:34:47.571Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/7a/06aa7482345480cc0cb597f5c875b11a82c3953f534394f620b0be2f700c/xxhash-3.6.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7d14a6cfaf03b1b6f5f9790f76880601ccc7896aff7ab9cd8978a939c1eb7e0d", size = 414001, upload-time = "2025-10-02T14:34:49.273Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/07/63ffb386cd47029aa2916b3d2f454e6cc5b9f5c5ada3790377d5430084e7/xxhash-3.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:418daf3db71e1413cfe211c2f9a528456936645c17f46b5204705581a45390ae", size = 191431, upload-time = "2025-10-02T14:34:50.798Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/93/14fde614cadb4ddf5e7cebf8918b7e8fac5ae7861c1875964f17e678205c/xxhash-3.6.0-cp312-cp312-win32.whl", hash = "sha256:50fc255f39428a27299c20e280d6193d8b63b8ef8028995323bf834a026b4fbb", size = 30617, upload-time = "2025-10-02T14:34:51.954Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/5d/0d125536cbe7565a83d06e43783389ecae0c0f2ed037b48ede185de477c0/xxhash-3.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:c0f2ab8c715630565ab8991b536ecded9416d615538be8ecddce43ccf26cbc7c", size = 31534, upload-time = "2025-10-02T14:34:53.276Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/85/6ec269b0952ec7e36ba019125982cf11d91256a778c7c3f98a4c5043d283/xxhash-3.6.0-cp312-cp312-win_arm64.whl", hash = "sha256:eae5c13f3bc455a3bbb68bdc513912dc7356de7e2280363ea235f71f54064829", size = 27876, upload-time = "2025-10-02T14:34:54.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/76/35d05267ac82f53ae9b0e554da7c5e281ee61f3cad44c743f0fcd354f211/xxhash-3.6.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:599e64ba7f67472481ceb6ee80fa3bd828fd61ba59fb11475572cc5ee52b89ec", size = 32738, upload-time = "2025-10-02T14:34:55.839Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/a8/3fbce1cd96534a95e35d5120637bf29b0d7f5d8fa2f6374e31b4156dd419/xxhash-3.6.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7d8b8aaa30fca4f16f0c84a5c8d7ddee0e25250ec2796c973775373257dde8f1", size = 30821, upload-time = "2025-10-02T14:34:57.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/ea/d387530ca7ecfa183cb358027f1833297c6ac6098223fd14f9782cd0015c/xxhash-3.6.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d597acf8506d6e7101a4a44a5e428977a51c0fadbbfd3c39650cca9253f6e5a6", size = 194127, upload-time = "2025-10-02T14:34:59.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/0c/71435dcb99874b09a43b8d7c54071e600a7481e42b3e3ce1eb5226a5711a/xxhash-3.6.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:858dc935963a33bc33490128edc1c12b0c14d9c7ebaa4e387a7869ecc4f3e263", size = 212975, upload-time = "2025-10-02T14:35:00.816Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/7a/c2b3d071e4bb4a90b7057228a99b10d51744878f4a8a6dd643c8bd897620/xxhash-3.6.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba284920194615cb8edf73bf52236ce2e1664ccd4a38fdb543506413529cc546", size = 212241, upload-time = "2025-10-02T14:35:02.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/5f/640b6eac0128e215f177df99eadcd0f1b7c42c274ab6a394a05059694c5a/xxhash-3.6.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b54219177f6c6674d5378bd862c6aedf64725f70dd29c472eaae154df1a2e89", size = 445471, upload-time = "2025-10-02T14:35:03.61Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/1e/3c3d3ef071b051cc3abbe3721ffb8365033a172613c04af2da89d5548a87/xxhash-3.6.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42c36dd7dbad2f5238950c377fcbf6811b1cdb1c444fab447960030cea60504d", size = 193936, upload-time = "2025-10-02T14:35:05.013Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/bd/4a5f68381939219abfe1c22a9e3a5854a4f6f6f3c4983a87d255f21f2e5d/xxhash-3.6.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f22927652cba98c44639ffdc7aaf35828dccf679b10b31c4ad72a5b530a18eb7", size = 210440, upload-time = "2025-10-02T14:35:06.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/37/b80fe3d5cfb9faff01a02121a0f4d565eb7237e9e5fc66e73017e74dcd36/xxhash-3.6.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b45fad44d9c5c119e9c6fbf2e1c656a46dc68e280275007bbfd3d572b21426db", size = 197990, upload-time = "2025-10-02T14:35:07.735Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/fd/2c0a00c97b9e18f72e1f240ad4e8f8a90fd9d408289ba9c7c495ed7dc05c/xxhash-3.6.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6f2580ffab1a8b68ef2b901cde7e55fa8da5e4be0977c68f78fc80f3c143de42", size = 210689, upload-time = "2025-10-02T14:35:09.438Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/86/5dd8076a926b9a95db3206aba20d89a7fc14dd5aac16e5c4de4b56033140/xxhash-3.6.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40c391dd3cd041ebc3ffe6f2c862f402e306eb571422e0aa918d8070ba31da11", size = 414068, upload-time = "2025-10-02T14:35:11.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/3c/0bb129170ee8f3650f08e993baee550a09593462a5cddd8e44d0011102b1/xxhash-3.6.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f205badabde7aafd1a31e8ca2a3e5a763107a71c397c4481d6a804eb5063d8bd", size = 191495, upload-time = "2025-10-02T14:35:12.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/3a/6797e0114c21d1725e2577508e24006fd7ff1d8c0c502d3b52e45c1771d8/xxhash-3.6.0-cp313-cp313-win32.whl", hash = "sha256:2577b276e060b73b73a53042ea5bd5203d3e6347ce0d09f98500f418a9fcf799", size = 30620, upload-time = "2025-10-02T14:35:14.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/15/9bc32671e9a38b413a76d24722a2bf8784a132c043063a8f5152d390b0f9/xxhash-3.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:757320d45d2fbcce8f30c42a6b2f47862967aea7bf458b9625b4bbe7ee390392", size = 31542, upload-time = "2025-10-02T14:35:15.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/c5/cc01e4f6188656e56112d6a8e0dfe298a16934b8c47a247236549a3f7695/xxhash-3.6.0-cp313-cp313-win_arm64.whl", hash = "sha256:457b8f85dec5825eed7b69c11ae86834a018b8e3df5e77783c999663da2f96d6", size = 27880, upload-time = "2025-10-02T14:35:16.315Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/30/25e5321c8732759e930c555176d37e24ab84365482d257c3b16362235212/xxhash-3.6.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a42e633d75cdad6d625434e3468126c73f13f7584545a9cf34e883aa1710e702", size = 32956, upload-time = "2025-10-02T14:35:17.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/3c/0573299560d7d9f8ab1838f1efc021a280b5ae5ae2e849034ef3dee18810/xxhash-3.6.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:568a6d743219e717b07b4e03b0a828ce593833e498c3b64752e0f5df6bfe84db", size = 31072, upload-time = "2025-10-02T14:35:18.844Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/1c/52d83a06e417cd9d4137722693424885cc9878249beb3a7c829e74bf7ce9/xxhash-3.6.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bec91b562d8012dae276af8025a55811b875baace6af510412a5e58e3121bc54", size = 196409, upload-time = "2025-10-02T14:35:20.31Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/8e/c6d158d12a79bbd0b878f8355432075fc82759e356ab5a111463422a239b/xxhash-3.6.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78e7f2f4c521c30ad5e786fdd6bae89d47a32672a80195467b5de0480aa97b1f", size = 215736, upload-time = "2025-10-02T14:35:21.616Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/68/c4c80614716345d55071a396cf03d06e34b5f4917a467faf43083c995155/xxhash-3.6.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ed0df1b11a79856df5ffcab572cbd6b9627034c1c748c5566fa79df9048a7c5", size = 214833, upload-time = "2025-10-02T14:35:23.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/e9/ae27c8ffec8b953efa84c7c4a6c6802c263d587b9fc0d6e7cea64e08c3af/xxhash-3.6.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0e4edbfc7d420925b0dd5e792478ed393d6e75ff8fc219a6546fb446b6a417b1", size = 448348, upload-time = "2025-10-02T14:35:25.111Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/6b/33e21afb1b5b3f46b74b6bd1913639066af218d704cc0941404ca717fc57/xxhash-3.6.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fba27a198363a7ef87f8c0f6b171ec36b674fe9053742c58dd7e3201c1ab30ee", size = 196070, upload-time = "2025-10-02T14:35:26.586Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/b6/fcabd337bc5fa624e7203aa0fa7d0c49eed22f72e93229431752bddc83d9/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:794fe9145fe60191c6532fa95063765529770edcdd67b3d537793e8004cabbfd", size = 212907, upload-time = "2025-10-02T14:35:28.087Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/d3/9ee6160e644d660fcf176c5825e61411c7f62648728f69c79ba237250143/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:6105ef7e62b5ac73a837778efc331a591d8442f8ef5c7e102376506cb4ae2729", size = 200839, upload-time = "2025-10-02T14:35:29.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/98/e8de5baa5109394baf5118f5e72ab21a86387c4f89b0e77ef3e2f6b0327b/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f01375c0e55395b814a679b3eea205db7919ac2af213f4a6682e01220e5fe292", size = 213304, upload-time = "2025-10-02T14:35:31.222Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/1d/71056535dec5c3177eeb53e38e3d367dd1d16e024e63b1cee208d572a033/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d706dca2d24d834a4661619dcacf51a75c16d65985718d6a7d73c1eeeb903ddf", size = 416930, upload-time = "2025-10-02T14:35:32.517Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/6c/5cbde9de2cd967c322e651c65c543700b19e7ae3e0aae8ece3469bf9683d/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5f059d9faeacd49c0215d66f4056e1326c80503f51a1532ca336a385edadd033", size = 193787, upload-time = "2025-10-02T14:35:33.827Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/fa/0172e350361d61febcea941b0cc541d6e6c8d65d153e85f850a7b256ff8a/xxhash-3.6.0-cp313-cp313t-win32.whl", hash = "sha256:1244460adc3a9be84731d72b8e80625788e5815b68da3da8b83f78115a40a7ec", size = 30916, upload-time = "2025-10-02T14:35:35.107Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/e6/e8cf858a2b19d6d45820f072eff1bea413910592ff17157cabc5f1227a16/xxhash-3.6.0-cp313-cp313t-win_amd64.whl", hash = "sha256:b1e420ef35c503869c4064f4a2f2b08ad6431ab7b229a05cce39d74268bca6b8", size = 31799, upload-time = "2025-10-02T14:35:36.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/15/064b197e855bfb7b343210e82490ae672f8bc7cdf3ddb02e92f64304ee8a/xxhash-3.6.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ec44b73a4220623235f67a996c862049f375df3b1052d9899f40a6382c32d746", size = 28044, upload-time = "2025-10-02T14:35:37.195Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/5e/0138bc4484ea9b897864d59fce9be9086030825bc778b76cb5a33a906d37/xxhash-3.6.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a40a3d35b204b7cc7643cbcf8c9976d818cb47befcfac8bbefec8038ac363f3e", size = 32754, upload-time = "2025-10-02T14:35:38.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/d7/5dac2eb2ec75fd771957a13e5dda560efb2176d5203f39502a5fc571f899/xxhash-3.6.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a54844be970d3fc22630b32d515e79a90d0a3ddb2644d8d7402e3c4c8da61405", size = 30846, upload-time = "2025-10-02T14:35:39.6Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/71/8bc5be2bb00deb5682e92e8da955ebe5fa982da13a69da5a40a4c8db12fb/xxhash-3.6.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:016e9190af8f0a4e3741343777710e3d5717427f175adfdc3e72508f59e2a7f3", size = 194343, upload-time = "2025-10-02T14:35:40.69Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/3b/52badfb2aecec2c377ddf1ae75f55db3ba2d321c5e164f14461c90837ef3/xxhash-3.6.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f6f72232f849eb9d0141e2ebe2677ece15adfd0fa599bc058aad83c714bb2c6", size = 213074, upload-time = "2025-10-02T14:35:42.29Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/2b/ae46b4e9b92e537fa30d03dbc19cdae57ed407e9c26d163895e968e3de85/xxhash-3.6.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:63275a8aba7865e44b1813d2177e0f5ea7eadad3dd063a21f7cf9afdc7054063", size = 212388, upload-time = "2025-10-02T14:35:43.929Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/80/49f88d3afc724b4ac7fbd664c8452d6db51b49915be48c6982659e0e7942/xxhash-3.6.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cd01fa2aa00d8b017c97eb46b9a794fbdca53fc14f845f5a328c71254b0abb7", size = 445614, upload-time = "2025-10-02T14:35:45.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/ba/603ce3961e339413543d8cd44f21f2c80e2a7c5cfe692a7b1f2cccf58f3c/xxhash-3.6.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0226aa89035b62b6a86d3c68df4d7c1f47a342b8683da2b60cedcddb46c4d95b", size = 194024, upload-time = "2025-10-02T14:35:46.959Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/d1/8e225ff7113bf81545cfdcd79eef124a7b7064a0bba53605ff39590b95c2/xxhash-3.6.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c6e193e9f56e4ca4923c61238cdaced324f0feac782544eb4c6d55ad5cc99ddd", size = 210541, upload-time = "2025-10-02T14:35:48.301Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/58/0f89d149f0bad89def1a8dd38feb50ccdeb643d9797ec84707091d4cb494/xxhash-3.6.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9176dcaddf4ca963d4deb93866d739a343c01c969231dbe21680e13a5d1a5bf0", size = 198305, upload-time = "2025-10-02T14:35:49.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/38/5eab81580703c4df93feb5f32ff8fa7fe1e2c51c1f183ee4e48d4bb9d3d7/xxhash-3.6.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c1ce4009c97a752e682b897aa99aef84191077a9433eb237774689f14f8ec152", size = 210848, upload-time = "2025-10-02T14:35:50.877Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/6b/953dc4b05c3ce678abca756416e4c130d2382f877a9c30a20d08ee6a77c0/xxhash-3.6.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:8cb2f4f679b01513b7adbb9b1b2f0f9cdc31b70007eaf9d59d0878809f385b11", size = 414142, upload-time = "2025-10-02T14:35:52.15Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/a9/238ec0d4e81a10eb5026d4a6972677cbc898ba6c8b9dbaec12ae001b1b35/xxhash-3.6.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:653a91d7c2ab54a92c19ccf43508b6a555440b9be1bc8be553376778be7f20b5", size = 191547, upload-time = "2025-10-02T14:35:53.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/ee/3cf8589e06c2164ac77c3bf0aa127012801128f1feebf2a079272da5737c/xxhash-3.6.0-cp314-cp314-win32.whl", hash = "sha256:a756fe893389483ee8c394d06b5ab765d96e68fbbfe6fde7aa17e11f5720559f", size = 31214, upload-time = "2025-10-02T14:35:54.746Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/5d/a19552fbc6ad4cb54ff953c3908bbc095f4a921bc569433d791f755186f1/xxhash-3.6.0-cp314-cp314-win_amd64.whl", hash = "sha256:39be8e4e142550ef69629c9cd71b88c90e9a5db703fecbcf265546d9536ca4ad", size = 32290, upload-time = "2025-10-02T14:35:55.791Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/11/dafa0643bc30442c887b55baf8e73353a344ee89c1901b5a5c54a6c17d39/xxhash-3.6.0-cp314-cp314-win_arm64.whl", hash = "sha256:25915e6000338999236f1eb68a02a32c3275ac338628a7eaa5a269c401995679", size = 28795, upload-time = "2025-10-02T14:35:57.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/db/0e99732ed7f64182aef4a6fb145e1a295558deec2a746265dcdec12d191e/xxhash-3.6.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c5294f596a9017ca5a3e3f8884c00b91ab2ad2933cf288f4923c3fd4346cf3d4", size = 32955, upload-time = "2025-10-02T14:35:58.267Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/f4/2a7c3c68e564a099becfa44bb3d398810cc0ff6749b0d3cb8ccb93f23c14/xxhash-3.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1cf9dcc4ab9cff01dfbba78544297a3a01dafd60f3bde4e2bfd016cf7e4ddc67", size = 31072, upload-time = "2025-10-02T14:35:59.382Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/d9/72a29cddc7250e8a5819dad5d466facb5dc4c802ce120645630149127e73/xxhash-3.6.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:01262da8798422d0685f7cef03b2bd3f4f46511b02830861df548d7def4402ad", size = 196579, upload-time = "2025-10-02T14:36:00.838Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/93/b21590e1e381040e2ca305a884d89e1c345b347404f7780f07f2cdd47ef4/xxhash-3.6.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51a73fb7cb3a3ead9f7a8b583ffd9b8038e277cdb8cb87cf890e88b3456afa0b", size = 215854, upload-time = "2025-10-02T14:36:02.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/b8/edab8a7d4fa14e924b29be877d54155dcbd8b80be85ea00d2be3413a9ed4/xxhash-3.6.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b9c6df83594f7df8f7f708ce5ebeacfc69f72c9fbaaababf6cf4758eaada0c9b", size = 214965, upload-time = "2025-10-02T14:36:03.507Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/67/dfa980ac7f0d509d54ea0d5a486d2bb4b80c3f1bb22b66e6a05d3efaf6c0/xxhash-3.6.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:627f0af069b0ea56f312fd5189001c24578868643203bca1abbc2c52d3a6f3ca", size = 448484, upload-time = "2025-10-02T14:36:04.828Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/63/8ffc2cc97e811c0ca5d00ab36604b3ea6f4254f20b7bc658ca825ce6c954/xxhash-3.6.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa912c62f842dfd013c5f21a642c9c10cd9f4c4e943e0af83618b4a404d9091a", size = 196162, upload-time = "2025-10-02T14:36:06.182Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/77/07f0e7a3edd11a6097e990f6e5b815b6592459cb16dae990d967693e6ea9/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b465afd7909db30168ab62afe40b2fcf79eedc0b89a6c0ab3123515dc0df8b99", size = 213007, upload-time = "2025-10-02T14:36:07.733Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/d8/bc5fa0d152837117eb0bef6f83f956c509332ce133c91c63ce07ee7c4873/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a881851cf38b0a70e7c4d3ce81fc7afd86fbc2a024f4cfb2a97cf49ce04b75d3", size = 200956, upload-time = "2025-10-02T14:36:09.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/a5/d749334130de9411783873e9b98ecc46688dad5db64ca6e04b02acc8b473/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9b3222c686a919a0f3253cfc12bb118b8b103506612253b5baeaac10d8027cf6", size = 213401, upload-time = "2025-10-02T14:36:10.585Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/72/abed959c956a4bfc72b58c0384bb7940663c678127538634d896b1195c10/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:c5aa639bc113e9286137cec8fadc20e9cd732b2cc385c0b7fa673b84fc1f2a93", size = 417083, upload-time = "2025-10-02T14:36:12.276Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/b3/62fd2b586283b7d7d665fb98e266decadf31f058f1cf6c478741f68af0cb/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5c1343d49ac102799905e115aee590183c3921d475356cb24b4de29a4bc56518", size = 193913, upload-time = "2025-10-02T14:36:14.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/9a/c19c42c5b3f5a4aad748a6d5b4f23df3bed7ee5445accc65a0fb3ff03953/xxhash-3.6.0-cp314-cp314t-win32.whl", hash = "sha256:5851f033c3030dd95c086b4a36a2683c2ff4a799b23af60977188b057e467119", size = 31586, upload-time = "2025-10-02T14:36:15.603Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/d6/4cc450345be9924fd5dc8c590ceda1db5b43a0a889587b0ae81a95511360/xxhash-3.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0444e7967dac37569052d2409b00a8860c2135cff05502df4da80267d384849f", size = 32526, upload-time = "2025-10-02T14:36:16.708Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/c9/7243eb3f9eaabd1a88a5a5acadf06df2d83b100c62684b7425c6a11bcaa8/xxhash-3.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:bb79b1e63f6fd84ec778a4b1916dfe0a7c3fdb986c06addd5db3a0d413819d95", size = 28898, upload-time = "2025-10-02T14:36:17.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/1e/8aec23647a34a249f62e2398c42955acd9b4c6ed5cf08cbea94dc46f78d2/xxhash-3.6.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0f7b7e2ec26c1666ad5fc9dbfa426a6a3367ceaf79db5dd76264659d509d73b0", size = 30662, upload-time = "2025-10-02T14:37:01.743Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/0b/b14510b38ba91caf43006209db846a696ceea6a847a0c9ba0a5b1adc53d6/xxhash-3.6.0-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5dc1e14d14fa0f5789ec29a7062004b5933964bb9b02aae6622b8f530dc40296", size = 41056, upload-time = "2025-10-02T14:37:02.879Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/55/15a7b8a56590e66ccd374bbfa3f9ffc45b810886c8c3b614e3f90bd2367c/xxhash-3.6.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:881b47fc47e051b37d94d13e7455131054b56749b91b508b0907eb07900d1c13", size = 36251, upload-time = "2025-10-02T14:37:04.44Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/b2/5ac99a041a29e58e95f907876b04f7067a0242cb85b5f39e726153981503/xxhash-3.6.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6dc31591899f5e5666f04cc2e529e69b4072827085c1ef15294d91a004bc1bd", size = 32481, upload-time = "2025-10-02T14:37:05.869Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/d9/8d95e906764a386a3d3b596f3c68bb63687dfca806373509f51ce8eea81f/xxhash-3.6.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:15e0dac10eb9309508bfc41f7f9deaa7755c69e35af835db9cb10751adebc35d", size = 31565, upload-time = "2025-10-02T14:37:06.966Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstandard"
|
||||
version = "0.25.0"
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
.langgraph_api/
|
||||
# Go cross-compiled binaries (built at release time, bundled into wheels)
|
||||
langgraph_cli/bin/
|
||||
|
||||
@@ -1,374 +0,0 @@
|
||||
# Go Migration Plan
|
||||
|
||||
## Goal
|
||||
|
||||
Move the full `langgraph` CLI implementation to Go while preserving existing
|
||||
Python distribution and invocation flows.
|
||||
|
||||
Users should continue to be able to run:
|
||||
|
||||
- `langgraph ...`
|
||||
- `uv run langgraph ...`
|
||||
- `uvx langgraph ...`
|
||||
|
||||
During phase 1, the Go path is gated behind a feature flag. The Python package
|
||||
remains the public entrypoint and launcher.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
This phase does not include:
|
||||
|
||||
- JS migration
|
||||
- `langsmith-cli` integration
|
||||
- user-facing command renames
|
||||
- intentional CLI behavior changes
|
||||
- a long-lived dual implementation
|
||||
|
||||
## Source Of Truth
|
||||
|
||||
There will be one implementation of CLI behavior:
|
||||
|
||||
- shared Go implementation lives in the `langgraph` repo
|
||||
- standalone Go `langgraph` binary uses that implementation
|
||||
- Python `langgraph-cli` package is a thin launcher around that binary
|
||||
- legacy Python implementation exists only temporarily as fallback during rollout
|
||||
|
||||
## Phase 1 Artifacts
|
||||
|
||||
Phase 1 ships these artifacts:
|
||||
|
||||
- shared Go package(s) in `langgraph`
|
||||
- standalone `langgraph` Go binary
|
||||
- Python wheel `langgraph-cli` that bundles the platform-specific Go binary
|
||||
- Python launcher entrypoint that can route to legacy Python or Go
|
||||
|
||||
JS is explicitly out of scope for phase 1.
|
||||
|
||||
## User-Facing Command Scope
|
||||
|
||||
Phase 1 scope is the whole `langgraph` CLI, not just deploy.
|
||||
|
||||
Target command coverage:
|
||||
|
||||
- `langgraph deploy ...`
|
||||
- `langgraph build ...`
|
||||
- `langgraph up ...`
|
||||
- `langgraph dockerfile ...`
|
||||
- `langgraph dev ...`
|
||||
- `langgraph new ...`
|
||||
|
||||
The goal is full parity with the current Python CLI command surface.
|
||||
|
||||
## Compatibility Contract
|
||||
|
||||
Behavior must not regress.
|
||||
|
||||
Required parity:
|
||||
|
||||
- exact JSON output where commands emit JSON
|
||||
- exact or equivalent error semantics
|
||||
- same exit codes
|
||||
- same argument and flag behavior
|
||||
- same generated artifacts for:
|
||||
- Dockerfile output
|
||||
- docker compose / inline compose output
|
||||
- same API request semantics where mocked in tests
|
||||
|
||||
Human-readable output should be same or better, but not worse.
|
||||
|
||||
## Repo Ownership
|
||||
|
||||
Shared implementation lives in the current `langgraph` repo.
|
||||
|
||||
Reasons:
|
||||
|
||||
- current CLI spec and tests already live here
|
||||
- rollout is initially only for `langgraph-cli`
|
||||
- command compatibility should be driven by existing behavior in this repo
|
||||
|
||||
## Architecture
|
||||
|
||||
Use process boundaries, not language FFI.
|
||||
|
||||
Python should not call a Go shared library directly. Instead:
|
||||
|
||||
- Python launcher locates bundled `langgraph` Go binary
|
||||
- Python launcher `exec`s or subprocesses into the Go binary
|
||||
- Go handles all command execution
|
||||
- for `dev`, Go subprocesses back into Python
|
||||
|
||||
This keeps the boundary simple and cross-platform.
|
||||
|
||||
## Go Package Structure
|
||||
|
||||
Recommended structure:
|
||||
|
||||
- `pkg/cli/config`
|
||||
- parse and validate `langgraph.json`
|
||||
- normalize config model
|
||||
- `pkg/cli/docker`
|
||||
- docker capability detection
|
||||
- compose generation
|
||||
- Dockerfile/build plan generation
|
||||
- `pkg/cli/deploy`
|
||||
- deployment flows
|
||||
- host backend client
|
||||
- polling, logs, revision logic
|
||||
- `pkg/cli/dev`
|
||||
- `dev` command orchestration
|
||||
- Python subprocess handoff
|
||||
- `pkg/cli/cmds`
|
||||
- command runner functions with typed options/results
|
||||
- no Cobra-specific code here
|
||||
- `cmd/langgraph`
|
||||
- standalone Go binary wrapping shared packages
|
||||
|
||||
Business logic should live in shared packages, not directly in CLI adapter code.
|
||||
|
||||
## Python Wrapper Model
|
||||
|
||||
The Python package remains installed as `langgraph-cli`, with entrypoint
|
||||
`langgraph`.
|
||||
|
||||
During migration, the wrapper decides whether to route to legacy Python or Go.
|
||||
|
||||
Wrapper behavior:
|
||||
|
||||
1. inspect feature flags
|
||||
2. resolve Go binary path
|
||||
3. if Go path is active, `exec` into Go binary
|
||||
4. otherwise fall back to legacy Python implementation
|
||||
|
||||
Long-term target:
|
||||
|
||||
- remove fallback
|
||||
- Python wrapper always launches bundled Go binary
|
||||
|
||||
## Feature Flags
|
||||
|
||||
Temporary rollout env vars:
|
||||
|
||||
- `LANGGRAPH_USE_GO_CLI=1`
|
||||
- route the Python wrapper to the Go binary instead of legacy Python
|
||||
- `LANGGRAPH_GO_CLI_PATH=/path/to/langgraph`
|
||||
- internal/dev/CI override for binary path resolution
|
||||
- not intended as a long-term public interface
|
||||
- `LANGGRAPH_CALLING_PYTHON=/path/to/python`
|
||||
- set by the Python wrapper before invoking Go
|
||||
- used by Go for `dev`
|
||||
|
||||
`LANGGRAPH_GO_CLI_PATH` is mainly for local development and CI and can be
|
||||
removed later.
|
||||
|
||||
## `dev` Invocation Contract
|
||||
|
||||
`dev` is the main tricky area.
|
||||
|
||||
Design rule:
|
||||
|
||||
- Go owns CLI parsing and routing
|
||||
- Python owns the actual in-process local dev server runtime
|
||||
|
||||
Flow for `uv run langgraph dev`:
|
||||
|
||||
1. `uv` selects the Python interpreter/environment
|
||||
2. Python wrapper starts
|
||||
3. Python wrapper sets `LANGGRAPH_CALLING_PYTHON=sys.executable`
|
||||
4. Python wrapper launches Go binary
|
||||
5. Go receives `dev`
|
||||
6. Go shells out to that exact Python interpreter for the actual Python runtime behavior
|
||||
|
||||
This preserves the current selected Python environment.
|
||||
|
||||
Go Python resolution order for `dev`:
|
||||
|
||||
1. `LANGGRAPH_CALLING_PYTHON`
|
||||
2. optional explicit override if added later
|
||||
3. environment-derived interpreter / active venv
|
||||
4. fallback detection
|
||||
5. clear failure
|
||||
|
||||
The critical constraint is: if the user entered through Python, `dev` should
|
||||
use that exact Python when possible.
|
||||
|
||||
## Why Not FFI
|
||||
|
||||
Do not use:
|
||||
|
||||
- cgo shared libs
|
||||
- Python-Go FFI bindings
|
||||
- embedded Python in Go
|
||||
- RPC unless absolutely necessary
|
||||
|
||||
Reasons:
|
||||
|
||||
- packaging complexity
|
||||
- cross-platform pain
|
||||
- no advantage for a CLI architecture
|
||||
- much worse release/debug story
|
||||
|
||||
Process-level boundaries are the right choice here.
|
||||
|
||||
## Packaging Constraints
|
||||
|
||||
The Go binary should be bundled inside Python wheels.
|
||||
|
||||
Preferred distribution model:
|
||||
|
||||
- build platform-specific `langgraph-cli` wheels
|
||||
- each wheel includes the matching `langgraph` Go binary
|
||||
- Python launcher resolves and executes the bundled binary
|
||||
|
||||
Do not rely on runtime download of the binary for normal operation.
|
||||
|
||||
Support matrix target:
|
||||
|
||||
- all OS/arch targets that are currently expected to be supported
|
||||
- at minimum, align with the practical support matrix desired for the CLI,
|
||||
using `orjson` support as a rough proxy if needed
|
||||
|
||||
If a platform is unsupported, fail clearly rather than silently falling back
|
||||
forever.
|
||||
|
||||
## Release Constraints
|
||||
|
||||
Phase 1 versioning applies to:
|
||||
|
||||
- shared Go implementation
|
||||
- standalone Go `langgraph` binary
|
||||
- PyPI `langgraph-cli` wrapper
|
||||
|
||||
They should stay on one version line.
|
||||
|
||||
Constraint:
|
||||
|
||||
- bundled Go binary version must exactly match the Python wrapper version for
|
||||
the migrated surface
|
||||
|
||||
The wrapper should detect obvious mismatch and fail clearly if it occurs.
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
Use a big-bang hidden implementation change with gradual activation.
|
||||
|
||||
Phase 1 rollout:
|
||||
|
||||
1. implement full Go path behind `LANGGRAPH_USE_GO_CLI`
|
||||
2. keep default behavior on legacy Python
|
||||
3. run dual CI for legacy and Go-backed paths
|
||||
4. dogfood with feature flag
|
||||
5. flip default to Go
|
||||
6. keep fallback briefly
|
||||
7. remove fallback in about two weeks
|
||||
|
||||
This is a big internal rewrite with gradual external activation.
|
||||
|
||||
## CI Strategy
|
||||
|
||||
Dual CI is required during migration.
|
||||
|
||||
Run both variants:
|
||||
|
||||
- legacy Python implementation
|
||||
- Python wrapper -> Go binary implementation
|
||||
|
||||
Required parity checks:
|
||||
|
||||
- help output
|
||||
- exit code
|
||||
- stdout
|
||||
- stderr
|
||||
- generated Dockerfile output
|
||||
- generated compose output
|
||||
- mocked deployment API request semantics
|
||||
- validation errors / usage errors
|
||||
|
||||
Goal is not merely "both tests pass". Goal is "both implementations behave
|
||||
identically enough to swap by default safely".
|
||||
|
||||
## Parity Test Philosophy
|
||||
|
||||
Use the current Python CLI tests as the behavioral spec.
|
||||
|
||||
Priority test areas:
|
||||
|
||||
- config validation
|
||||
- compose/Dockerfile generation
|
||||
- deployment flows
|
||||
- error and prompt behavior
|
||||
- command help / command surface
|
||||
|
||||
Where practical, add golden comparisons so regressions are obvious.
|
||||
|
||||
## Implementation Order Inside Phase 1
|
||||
|
||||
Even though rollout is one hidden phase, implementation should proceed in this
|
||||
order:
|
||||
|
||||
1. wrapper contract and env contract
|
||||
2. Go command scaffolding and package boundaries
|
||||
3. config + docker/build/compose logic
|
||||
4. deploy flows
|
||||
5. remaining commands
|
||||
6. `dev` subprocess orchestration
|
||||
7. parity hardening in CI
|
||||
|
||||
This reduces risk because `dev` is the highest-uncertainty area.
|
||||
|
||||
## Command Ownership Constraint
|
||||
|
||||
All command behavior should live in Go once ported.
|
||||
|
||||
Do not allow:
|
||||
|
||||
- some flags parsed in Python and others in Go
|
||||
- duplicated command logic across Python and Go
|
||||
- separate behavior definitions for legacy and migrated commands
|
||||
|
||||
The wrapper should be thin only.
|
||||
|
||||
## Fallback Constraint
|
||||
|
||||
Fallback is temporary, not a product feature.
|
||||
|
||||
Policy:
|
||||
|
||||
- use feature flag during migration
|
||||
- flip default after parity confidence
|
||||
- remove legacy Python implementation roughly two weeks later
|
||||
|
||||
Do not normalize to permanent dual execution paths.
|
||||
|
||||
## Documentation Constraint
|
||||
|
||||
During migration, documentation should stay conservative:
|
||||
|
||||
- existing Python install flow remains primary
|
||||
- feature flag is acceptable for internal/dogfood docs
|
||||
- avoid broad external messaging about the Go implementation until default is flipped
|
||||
|
||||
## Open Issues To Track
|
||||
|
||||
These are not blockers, but they need explicit implementation decisions:
|
||||
|
||||
- exact bundled wheel layout for binaries
|
||||
- exact list of supported OS/arch targets
|
||||
- whether to expose a public `--python` override for `dev`
|
||||
- whether some pretty output is allowed to improve while keeping parsed output stable
|
||||
|
||||
## Summary
|
||||
|
||||
Phase 1 plan:
|
||||
|
||||
- move the entire `langgraph` CLI implementation into shared Go code in the
|
||||
`langgraph` repo
|
||||
- ship a standalone `langgraph` Go binary
|
||||
- keep `langgraph-cli` on PyPI as a thin launcher that bundles and executes
|
||||
that binary
|
||||
- preserve `uv run` / `uvx` behavior
|
||||
- handle `dev` by passing the calling Python path through the wrapper and
|
||||
having Go subprocess back into Python
|
||||
- gate everything behind `LANGGRAPH_USE_GO_CLI`
|
||||
- run dual CI until parity is proven
|
||||
- flip default
|
||||
- remove legacy fallback quickly
|
||||
+3
-84
@@ -1,21 +1,15 @@
|
||||
.PHONY: test lint type format test-integration update-schema bump-version
|
||||
.PHONY: test-go lint-go format-go
|
||||
.PHONY: build-go build-go-all clean-go-bin
|
||||
|
||||
######################
|
||||
# TESTING AND COVERAGE
|
||||
######################
|
||||
|
||||
TEST?= "tests/unit_tests"
|
||||
GO_FILES=$(shell find cmd internal -type f -name '*.go' 2>/dev/null)
|
||||
test: test-go
|
||||
test:
|
||||
uv run pytest $(TEST)
|
||||
test-integration:
|
||||
uv run pytest tests/integration_tests
|
||||
|
||||
test-go:
|
||||
[ ! -f go.mod ] || go test ./...
|
||||
|
||||
######################
|
||||
# LINTING AND FORMATTING
|
||||
######################
|
||||
@@ -29,94 +23,19 @@ lint_package: PYTHON_FILES=langgraph_cli
|
||||
lint_tests: PYTHON_FILES=tests
|
||||
lint_tests: MYPY_CACHE=.mypy_cache_test
|
||||
|
||||
lint lint_diff lint_package lint_tests: lint-go
|
||||
lint lint_diff lint_package lint_tests:
|
||||
uv run ruff check .
|
||||
[ "$(PYTHON_FILES)" = "" ] || uv run ruff format $(PYTHON_FILES) --diff
|
||||
[ "$(PYTHON_FILES)" = "" ] || uv run ruff check --select I $(PYTHON_FILES)
|
||||
[ "$(PYTHON_FILES)" = "" ] || mkdir -p $(MYPY_CACHE) || uv run mypy $(PYTHON_FILES) --cache-dir $(MYPY_CACHE)
|
||||
|
||||
lint-go:
|
||||
[ -z "$(GO_FILES)" ] || test -z "$$(gofmt -l $(GO_FILES))"
|
||||
|
||||
type:
|
||||
mkdir -p $(MYPY_CACHE) && uv run mypy $(PYTHON_FILES) --cache-dir $(MYPY_CACHE)
|
||||
|
||||
format format_diff: format-go
|
||||
format format_diff:
|
||||
uv run ruff format $(PYTHON_FILES)
|
||||
uv run ruff check --select I --fix $(PYTHON_FILES)
|
||||
|
||||
format-go:
|
||||
[ -z "$(GO_FILES)" ] || gofmt -w $(GO_FILES)
|
||||
|
||||
######################
|
||||
# GO BINARY CROSS-COMPILATION
|
||||
######################
|
||||
|
||||
GO_MODULE=github.com/langchain-ai/langgraph/libs/cli
|
||||
GO_BINARY=cmd/langgraph/main.go
|
||||
GO_VERSION_PKG=$(GO_MODULE)/internal/version
|
||||
GO_BIN_DIR=langgraph_cli/bin
|
||||
GO_VERSION=$(shell grep -m 1 '^__version__' langgraph_cli/__init__.py | cut -d '"' -f 2)
|
||||
GO_COMMIT=$(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
||||
GO_DATE=$(shell date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
GO_LDFLAGS=-s -w \
|
||||
-X '$(GO_VERSION_PKG).Version=$(GO_VERSION)' \
|
||||
-X '$(GO_VERSION_PKG).Commit=$(GO_COMMIT)' \
|
||||
-X '$(GO_VERSION_PKG).Date=$(GO_DATE)'
|
||||
|
||||
# Platform matrix — covers every platform orjson ships for. Since the Go
|
||||
# binary is statically linked (CGO_ENABLED=0), the same linux binary works
|
||||
# on both glibc and musl. Musllinux wheels reuse the linux binary but are
|
||||
# tagged differently so pip installs them on Alpine/musl systems.
|
||||
GO_PLATFORMS = \
|
||||
linux/amd64 \
|
||||
linux/arm64 \
|
||||
linux/arm \
|
||||
linux/386 \
|
||||
linux/ppc64le \
|
||||
linux/s390x \
|
||||
darwin/amd64 \
|
||||
darwin/arm64 \
|
||||
windows/amd64 \
|
||||
windows/arm64 \
|
||||
windows/386
|
||||
|
||||
# Build for the current platform (development).
|
||||
build-go:
|
||||
@mkdir -p $(GO_BIN_DIR)
|
||||
go build -ldflags "$(GO_LDFLAGS)" -o $(GO_BIN_DIR)/langgraph $(GO_BINARY)
|
||||
@echo "Built $(GO_BIN_DIR)/langgraph"
|
||||
|
||||
# Build for a single target: make build-go-target GOOS=linux GOARCH=amd64
|
||||
build-go-target:
|
||||
$(eval EXT=$(if $(filter windows,$(GOOS)),.exe,))
|
||||
@mkdir -p $(GO_BIN_DIR)
|
||||
GOOS=$(GOOS) GOARCH=$(GOARCH) CGO_ENABLED=0 \
|
||||
go build -ldflags "$(GO_LDFLAGS)" \
|
||||
-o $(GO_BIN_DIR)/langgraph-$(GOOS)-$(GOARCH)$(EXT) $(GO_BINARY)
|
||||
@echo "Built $(GO_BIN_DIR)/langgraph-$(GOOS)-$(GOARCH)$(EXT)"
|
||||
|
||||
# Build for all platforms.
|
||||
build-go-all:
|
||||
@mkdir -p $(GO_BIN_DIR)
|
||||
@for platform in $(GO_PLATFORMS); do \
|
||||
os=$${platform%/*}; arch=$${platform#*/}; \
|
||||
ext=""; \
|
||||
if [ "$$os" = "windows" ]; then ext=".exe"; fi; \
|
||||
echo "Building $$os/$$arch..."; \
|
||||
GOOS=$$os GOARCH=$$arch CGO_ENABLED=0 \
|
||||
go build -ldflags "$(GO_LDFLAGS)" \
|
||||
-o $(GO_BIN_DIR)/langgraph-$$os-$$arch$$ext $(GO_BINARY) || exit 1; \
|
||||
done
|
||||
@echo "All platforms built in $(GO_BIN_DIR)/"
|
||||
|
||||
clean-go-bin:
|
||||
rm -rf $(GO_BIN_DIR)
|
||||
|
||||
######################
|
||||
# SCHEMA AND VERSIONING
|
||||
######################
|
||||
|
||||
update-schema:
|
||||
uv run python generate_schema.py
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/langchain-ai/langgraph/libs/cli/internal/root"
|
||||
)
|
||||
|
||||
func main() {
|
||||
os.Exit(root.Run(os.Args[1:], os.Stdout, os.Stderr))
|
||||
}
|
||||
@@ -5,5 +5,5 @@ description = "Test for prerelease stuff"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"langchain-openai==1.0.1"
|
||||
"langchain-openai==1.1.14"
|
||||
]
|
||||
@@ -5,7 +5,7 @@ description = "Test for prerelease stuff"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"langchain-openai==1.0.0a2",
|
||||
"langchain-openai==1.1.14",
|
||||
"langchain-anthropic==1.0.0a5",
|
||||
"langgraph==1.1.5"
|
||||
]
|
||||
|
||||
@@ -5,7 +5,7 @@ description = "Test for prerelease stuff"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"langchain-openai==1.0.0a2",
|
||||
"langchain-openai==1.1.14",
|
||||
"langgraph==1.1.2",
|
||||
"langchain_community>=0.3.0",
|
||||
]
|
||||
@@ -1,3 +0,0 @@
|
||||
module github.com/langchain-ai/langgraph/libs/cli
|
||||
|
||||
go 1.23.0
|
||||
@@ -1,59 +0,0 @@
|
||||
"""Hatch build hook that bundles the platform-specific Go binary into the wheel.
|
||||
|
||||
Usage:
|
||||
1. Cross-compile: make build-go-target GOOS=linux GOARCH=amd64
|
||||
2. Set LANGGRAPH_GO_BINARY to the built binary path
|
||||
3. Build wheel: uv build --wheel
|
||||
|
||||
The hook copies the binary into langgraph_cli/bin/ so the entrypoint can find it.
|
||||
If LANGGRAPH_GO_BINARY is not set, the wheel is built without a binary (pure Python
|
||||
fallback — fine for development and the legacy code path).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
from pathlib import Path
|
||||
|
||||
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
|
||||
|
||||
|
||||
class GoBinaryBuildHook(BuildHookInterface):
|
||||
PLUGIN_NAME = "go-binary"
|
||||
|
||||
def initialize(self, version: str, build_data: dict) -> None:
|
||||
bin_dir = Path("langgraph_cli/bin")
|
||||
if bin_dir.exists():
|
||||
shutil.rmtree(bin_dir)
|
||||
|
||||
binary_path = os.environ.get("LANGGRAPH_GO_BINARY")
|
||||
if not binary_path:
|
||||
return
|
||||
|
||||
source = Path(binary_path)
|
||||
if not source.is_file():
|
||||
msg = f"LANGGRAPH_GO_BINARY points to missing file: {source}"
|
||||
raise FileNotFoundError(msg)
|
||||
|
||||
bin_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Determine output name (langgraph or langgraph.exe)
|
||||
dest_name = "langgraph.exe" if source.suffix == ".exe" else "langgraph"
|
||||
dest = bin_dir / dest_name
|
||||
|
||||
shutil.copy2(str(source), str(dest))
|
||||
# Ensure executable permission
|
||||
dest.chmod(dest.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||
|
||||
# Tell hatch to include the binary in the wheel
|
||||
build_data["shared_data"] = {}
|
||||
build_data["force_include"] = {
|
||||
str(dest): f"langgraph_cli/bin/{dest_name}",
|
||||
}
|
||||
|
||||
# Set the platform tag so pip installs the right wheel
|
||||
platform_tag = os.environ.get("LANGGRAPH_WHEEL_PLAT")
|
||||
if platform_tag:
|
||||
build_data["tag"] = f"py3-none-{platform_tag}"
|
||||
@@ -1,698 +0,0 @@
|
||||
// Package config provides validation for langgraph.json configuration files.
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
MinNodeVersion = "20"
|
||||
DefaultNodeVersion = "20"
|
||||
MinPythonVersion = "3.11"
|
||||
DefaultPythonVersion = "3.11"
|
||||
DefaultImageDistro = "debian"
|
||||
)
|
||||
|
||||
var validDistros = []string{"debian", "wolfi", "bookworm"}
|
||||
|
||||
var knownConfigKeys = map[string]bool{
|
||||
"python_version": true,
|
||||
"node_version": true,
|
||||
"api_version": true,
|
||||
"base_image": true,
|
||||
"image_distro": true,
|
||||
"pip_config_file": true,
|
||||
"pip_installer": true,
|
||||
"source": true,
|
||||
"dependencies": true,
|
||||
"dockerfile_lines": true,
|
||||
"graphs": true,
|
||||
"env": true,
|
||||
"store": true,
|
||||
"auth": true,
|
||||
"encryption": true,
|
||||
"http": true,
|
||||
"webhooks": true,
|
||||
"checkpointer": true,
|
||||
"ui": true,
|
||||
"ui_config": true,
|
||||
"keep_pkg_tools": true,
|
||||
"_INTERNAL_docker_tag": true,
|
||||
"project_root": true,
|
||||
"package": true,
|
||||
}
|
||||
|
||||
var nodeExtensions = map[string]bool{
|
||||
".ts": true, ".mts": true, ".cts": true,
|
||||
".js": true, ".mjs": true, ".cjs": true,
|
||||
}
|
||||
|
||||
// isNodeGraph checks whether a graph spec refers to a Node.js file.
|
||||
func isNodeGraph(spec any) bool {
|
||||
var filePath string
|
||||
switch v := spec.(type) {
|
||||
case string:
|
||||
filePath = strings.SplitN(v, ":", 2)[0]
|
||||
case map[string]any:
|
||||
if p, _ := v["path"].(string); p != "" {
|
||||
filePath = strings.SplitN(p, ":", 2)[0]
|
||||
}
|
||||
}
|
||||
return nodeExtensions[filepath.Ext(filePath)]
|
||||
}
|
||||
|
||||
// getSourceKind extracts source.kind from a raw config.
|
||||
func getSourceKind(raw map[string]any) string {
|
||||
source, ok := raw["source"]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
m, ok := source.(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
kind, _ := m["kind"].(string)
|
||||
return kind
|
||||
}
|
||||
|
||||
// getString returns the string value for key, or "" if missing/wrong type.
|
||||
func getString(raw map[string]any, key string) string {
|
||||
v, _ := raw[key].(string)
|
||||
return v
|
||||
}
|
||||
|
||||
// parseVersion parses "3.11" or "0.8.1" into integer parts.
|
||||
func parseVersion(s string) ([]int, error) {
|
||||
s = strings.SplitN(s, "-", 2)[0]
|
||||
parts := strings.Split(s, ".")
|
||||
result := make([]int, len(parts))
|
||||
for i, p := range parts {
|
||||
n, err := strconv.Atoi(p)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid version part: %s", p)
|
||||
}
|
||||
result[i] = n
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// versionLessThan returns true if a < b (component-wise).
|
||||
func versionLessThan(a, b []int) bool {
|
||||
for i := 0; i < len(a) && i < len(b); i++ {
|
||||
if a[i] < b[i] {
|
||||
return true
|
||||
}
|
||||
if a[i] > b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return len(a) < len(b)
|
||||
}
|
||||
|
||||
// ValidateConfig validates a raw config map and returns a normalised copy.
|
||||
// Errors match the Python CLI's click.UsageError messages exactly.
|
||||
func ValidateConfig(raw map[string]any) (map[string]any, error) {
|
||||
// --- detect graph types ---
|
||||
graphs, _ := raw["graphs"].(map[string]any)
|
||||
hasNode, hasPython := false, false
|
||||
for _, spec := range graphs {
|
||||
if isNodeGraph(spec) {
|
||||
hasNode = true
|
||||
} else {
|
||||
hasPython = true
|
||||
}
|
||||
}
|
||||
|
||||
// --- version defaults ---
|
||||
nodeVersion := getString(raw, "node_version")
|
||||
pythonVersion := getString(raw, "python_version")
|
||||
|
||||
if hasNode && nodeVersion == "" {
|
||||
nodeVersion = DefaultNodeVersion
|
||||
}
|
||||
if hasPython && pythonVersion == "" {
|
||||
pythonVersion = DefaultPythonVersion
|
||||
}
|
||||
|
||||
imageDistro := getString(raw, "image_distro")
|
||||
if imageDistro == "" {
|
||||
imageDistro = DefaultImageDistro
|
||||
}
|
||||
|
||||
// --- mutual exclusion: _INTERNAL_docker_tag vs api_version ---
|
||||
_, hasInternalTag := raw["_INTERNAL_docker_tag"]
|
||||
_, hasAPIVersion := raw["api_version"]
|
||||
if hasInternalTag && hasAPIVersion {
|
||||
return nil, fmt.Errorf("Cannot specify both _INTERNAL_docker_tag and api_version.")
|
||||
}
|
||||
|
||||
// --- api_version format ---
|
||||
if apiVersion := getString(raw, "api_version"); apiVersion != "" {
|
||||
base := strings.SplitN(apiVersion, "-", 2)[0]
|
||||
parts := strings.Split(base, ".")
|
||||
if len(parts) > 3 {
|
||||
return nil, fmt.Errorf("Version must be major or major.minor or major.minor.patch.")
|
||||
}
|
||||
for _, p := range parts {
|
||||
if _, err := strconv.Atoi(p); err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"Invalid version format: %s.\n\n"+
|
||||
"Pin to a minor version, e.g.:\n"+
|
||||
" \"api_version\": \"0.8\"", apiVersion)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- build result config with defaults ---
|
||||
config := map[string]any{
|
||||
"node_version": nodeVersion,
|
||||
"python_version": pythonVersion,
|
||||
"pip_config_file": raw["pip_config_file"],
|
||||
"pip_installer": "auto",
|
||||
"source": raw["source"],
|
||||
"base_image": raw["base_image"],
|
||||
"image_distro": imageDistro,
|
||||
"dependencies": raw["dependencies"],
|
||||
"dockerfile_lines": raw["dockerfile_lines"],
|
||||
"graphs": raw["graphs"],
|
||||
"env": raw["env"],
|
||||
"store": raw["store"],
|
||||
"auth": raw["auth"],
|
||||
"encryption": raw["encryption"],
|
||||
"http": raw["http"],
|
||||
"webhooks": raw["webhooks"],
|
||||
"checkpointer": raw["checkpointer"],
|
||||
"ui": raw["ui"],
|
||||
"ui_config": raw["ui_config"],
|
||||
"keep_pkg_tools": raw["keep_pkg_tools"],
|
||||
}
|
||||
if raw["pip_installer"] != nil {
|
||||
config["pip_installer"] = raw["pip_installer"]
|
||||
}
|
||||
if hasInternalTag {
|
||||
config["_INTERNAL_docker_tag"] = raw["_INTERNAL_docker_tag"]
|
||||
}
|
||||
if hasAPIVersion {
|
||||
config["api_version"] = raw["api_version"]
|
||||
}
|
||||
|
||||
// Apply list defaults.
|
||||
if config["dependencies"] == nil {
|
||||
config["dependencies"] = []any{}
|
||||
}
|
||||
if config["dockerfile_lines"] == nil {
|
||||
config["dockerfile_lines"] = []any{}
|
||||
}
|
||||
if config["graphs"] == nil {
|
||||
config["graphs"] = map[string]any{}
|
||||
}
|
||||
if config["env"] == nil {
|
||||
config["env"] = map[string]any{}
|
||||
}
|
||||
|
||||
// --- node_version validation ---
|
||||
if nodeVersion != "" {
|
||||
if strings.Contains(nodeVersion, ".") {
|
||||
return nil, fmt.Errorf("Node.js version must be major version only")
|
||||
}
|
||||
major, err := strconv.Atoi(nodeVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"Invalid Node.js version format: %s. Use major version only (e.g., '20').",
|
||||
nodeVersion)
|
||||
}
|
||||
minMajor, _ := strconv.Atoi(MinNodeVersion)
|
||||
if major < minMajor {
|
||||
return nil, fmt.Errorf(
|
||||
"Node.js version %s is not supported. "+
|
||||
"Minimum required version is %s.\n\n"+
|
||||
"Set node_version to %s or higher:\n"+
|
||||
" \"node_version\": \"%s\"",
|
||||
nodeVersion, MinNodeVersion, MinNodeVersion, MinNodeVersion)
|
||||
}
|
||||
}
|
||||
|
||||
// --- pip_installer validation ---
|
||||
if pi, ok := raw["pip_installer"].(string); ok {
|
||||
switch pi {
|
||||
case "auto", "pip", "uv":
|
||||
// valid
|
||||
default:
|
||||
return nil, fmt.Errorf(
|
||||
"Invalid pip_installer: '%s'. "+
|
||||
"Consider using uv-based source management instead:\n\n"+
|
||||
" \"source\": {\"kind\": \"uv\", \"root\": \"..\"}",
|
||||
pi)
|
||||
}
|
||||
}
|
||||
|
||||
// --- source validation ---
|
||||
sourceKind := getSourceKind(raw)
|
||||
if source := raw["source"]; source != nil {
|
||||
if _, ok := source.(map[string]any); !ok {
|
||||
return nil, fmt.Errorf(
|
||||
"`source` must be an object, e.g.:\n" +
|
||||
" \"source\": {\"kind\": \"uv\", \"root\": \"..\"}")
|
||||
}
|
||||
if sourceKind != "uv" {
|
||||
return nil, fmt.Errorf(
|
||||
"Invalid source.kind. The only supported value is 'uv':\n" +
|
||||
" \"source\": {\"kind\": \"uv\", \"root\": \"..\"}")
|
||||
}
|
||||
}
|
||||
|
||||
// --- python_version validation ---
|
||||
if pythonVersion != "" {
|
||||
base := strings.SplitN(pythonVersion, "-", 2)[0]
|
||||
dotParts := strings.Split(base, ".")
|
||||
allDigits := true
|
||||
for _, p := range dotParts {
|
||||
if _, err := strconv.Atoi(p); err != nil {
|
||||
allDigits = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(dotParts) != 2 || !allDigits {
|
||||
fix := MinPythonVersion
|
||||
if len(dotParts) >= 2 {
|
||||
fix = dotParts[0] + "." + dotParts[1]
|
||||
}
|
||||
return nil, fmt.Errorf(
|
||||
"Invalid Python version format: %s. "+
|
||||
"Use 'major.minor' format — patch version cannot be specified.\n\n"+
|
||||
" \"python_version\": \"%s\"",
|
||||
pythonVersion, fix)
|
||||
}
|
||||
pyParsed, _ := parseVersion(pythonVersion)
|
||||
minParsed, _ := parseVersion(MinPythonVersion)
|
||||
if versionLessThan(pyParsed, minParsed) {
|
||||
return nil, fmt.Errorf(
|
||||
"Python version %s is not supported. "+
|
||||
"Minimum required version is %s.\n\n"+
|
||||
" \"python_version\": \"%s\"",
|
||||
pythonVersion, MinPythonVersion, MinPythonVersion)
|
||||
}
|
||||
if strings.Contains(pythonVersion, "bullseye") {
|
||||
return nil, fmt.Errorf(
|
||||
"Bullseye images were deprecated in version 0.4.13. " +
|
||||
"Please use 'bookworm' or 'debian' instead.")
|
||||
}
|
||||
|
||||
// dependencies required when not uv
|
||||
deps, _ := config["dependencies"].([]any)
|
||||
if sourceKind != "uv" && len(deps) == 0 {
|
||||
return nil, fmt.Errorf(
|
||||
"No dependencies found in config. " +
|
||||
"Consider using uv-based source management:\n\n" +
|
||||
" \"source\": {\"kind\": \"uv\", \"root\": \"..\"}")
|
||||
}
|
||||
}
|
||||
|
||||
// --- graphs required ---
|
||||
graphMap, _ := config["graphs"].(map[string]any)
|
||||
if len(graphMap) == 0 {
|
||||
return nil, fmt.Errorf(
|
||||
"No graphs found in config. Add at least one graph, e.g.:\n" +
|
||||
" \"graphs\": {\n" +
|
||||
" \"agent\": \"./my_agent/graph.py:graph\"\n" +
|
||||
" }")
|
||||
}
|
||||
|
||||
// --- image_distro validation ---
|
||||
if imageDistro == "bullseye" {
|
||||
return nil, fmt.Errorf(
|
||||
"Bullseye images were deprecated in version 0.4.13. " +
|
||||
"Please use 'bookworm' or 'debian' instead.")
|
||||
}
|
||||
validDistro := false
|
||||
for _, d := range validDistros {
|
||||
if imageDistro == d {
|
||||
validDistro = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !validDistro {
|
||||
quoted := make([]string, len(validDistros))
|
||||
for i, d := range validDistros {
|
||||
quoted[i] = fmt.Sprintf("'%s'", d)
|
||||
}
|
||||
return nil, fmt.Errorf(
|
||||
"Invalid image_distro: '%s'. "+
|
||||
"Must be one of: %s.\n\n"+
|
||||
" \"image_distro\": \"wolfi\" (recommended)",
|
||||
imageDistro, strings.Join(quoted, ", "))
|
||||
}
|
||||
|
||||
// --- uv source mode validation ---
|
||||
if sourceKind == "uv" {
|
||||
var errs []string
|
||||
if pythonVersion == "" {
|
||||
errs = append(errs, "source.kind 'uv' requires `python_version` — it is a Python-only deployment mode. Node.js-only graphs are not supported.")
|
||||
}
|
||||
|
||||
deps, _ := raw["dependencies"].([]any)
|
||||
if deps != nil && len(deps) > 0 {
|
||||
errs = append(errs, "Remove `dependencies` from your config. With `source.kind = \"uv\"`, all dependencies are read from your pyproject.toml and uv.lock instead.")
|
||||
}
|
||||
// Also check if dependencies key exists even if empty array.
|
||||
if deps == nil {
|
||||
if rawDeps, exists := raw["dependencies"]; exists && rawDeps != nil {
|
||||
// dependencies key present but not an array — still flag it
|
||||
if depsArr, ok := rawDeps.([]any); ok && len(depsArr) > 0 {
|
||||
errs = append(errs, "Remove `dependencies` from your config. With `source.kind = \"uv\"`, all dependencies are read from your pyproject.toml and uv.lock instead.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sourceMap, _ := raw["source"].(map[string]any)
|
||||
if root, exists := sourceMap["root"]; exists {
|
||||
rootStr, ok := root.(string)
|
||||
if !ok {
|
||||
errs = append(errs, fmt.Sprintf("`source.root` must be a string, got %T.", root))
|
||||
} else if rootStr == "" {
|
||||
errs = append(errs, "`source.root` must be a non-empty string. Use `\".\"`.")
|
||||
}
|
||||
}
|
||||
|
||||
if pkg, exists := sourceMap["package"]; exists {
|
||||
if pkg != nil {
|
||||
pkgStr, ok := pkg.(string)
|
||||
if !ok {
|
||||
errs = append(errs, "`source.package` must be a non-empty string.")
|
||||
} else if pkgStr == "" {
|
||||
errs = append(errs, "`source.package` must be a non-empty string.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
formatted := ""
|
||||
for i, e := range errs {
|
||||
formatted += fmt.Sprintf("\n %d. %s", i+1, e)
|
||||
}
|
||||
return nil, fmt.Errorf(
|
||||
"source.kind 'uv' requires a different config shape than dependency-based installs:%s",
|
||||
formatted)
|
||||
}
|
||||
}
|
||||
|
||||
// --- legacy project_root / package ---
|
||||
_, hasProjectRoot := raw["project_root"]
|
||||
_, hasPackage := raw["package"]
|
||||
if hasProjectRoot || hasPackage {
|
||||
return nil, fmt.Errorf(
|
||||
"Top-level `project_root` and `package` are no longer supported. " +
|
||||
"Use `source.root` and `source.package` instead.")
|
||||
}
|
||||
|
||||
// --- auth path validation ---
|
||||
if auth, ok := raw["auth"].(map[string]any); ok {
|
||||
if authPath, _ := auth["path"].(string); authPath != "" {
|
||||
if !strings.Contains(authPath, ":") {
|
||||
return nil, fmt.Errorf(
|
||||
"Invalid auth.path format: '%s'. "+
|
||||
"Must be in format './path/to/file.py:attribute_name'",
|
||||
authPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- encryption path validation ---
|
||||
if enc, ok := raw["encryption"].(map[string]any); ok {
|
||||
if encPath, _ := enc["path"].(string); encPath != "" {
|
||||
if !strings.Contains(encPath, ":") {
|
||||
return nil, fmt.Errorf(
|
||||
"Invalid encryption.path format: '%s'. "+
|
||||
"Must be in format './path/to/file.py:attribute_name'",
|
||||
encPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- http.app path validation ---
|
||||
if httpConf, ok := raw["http"].(map[string]any); ok {
|
||||
if app, _ := httpConf["app"].(string); app != "" {
|
||||
if !strings.Contains(app, ":") {
|
||||
return nil, fmt.Errorf(
|
||||
"Invalid http.app format: '%s'. "+
|
||||
"Must be in format './path/to/file.py:attribute_name'",
|
||||
app)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- keep_pkg_tools validation ---
|
||||
if kpt := raw["keep_pkg_tools"]; kpt != nil {
|
||||
validBuildTools := map[string]bool{"pip": true, "setuptools": true, "wheel": true}
|
||||
switch v := kpt.(type) {
|
||||
case bool:
|
||||
// ok
|
||||
case []any:
|
||||
for _, item := range v {
|
||||
tool, ok := item.(string)
|
||||
if !ok || !validBuildTools[tool] {
|
||||
return nil, fmt.Errorf(
|
||||
"Invalid keep_pkg_tools: '%v'. "+
|
||||
"Must be one of 'pip', 'setuptools', 'wheel'.",
|
||||
item)
|
||||
}
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf(
|
||||
"Invalid keep_pkg_tools: '%v'. "+
|
||||
"Must be bool or list[str] (with values 'pip', 'setuptools', and/or 'wheel').",
|
||||
kpt)
|
||||
}
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// ValidateConfigFile loads a config file, validates it, and returns the result.
|
||||
func ValidateConfigFile(configPath string) (map[string]any, error) {
|
||||
raw, err := LoadRawConfigFile(configPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return validateConfigFile(configPath, raw)
|
||||
}
|
||||
|
||||
// LoadRawConfigFile loads a config file and requires the top-level JSON value
|
||||
// to be an object.
|
||||
func LoadRawConfigFile(configPath string) (map[string]any, error) {
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not read config file: %w", err)
|
||||
}
|
||||
|
||||
var rawAny any
|
||||
if err := json.Unmarshal(data, &rawAny); err != nil {
|
||||
return nil, fmt.Errorf("Invalid JSON in %s: %s", configPath, err.Error())
|
||||
}
|
||||
|
||||
raw, ok := rawAny.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf(
|
||||
"Invalid config in %s: top-level JSON value must be an object.",
|
||||
configPath,
|
||||
)
|
||||
}
|
||||
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func validateConfigFile(configPath string, raw map[string]any) (map[string]any, error) {
|
||||
validated, err := ValidateConfig(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check package.json node version if node_version is set.
|
||||
if nv, _ := validated["node_version"].(string); nv != "" {
|
||||
dir := filepath.Dir(configPath)
|
||||
pkgJSONPath := filepath.Join(dir, "package.json")
|
||||
if info, statErr := os.Stat(pkgJSONPath); statErr == nil && !info.IsDir() {
|
||||
if pkgErr := validatePackageJSON(pkgJSONPath); pkgErr != nil {
|
||||
return nil, pkgErr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return validated, nil
|
||||
}
|
||||
|
||||
func validatePackageJSON(path string) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var pkg map[string]any
|
||||
if err := json.Unmarshal(data, &pkg); err != nil {
|
||||
return fmt.Errorf(
|
||||
"Invalid package.json found in langgraph config directory %s: file is not valid JSON",
|
||||
path,
|
||||
)
|
||||
}
|
||||
|
||||
enginesRaw, ok := pkg["engines"]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
engines, ok := enginesRaw.(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
for k := range engines {
|
||||
if k != "node" {
|
||||
keys := make([]string, 0, len(engines))
|
||||
for ek := range engines {
|
||||
keys = append(keys, ek)
|
||||
}
|
||||
return fmt.Errorf(
|
||||
"Only 'node' engine is supported in package.json engines. Got engines: %v",
|
||||
keys)
|
||||
}
|
||||
}
|
||||
|
||||
if nodeVer, ok := engines["node"].(string); ok && nodeVer != "" {
|
||||
if strings.Contains(nodeVer, ".") {
|
||||
return fmt.Errorf(
|
||||
"Node.js version in package.json engines must be >= %s "+
|
||||
"(major version only), got '%s'. "+
|
||||
"Minor/patch versions (like '20.x.y') are not supported to "+
|
||||
"prevent deployment issues when new Node.js versions are released.",
|
||||
MinNodeVersion, nodeVer)
|
||||
}
|
||||
major, err := strconv.Atoi(nodeVer)
|
||||
if err == nil {
|
||||
minMajor, _ := strconv.Atoi(MinNodeVersion)
|
||||
if major < minMajor {
|
||||
return fmt.Errorf(
|
||||
"Node.js version in package.json engines must be >= %s "+
|
||||
"(major version only), got '%s'. "+
|
||||
"Minor/patch versions (like '20.x.y') are not supported to "+
|
||||
"prevent deployment issues when new Node.js versions are released.",
|
||||
MinNodeVersion, nodeVer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUnknownKeys returns warnings for unrecognised top-level keys.
|
||||
func GetUnknownKeys(raw map[string]any) []string {
|
||||
var unknown []string
|
||||
for k := range raw {
|
||||
if !knownConfigKeys[k] {
|
||||
unknown = append(unknown, k)
|
||||
}
|
||||
}
|
||||
sortStrings(unknown)
|
||||
|
||||
var warnings []string
|
||||
knownList := make([]string, 0, len(knownConfigKeys))
|
||||
for k := range knownConfigKeys {
|
||||
knownList = append(knownList, k)
|
||||
}
|
||||
|
||||
for _, key := range unknown {
|
||||
if close := closestMatch(key, knownList); close != "" {
|
||||
warnings = append(warnings, fmt.Sprintf("Unknown key '%s' — did you mean '%s'?", key, close))
|
||||
} else {
|
||||
warnings = append(warnings, fmt.Sprintf("Unknown key '%s' is not a recognized config field.", key))
|
||||
}
|
||||
}
|
||||
return warnings
|
||||
}
|
||||
|
||||
// closestMatch finds the best match for word among candidates using edit distance.
|
||||
// Returns "" if no match is close enough (ratio >= 0.6).
|
||||
func closestMatch(word string, candidates []string) string {
|
||||
best := ""
|
||||
bestRatio := 0.6 // minimum threshold
|
||||
for _, c := range candidates {
|
||||
ratio := similarity(word, c)
|
||||
if ratio > bestRatio {
|
||||
bestRatio = ratio
|
||||
best = c
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// similarity returns a ratio in [0,1] based on Levenshtein distance.
|
||||
func similarity(a, b string) float64 {
|
||||
maxLen := len(a)
|
||||
if len(b) > maxLen {
|
||||
maxLen = len(b)
|
||||
}
|
||||
if maxLen == 0 {
|
||||
return 1.0
|
||||
}
|
||||
dist := editDistance(a, b)
|
||||
return 1.0 - float64(dist)/float64(maxLen)
|
||||
}
|
||||
|
||||
// editDistance computes Levenshtein distance between two strings.
|
||||
func editDistance(a, b string) int {
|
||||
la, lb := len(a), len(b)
|
||||
if la == 0 {
|
||||
return lb
|
||||
}
|
||||
if lb == 0 {
|
||||
return la
|
||||
}
|
||||
|
||||
prev := make([]int, lb+1)
|
||||
curr := make([]int, lb+1)
|
||||
|
||||
for j := 0; j <= lb; j++ {
|
||||
prev[j] = j
|
||||
}
|
||||
for i := 1; i <= la; i++ {
|
||||
curr[0] = i
|
||||
for j := 1; j <= lb; j++ {
|
||||
cost := 1
|
||||
if a[i-1] == b[j-1] {
|
||||
cost = 0
|
||||
}
|
||||
ins := curr[j-1] + 1
|
||||
del := prev[j] + 1
|
||||
sub := prev[j-1] + cost
|
||||
curr[j] = min3(ins, del, sub)
|
||||
}
|
||||
prev, curr = curr, prev
|
||||
}
|
||||
return prev[lb]
|
||||
}
|
||||
|
||||
func min3(a, b, c int) int {
|
||||
if a < b {
|
||||
if a < c {
|
||||
return a
|
||||
}
|
||||
return c
|
||||
}
|
||||
if b < c {
|
||||
return b
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// sortStrings sorts a slice of strings in place (simple insertion sort, fine for small n).
|
||||
func sortStrings(s []string) {
|
||||
for i := 1; i < len(s); i++ {
|
||||
for j := i; j > 0 && s[j] < s[j-1]; j-- {
|
||||
s[j], s[j-1] = s[j-1], s[j]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,568 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// baseConfig returns a minimal valid config map. Tests should copy and modify it.
|
||||
func baseConfig() map[string]any {
|
||||
return map[string]any{
|
||||
"dependencies": []any{"langchain"},
|
||||
"graphs": map[string]any{"agent": "./agent.py:graph"},
|
||||
}
|
||||
}
|
||||
|
||||
// copyMap returns a shallow copy of m.
|
||||
func copyMap(m map[string]any) map[string]any {
|
||||
out := make(map[string]any, len(m))
|
||||
for k, v := range m {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mustSucceed is a test helper that fails if err is non-nil.
|
||||
func mustSucceed(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Fatalf("expected success but got error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// mustFail is a test helper that fails if err is nil.
|
||||
func mustFail(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected error but got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// mustContain checks that err is non-nil and its message contains substr.
|
||||
func mustContain(t *testing.T, err error, substr string) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q but got nil", substr)
|
||||
}
|
||||
if !strings.Contains(err.Error(), substr) {
|
||||
t.Fatalf("expected error to contain %q, got: %s", substr, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfigValid(t *testing.T) {
|
||||
t.Run("minimal config", func(t *testing.T) {
|
||||
raw := baseConfig()
|
||||
result, err := ValidateConfig(raw)
|
||||
mustSucceed(t, err)
|
||||
|
||||
if pv, _ := result["python_version"].(string); pv != "3.11" {
|
||||
t.Fatalf("expected python_version '3.11', got %q", pv)
|
||||
}
|
||||
if id, _ := result["image_distro"].(string); id != "debian" {
|
||||
t.Fatalf("expected image_distro 'debian', got %q", id)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("full config with all optional fields", func(t *testing.T) {
|
||||
raw := map[string]any{
|
||||
"python_version": "3.12",
|
||||
"image_distro": "wolfi",
|
||||
"pip_installer": "uv",
|
||||
"dependencies": []any{"langchain", "langgraph"},
|
||||
"graphs": map[string]any{"agent": "./agent.py:graph"},
|
||||
"env": map[string]any{"FOO": "bar"},
|
||||
"dockerfile_lines": []any{"RUN apt-get update"},
|
||||
"auth": map[string]any{"path": "./auth.py:handler"},
|
||||
"encryption": map[string]any{"path": "./enc.py:enc"},
|
||||
"http": map[string]any{"app": "./app.py:app"},
|
||||
"keep_pkg_tools": true,
|
||||
"api_version": "0.8",
|
||||
}
|
||||
_, err := ValidateConfig(raw)
|
||||
mustSucceed(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateConfigPythonVersion(t *testing.T) {
|
||||
validVersions := []string{"3.11", "3.12", "3.13"}
|
||||
for _, v := range validVersions {
|
||||
t.Run("valid "+v, func(t *testing.T) {
|
||||
raw := copyMap(baseConfig())
|
||||
raw["python_version"] = v
|
||||
_, err := ValidateConfig(raw)
|
||||
mustSucceed(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("valid 3.12-slim suffix stripped", func(t *testing.T) {
|
||||
raw := copyMap(baseConfig())
|
||||
raw["python_version"] = "3.12-slim"
|
||||
_, err := ValidateConfig(raw)
|
||||
mustSucceed(t, err)
|
||||
})
|
||||
|
||||
tooOld := []string{"3.10", "3.9"}
|
||||
for _, v := range tooOld {
|
||||
t.Run("too old "+v, func(t *testing.T) {
|
||||
raw := copyMap(baseConfig())
|
||||
raw["python_version"] = v
|
||||
_, err := ValidateConfig(raw)
|
||||
mustContain(t, err, "Minimum required version")
|
||||
})
|
||||
}
|
||||
|
||||
badFormat := []struct {
|
||||
version string
|
||||
}{
|
||||
{"3.11.0"},
|
||||
{"3"},
|
||||
{"abc.def"},
|
||||
}
|
||||
for _, tc := range badFormat {
|
||||
t.Run("bad format "+tc.version, func(t *testing.T) {
|
||||
raw := copyMap(baseConfig())
|
||||
raw["python_version"] = tc.version
|
||||
_, err := ValidateConfig(raw)
|
||||
mustContain(t, err, "Invalid Python version format")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfigNodeVersion(t *testing.T) {
|
||||
// Need a node graph to trigger node_version validation
|
||||
nodeBase := func() map[string]any {
|
||||
return map[string]any{
|
||||
"dependencies": []any{"langchain"},
|
||||
"graphs": map[string]any{"agent": "./agent.py:graph"},
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("valid 20", func(t *testing.T) {
|
||||
raw := nodeBase()
|
||||
raw["node_version"] = "20"
|
||||
_, err := ValidateConfig(raw)
|
||||
mustSucceed(t, err)
|
||||
})
|
||||
|
||||
t.Run("valid 22", func(t *testing.T) {
|
||||
raw := nodeBase()
|
||||
raw["node_version"] = "22"
|
||||
_, err := ValidateConfig(raw)
|
||||
mustSucceed(t, err)
|
||||
})
|
||||
|
||||
t.Run("too old 18", func(t *testing.T) {
|
||||
raw := nodeBase()
|
||||
raw["node_version"] = "18"
|
||||
_, err := ValidateConfig(raw)
|
||||
mustContain(t, err, "Minimum required version is 20")
|
||||
})
|
||||
|
||||
t.Run("minor version 20.1", func(t *testing.T) {
|
||||
raw := nodeBase()
|
||||
raw["node_version"] = "20.1"
|
||||
_, err := ValidateConfig(raw)
|
||||
mustContain(t, err, "major version only")
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateConfigGraphs(t *testing.T) {
|
||||
t.Run("empty graphs", func(t *testing.T) {
|
||||
raw := copyMap(baseConfig())
|
||||
raw["graphs"] = map[string]any{}
|
||||
_, err := ValidateConfig(raw)
|
||||
mustContain(t, err, "No graphs found")
|
||||
})
|
||||
|
||||
t.Run("missing graphs key", func(t *testing.T) {
|
||||
raw := map[string]any{
|
||||
"dependencies": []any{"langchain"},
|
||||
}
|
||||
_, err := ValidateConfig(raw)
|
||||
mustContain(t, err, "No graphs found")
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateConfigImageDistro(t *testing.T) {
|
||||
validDistroTests := []string{"debian", "wolfi", "bookworm"}
|
||||
for _, d := range validDistroTests {
|
||||
t.Run("valid "+d, func(t *testing.T) {
|
||||
raw := copyMap(baseConfig())
|
||||
raw["image_distro"] = d
|
||||
_, err := ValidateConfig(raw)
|
||||
mustSucceed(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("bullseye deprecated", func(t *testing.T) {
|
||||
raw := copyMap(baseConfig())
|
||||
raw["image_distro"] = "bullseye"
|
||||
_, err := ValidateConfig(raw)
|
||||
mustContain(t, err, "deprecated")
|
||||
})
|
||||
|
||||
t.Run("invalid ubuntu", func(t *testing.T) {
|
||||
raw := copyMap(baseConfig())
|
||||
raw["image_distro"] = "ubuntu"
|
||||
_, err := ValidateConfig(raw)
|
||||
mustContain(t, err, "Invalid image_distro")
|
||||
})
|
||||
|
||||
t.Run("invalid alpine", func(t *testing.T) {
|
||||
raw := copyMap(baseConfig())
|
||||
raw["image_distro"] = "alpine"
|
||||
_, err := ValidateConfig(raw)
|
||||
mustContain(t, err, "Invalid image_distro")
|
||||
})
|
||||
|
||||
t.Run("default is debian", func(t *testing.T) {
|
||||
raw := baseConfig()
|
||||
// no image_distro key
|
||||
result, err := ValidateConfig(raw)
|
||||
mustSucceed(t, err)
|
||||
if id, _ := result["image_distro"].(string); id != "debian" {
|
||||
t.Fatalf("expected default image_distro 'debian', got %q", id)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateConfigPipInstaller(t *testing.T) {
|
||||
valid := []string{"auto", "pip", "uv"}
|
||||
for _, pi := range valid {
|
||||
t.Run("valid "+pi, func(t *testing.T) {
|
||||
raw := copyMap(baseConfig())
|
||||
raw["pip_installer"] = pi
|
||||
_, err := ValidateConfig(raw)
|
||||
mustSucceed(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
invalid := []string{"conda", "uv_lock"}
|
||||
for _, pi := range invalid {
|
||||
t.Run("invalid "+pi, func(t *testing.T) {
|
||||
raw := copyMap(baseConfig())
|
||||
raw["pip_installer"] = pi
|
||||
_, err := ValidateConfig(raw)
|
||||
mustContain(t, err, "Invalid pip_installer")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfigSource(t *testing.T) {
|
||||
t.Run("valid uv source with root", func(t *testing.T) {
|
||||
raw := map[string]any{
|
||||
"python_version": "3.12",
|
||||
"graphs": map[string]any{"agent": "./agent.py:graph"},
|
||||
"source": map[string]any{"kind": "uv", "root": "../.."},
|
||||
}
|
||||
_, err := ValidateConfig(raw)
|
||||
mustSucceed(t, err)
|
||||
})
|
||||
|
||||
t.Run("invalid source kind poetry", func(t *testing.T) {
|
||||
raw := map[string]any{
|
||||
"python_version": "3.12",
|
||||
"graphs": map[string]any{"agent": "./agent.py:graph"},
|
||||
"source": map[string]any{"kind": "poetry"},
|
||||
}
|
||||
_, err := ValidateConfig(raw)
|
||||
mustContain(t, err, "Invalid source.kind")
|
||||
})
|
||||
|
||||
t.Run("source as string not object", func(t *testing.T) {
|
||||
raw := copyMap(baseConfig())
|
||||
raw["source"] = "not-an-object"
|
||||
_, err := ValidateConfig(raw)
|
||||
mustContain(t, err, "`source` must be an object")
|
||||
})
|
||||
|
||||
t.Run("uv source with dependencies", func(t *testing.T) {
|
||||
raw := map[string]any{
|
||||
"python_version": "3.12",
|
||||
"graphs": map[string]any{"agent": "./agent.py:graph"},
|
||||
"source": map[string]any{"kind": "uv", "root": ".."},
|
||||
"dependencies": []any{"langchain"},
|
||||
}
|
||||
_, err := ValidateConfig(raw)
|
||||
mustContain(t, err, "Remove `dependencies`")
|
||||
})
|
||||
|
||||
t.Run("uv source with root as number", func(t *testing.T) {
|
||||
raw := map[string]any{
|
||||
"python_version": "3.12",
|
||||
"graphs": map[string]any{"agent": "./agent.py:graph"},
|
||||
"source": map[string]any{"kind": "uv", "root": 123},
|
||||
}
|
||||
_, err := ValidateConfig(raw)
|
||||
mustContain(t, err, "source.root` must be a string")
|
||||
})
|
||||
|
||||
t.Run("uv source with package as number", func(t *testing.T) {
|
||||
raw := map[string]any{
|
||||
"python_version": "3.12",
|
||||
"graphs": map[string]any{"agent": "./agent.py:graph"},
|
||||
"source": map[string]any{"kind": "uv", "root": "..", "package": 123},
|
||||
}
|
||||
_, err := ValidateConfig(raw)
|
||||
mustContain(t, err, "source.package` must be a non-empty string")
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateConfigAPIVersion(t *testing.T) {
|
||||
t.Run("valid 0.8", func(t *testing.T) {
|
||||
raw := copyMap(baseConfig())
|
||||
raw["api_version"] = "0.8"
|
||||
_, err := ValidateConfig(raw)
|
||||
mustSucceed(t, err)
|
||||
})
|
||||
|
||||
t.Run("valid 0.8.1", func(t *testing.T) {
|
||||
raw := copyMap(baseConfig())
|
||||
raw["api_version"] = "0.8.1"
|
||||
_, err := ValidateConfig(raw)
|
||||
mustSucceed(t, err)
|
||||
})
|
||||
|
||||
t.Run("invalid abc", func(t *testing.T) {
|
||||
raw := copyMap(baseConfig())
|
||||
raw["api_version"] = "abc"
|
||||
_, err := ValidateConfig(raw)
|
||||
mustContain(t, err, "Invalid version format")
|
||||
})
|
||||
|
||||
t.Run("invalid 1.2.3.4 too many parts", func(t *testing.T) {
|
||||
raw := copyMap(baseConfig())
|
||||
raw["api_version"] = "1.2.3.4"
|
||||
_, err := ValidateConfig(raw)
|
||||
mustContain(t, err, "major or major.minor")
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateConfigMutualExclusion(t *testing.T) {
|
||||
t.Run("both _INTERNAL_docker_tag and api_version", func(t *testing.T) {
|
||||
raw := copyMap(baseConfig())
|
||||
raw["_INTERNAL_docker_tag"] = "some-tag"
|
||||
raw["api_version"] = "0.8"
|
||||
_, err := ValidateConfig(raw)
|
||||
mustContain(t, err, "Cannot specify both")
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateConfigAuthPath(t *testing.T) {
|
||||
t.Run("valid auth path with colon", func(t *testing.T) {
|
||||
raw := copyMap(baseConfig())
|
||||
raw["auth"] = map[string]any{"path": "./auth.py:handler"}
|
||||
_, err := ValidateConfig(raw)
|
||||
mustSucceed(t, err)
|
||||
})
|
||||
|
||||
t.Run("invalid auth path without colon", func(t *testing.T) {
|
||||
raw := copyMap(baseConfig())
|
||||
raw["auth"] = map[string]any{"path": "../../examples/my_app.py"}
|
||||
_, err := ValidateConfig(raw)
|
||||
mustContain(t, err, "Invalid auth.path format")
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateConfigEncryptionPath(t *testing.T) {
|
||||
t.Run("valid encryption path with colon", func(t *testing.T) {
|
||||
raw := copyMap(baseConfig())
|
||||
raw["encryption"] = map[string]any{"path": "./enc.py:enc"}
|
||||
_, err := ValidateConfig(raw)
|
||||
mustSucceed(t, err)
|
||||
})
|
||||
|
||||
t.Run("invalid encryption path without colon", func(t *testing.T) {
|
||||
raw := copyMap(baseConfig())
|
||||
raw["encryption"] = map[string]any{"path": "./enc.py"}
|
||||
_, err := ValidateConfig(raw)
|
||||
mustContain(t, err, "Invalid encryption.path format")
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateConfigHTTPApp(t *testing.T) {
|
||||
t.Run("valid http app with colon", func(t *testing.T) {
|
||||
raw := copyMap(baseConfig())
|
||||
raw["http"] = map[string]any{"app": "./app.py:app"}
|
||||
_, err := ValidateConfig(raw)
|
||||
mustSucceed(t, err)
|
||||
})
|
||||
|
||||
t.Run("invalid http app without colon", func(t *testing.T) {
|
||||
raw := copyMap(baseConfig())
|
||||
raw["http"] = map[string]any{"app": "./app.py"}
|
||||
_, err := ValidateConfig(raw)
|
||||
mustContain(t, err, "Invalid http.app format")
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateConfigKeepPkgTools(t *testing.T) {
|
||||
t.Run("bool true", func(t *testing.T) {
|
||||
raw := copyMap(baseConfig())
|
||||
raw["keep_pkg_tools"] = true
|
||||
_, err := ValidateConfig(raw)
|
||||
mustSucceed(t, err)
|
||||
})
|
||||
|
||||
t.Run("valid list", func(t *testing.T) {
|
||||
raw := copyMap(baseConfig())
|
||||
raw["keep_pkg_tools"] = []any{"pip", "wheel"}
|
||||
_, err := ValidateConfig(raw)
|
||||
mustSucceed(t, err)
|
||||
})
|
||||
|
||||
t.Run("invalid list item", func(t *testing.T) {
|
||||
raw := copyMap(baseConfig())
|
||||
raw["keep_pkg_tools"] = []any{"invalid"}
|
||||
_, err := ValidateConfig(raw)
|
||||
mustContain(t, err, "Invalid keep_pkg_tools")
|
||||
})
|
||||
|
||||
t.Run("invalid string type", func(t *testing.T) {
|
||||
raw := copyMap(baseConfig())
|
||||
raw["keep_pkg_tools"] = "string"
|
||||
_, err := ValidateConfig(raw)
|
||||
mustContain(t, err, "Invalid keep_pkg_tools")
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateConfigLegacyKeys(t *testing.T) {
|
||||
t.Run("project_root legacy", func(t *testing.T) {
|
||||
raw := copyMap(baseConfig())
|
||||
raw["project_root"] = ".."
|
||||
_, err := ValidateConfig(raw)
|
||||
mustContain(t, err, "no longer supported")
|
||||
})
|
||||
|
||||
t.Run("package legacy", func(t *testing.T) {
|
||||
raw := copyMap(baseConfig())
|
||||
raw["package"] = "foo"
|
||||
_, err := ValidateConfig(raw)
|
||||
mustContain(t, err, "no longer supported")
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateConfigNodeGraphDetection(t *testing.T) {
|
||||
t.Run("ts extension auto-sets node_version", func(t *testing.T) {
|
||||
raw := map[string]any{
|
||||
"dependencies": []any{"langchain"},
|
||||
"graphs": map[string]any{"agent": "./agent.py:graph", "bot": "./bot.ts:bot"},
|
||||
}
|
||||
result, err := ValidateConfig(raw)
|
||||
mustSucceed(t, err)
|
||||
if nv, _ := result["node_version"].(string); nv != "20" {
|
||||
t.Fatalf("expected node_version '20', got %q", nv)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("js extension auto-sets node_version", func(t *testing.T) {
|
||||
raw := map[string]any{
|
||||
"dependencies": []any{"langchain"},
|
||||
"graphs": map[string]any{"agent": "./agent.py:graph", "bot": "./bot.js:bot"},
|
||||
}
|
||||
result, err := ValidateConfig(raw)
|
||||
mustSucceed(t, err)
|
||||
if nv, _ := result["node_version"].(string); nv != "20" {
|
||||
t.Fatalf("expected node_version '20', got %q", nv)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("py extension does not set node_version", func(t *testing.T) {
|
||||
raw := baseConfig()
|
||||
result, err := ValidateConfig(raw)
|
||||
mustSucceed(t, err)
|
||||
if nv, _ := result["node_version"].(string); nv != "" {
|
||||
t.Fatalf("expected node_version '', got %q", nv)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetUnknownKeys(t *testing.T) {
|
||||
t.Run("typo suggests correction", func(t *testing.T) {
|
||||
raw := map[string]any{
|
||||
"grpahs": map[string]any{"agent": "./agent.py:graph"},
|
||||
"dependencies": []any{"langchain"},
|
||||
}
|
||||
warnings := GetUnknownKeys(raw)
|
||||
found := false
|
||||
for _, w := range warnings {
|
||||
if strings.Contains(w, "did you mean 'graphs'") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected warning suggesting 'graphs', got: %v", warnings)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("totally unknown key", func(t *testing.T) {
|
||||
raw := map[string]any{
|
||||
"totally_unknown": "value",
|
||||
"graphs": map[string]any{"agent": "./agent.py:graph"},
|
||||
"dependencies": []any{"langchain"},
|
||||
}
|
||||
warnings := GetUnknownKeys(raw)
|
||||
found := false
|
||||
for _, w := range warnings {
|
||||
if strings.Contains(w, "not a recognized config field") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected 'not a recognized config field' warning, got: %v", warnings)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("only known keys gives no warnings", func(t *testing.T) {
|
||||
raw := baseConfig()
|
||||
warnings := GetUnknownKeys(raw)
|
||||
if len(warnings) != 0 {
|
||||
t.Fatalf("expected no warnings, got: %v", warnings)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateConfigMultiplatform(t *testing.T) {
|
||||
t.Run("only JS graphs", func(t *testing.T) {
|
||||
raw := map[string]any{
|
||||
"graphs": map[string]any{"bot": "./bot.ts:bot"},
|
||||
}
|
||||
result, err := ValidateConfig(raw)
|
||||
mustSucceed(t, err)
|
||||
if nv, _ := result["node_version"].(string); nv != "20" {
|
||||
t.Fatalf("expected node_version '20', got %q", nv)
|
||||
}
|
||||
if pv, _ := result["python_version"].(string); pv != "" {
|
||||
t.Fatalf("expected python_version '', got %q", pv)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("only Python graphs", func(t *testing.T) {
|
||||
raw := baseConfig()
|
||||
result, err := ValidateConfig(raw)
|
||||
mustSucceed(t, err)
|
||||
if pv, _ := result["python_version"].(string); pv != "3.11" {
|
||||
t.Fatalf("expected python_version '3.11', got %q", pv)
|
||||
}
|
||||
if nv, _ := result["node_version"].(string); nv != "" {
|
||||
t.Fatalf("expected node_version '', got %q", nv)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("mixed graphs", func(t *testing.T) {
|
||||
raw := map[string]any{
|
||||
"dependencies": []any{"langchain"},
|
||||
"graphs": map[string]any{"agent": "./agent.py:graph", "bot": "./bot.ts:bot"},
|
||||
}
|
||||
result, err := ValidateConfig(raw)
|
||||
mustSucceed(t, err)
|
||||
if pv, _ := result["python_version"].(string); pv != "3.11" {
|
||||
t.Fatalf("expected python_version '3.11', got %q", pv)
|
||||
}
|
||||
if nv, _ := result["node_version"].(string); nv != "20" {
|
||||
t.Fatalf("expected node_version '20', got %q", nv)
|
||||
}
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,804 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: write a uv-lock workspace fixture
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type workspaceOpts struct {
|
||||
// Relative directory (within project_root) that holds langgraph.json.
|
||||
// Default: "deploy/agent"
|
||||
configRelativeDir string
|
||||
// Extra TOML appended to the root pyproject.toml (e.g. [tool.uv.sources]).
|
||||
rootSources string
|
||||
// Extra TOML appended to the agent pyproject.toml (e.g. [tool.uv.sources]).
|
||||
agentSources string
|
||||
// Extra TOML appended to the shared lib pyproject.toml (e.g. [tool.uv] section).
|
||||
sharedUvConfig string
|
||||
// Custom agent dependencies list. When nil the default is used.
|
||||
agentDependencies []string
|
||||
// Additional files to create: relative-path (from project_root) -> content.
|
||||
extraFiles map[string]string
|
||||
}
|
||||
|
||||
// writeUvLockWorkspace creates the standard multi-package uv workspace used
|
||||
// by the Python test_config_to_docker_uv_lock* test suite and returns
|
||||
// (projectRoot, configPath).
|
||||
//
|
||||
// Layout:
|
||||
//
|
||||
// workspace/
|
||||
// pyproject.toml [project] name="workspace-root" + [tool.uv.workspace]
|
||||
// uv.lock
|
||||
// apps/agent/ package "agent"
|
||||
// pyproject.toml
|
||||
// src/agent/graph.py
|
||||
// libs/shared/ package "shared"
|
||||
// pyproject.toml
|
||||
// src/shared/auth.py
|
||||
// libs/extra/ package "extra" (not a dep of agent)
|
||||
// pyproject.toml
|
||||
// src/extra/graph.py
|
||||
// deploy/agent/ config directory (configRelativeDir)
|
||||
// langgraph.json
|
||||
func writeUvLockWorkspace(t *testing.T, opts workspaceOpts) (string, string) {
|
||||
t.Helper()
|
||||
base := t.TempDir()
|
||||
projectRoot := filepath.Join(base, "workspace")
|
||||
|
||||
configRelDir := opts.configRelativeDir
|
||||
if configRelDir == "" {
|
||||
configRelDir = "deploy/agent"
|
||||
}
|
||||
|
||||
configDir := filepath.Join(projectRoot, configRelDir)
|
||||
sharedDir := filepath.Join(projectRoot, "libs", "shared")
|
||||
extraDir := filepath.Join(projectRoot, "libs", "extra")
|
||||
deployDir := filepath.Join(projectRoot, "deploy", "agent")
|
||||
|
||||
for _, d := range []string{configDir, sharedDir, extraDir, deployDir} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll(%q): %v", d, err)
|
||||
}
|
||||
}
|
||||
|
||||
// -- root pyproject.toml -------------------------------------------------
|
||||
rootSources := opts.rootSources
|
||||
writeFile(t, filepath.Join(projectRoot, "pyproject.toml"),
|
||||
"[project]\n"+
|
||||
"name = \"workspace-root\"\n"+
|
||||
"version = \"0.1.0\"\n"+
|
||||
"\n"+
|
||||
"[tool.uv.workspace]\n"+
|
||||
"members = [\"apps/*\", \"libs/*\"]\n"+
|
||||
"\n"+
|
||||
rootSources+"\n"+
|
||||
"\n"+
|
||||
"[build-system]\n"+
|
||||
"requires = [\"setuptools>=61\"]\n"+
|
||||
"build-backend = \"setuptools.build_meta\"\n")
|
||||
|
||||
// -- uv.lock -------------------------------------------------------------
|
||||
writeFile(t, filepath.Join(projectRoot, "uv.lock"), "# uv lock file\n")
|
||||
|
||||
// -- agent pyproject.toml ------------------------------------------------
|
||||
agentDir := filepath.Join(projectRoot, "apps", "agent")
|
||||
if err := os.MkdirAll(agentDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
agentDeps := opts.agentDependencies
|
||||
if agentDeps == nil {
|
||||
agentDeps = []string{"shared", "httpx>=0.28"}
|
||||
}
|
||||
depsList := "[\"" + strings.Join(agentDeps, "\", \"") + "\"]"
|
||||
|
||||
agentSources := opts.agentSources
|
||||
writeFile(t, filepath.Join(agentDir, "pyproject.toml"),
|
||||
"[project]\n"+
|
||||
"name = \"agent\"\n"+
|
||||
"version = \"0.1.0\"\n"+
|
||||
"dependencies = "+depsList+"\n"+
|
||||
"\n"+
|
||||
agentSources+"\n"+
|
||||
"\n"+
|
||||
"[build-system]\n"+
|
||||
"requires = [\"setuptools>=61\"]\n"+
|
||||
"build-backend = \"setuptools.build_meta\"\n")
|
||||
|
||||
// -- shared pyproject.toml -----------------------------------------------
|
||||
sharedUvConfig := opts.sharedUvConfig
|
||||
writeFile(t, filepath.Join(sharedDir, "pyproject.toml"),
|
||||
"[project]\n"+
|
||||
"name = \"shared\"\n"+
|
||||
"version = \"0.1.0\"\n"+
|
||||
"dependencies = [\"anyio>=4\"]\n"+
|
||||
"\n"+
|
||||
sharedUvConfig+"\n"+
|
||||
"\n"+
|
||||
"[build-system]\n"+
|
||||
"requires = [\"setuptools>=61\"]\n"+
|
||||
"build-backend = \"setuptools.build_meta\"\n")
|
||||
|
||||
// -- extra pyproject.toml ------------------------------------------------
|
||||
writeFile(t, filepath.Join(extraDir, "pyproject.toml"),
|
||||
"[project]\n"+
|
||||
"name = \"extra\"\n"+
|
||||
"version = \"0.1.0\"\n"+
|
||||
"\n"+
|
||||
"[build-system]\n"+
|
||||
"requires = [\"setuptools>=61\"]\n"+
|
||||
"build-backend = \"setuptools.build_meta\"\n")
|
||||
|
||||
// -- source files --------------------------------------------------------
|
||||
writeFile(t, filepath.Join(agentDir, "src", "agent", "graph.py"), "")
|
||||
writeFile(t, filepath.Join(sharedDir, "src", "shared", "auth.py"), "")
|
||||
writeFile(t, filepath.Join(extraDir, "src", "extra", "graph.py"), "")
|
||||
|
||||
// -- config file ---------------------------------------------------------
|
||||
configPath := filepath.Join(deployDir, "langgraph.json")
|
||||
writeFile(t, configPath, "{}\n")
|
||||
|
||||
// If configRelativeDir is non-default, also place langgraph.json there.
|
||||
if configRelDir != "deploy/agent" {
|
||||
altConfigPath := filepath.Join(configDir, "langgraph.json")
|
||||
writeFile(t, altConfigPath, "{}\n")
|
||||
configPath = altConfigPath
|
||||
}
|
||||
|
||||
// -- extra files ---------------------------------------------------------
|
||||
for relPath, content := range opts.extraFiles {
|
||||
writeFile(t, filepath.Join(projectRoot, relPath), content)
|
||||
}
|
||||
|
||||
return projectRoot, configPath
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestUvLockBasic(t *testing.T) {
|
||||
_, configPath := writeUvLockWorkspace(t, workspaceOpts{
|
||||
agentSources: "[tool.uv.sources]\nshared = { workspace = true }",
|
||||
})
|
||||
|
||||
cfg := mustValidate(t, map[string]any{
|
||||
"python_version": "3.11",
|
||||
"graphs": map[string]any{
|
||||
"agent": "../../apps/agent/src/agent/graph.py:graph",
|
||||
},
|
||||
"source": map[string]any{"kind": "uv", "root": "../..", "package": "agent"},
|
||||
"auth": map[string]any{"path": "../../libs/shared/src/shared/auth.py:create_auth"},
|
||||
})
|
||||
|
||||
docker, contexts, err := ConfigToDocker(configPath, cfg, DockerOpts{
|
||||
BaseImage: "langchain/langgraph-api:0.2.47",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ConfigToDocker: %v", err)
|
||||
}
|
||||
|
||||
// Core commands.
|
||||
assertContains(t, docker, "uv pip install --system")
|
||||
assertContains(t, docker,
|
||||
"uv export --package 'agent' --frozen --no-hashes --no-emit-project --no-emit-workspace")
|
||||
|
||||
// Copy project metadata for uv export.
|
||||
assertContains(t, docker,
|
||||
"COPY --from=uv-workspace-root pyproject.toml /tmp/uv_export/project/pyproject.toml")
|
||||
assertContains(t, docker,
|
||||
"COPY --from=uv-workspace-root uv.lock /tmp/uv_export/project/uv.lock")
|
||||
|
||||
// Additional build contexts.
|
||||
if _, ok := contexts["uv-workspace-root"]; !ok {
|
||||
t.Fatal("expected 'uv-workspace-root' in additional contexts")
|
||||
}
|
||||
|
||||
// Workspace packages copied.
|
||||
assertContains(t, docker,
|
||||
"COPY --from=uv-workspace-root apps/agent /deps/workspace/apps/agent")
|
||||
assertContains(t, docker,
|
||||
"COPY --from=uv-workspace-root libs/shared /deps/workspace/libs/shared")
|
||||
// Unrelated member NOT copied.
|
||||
assertNotContains(t, docker,
|
||||
"libs/extra /deps/workspace/libs/extra")
|
||||
|
||||
// Install order: shared before agent.
|
||||
assertContains(t, docker, "WORKDIR /deps/workspace/libs/shared")
|
||||
assertContains(t, docker, "WORKDIR /deps/workspace/apps/agent")
|
||||
sharedInstall := "uv pip install --system --no-cache-dir -c /api/constraints.txt --no-deps -e ."
|
||||
assertContains(t, docker, sharedInstall)
|
||||
|
||||
// Ordering: shared COPY < shared WORKDIR < agent COPY < agent WORKDIR.
|
||||
sharedCopy := "COPY --from=uv-workspace-root libs/shared /deps/workspace/libs/shared"
|
||||
sharedWD := "WORKDIR /deps/workspace/libs/shared"
|
||||
agentCopy := "COPY --from=uv-workspace-root apps/agent /deps/workspace/apps/agent"
|
||||
agentWD := "WORKDIR /deps/workspace/apps/agent"
|
||||
if strings.Index(docker, sharedCopy) >= strings.Index(docker, sharedWD) {
|
||||
t.Error("shared COPY should appear before shared WORKDIR")
|
||||
}
|
||||
if strings.Index(docker, sharedWD) >= strings.Index(docker, agentCopy) {
|
||||
t.Error("shared WORKDIR should appear before agent COPY")
|
||||
}
|
||||
if strings.Index(docker, sharedWD) >= strings.Index(docker, agentWD) {
|
||||
t.Error("shared WORKDIR should appear before agent WORKDIR")
|
||||
}
|
||||
|
||||
// No legacy dep-loop patterns.
|
||||
assertNotContains(t, docker, "for dep in /deps/*")
|
||||
assertNotContains(t, docker, "# -- Installing workspace packages --")
|
||||
assertContains(t, docker, "WORKDIR /tmp/uv_export/project")
|
||||
assertNotContains(t, docker, "RUN cd ")
|
||||
|
||||
// Rewritten paths in env vars (Go JSON uses compact format without spaces).
|
||||
assertContains(t, docker,
|
||||
`"/deps/workspace/libs/shared/src/shared/auth.py:create_auth"`)
|
||||
assertContains(t, docker,
|
||||
`"/deps/workspace/apps/agent/src/agent/graph.py:graph"`)
|
||||
|
||||
// Cleanup.
|
||||
assertContains(t, docker, "rm /usr/bin/uv /usr/bin/uvx")
|
||||
}
|
||||
|
||||
func TestUvLockHonorsRootWorkspaceSources(t *testing.T) {
|
||||
_, configPath := writeUvLockWorkspace(t, workspaceOpts{
|
||||
rootSources: "[tool.uv.sources]\nshared = { workspace = true }",
|
||||
})
|
||||
|
||||
cfg := mustValidate(t, map[string]any{
|
||||
"python_version": "3.11",
|
||||
"graphs": map[string]any{
|
||||
"agent": "../../apps/agent/src/agent/graph.py:graph",
|
||||
},
|
||||
"source": map[string]any{"kind": "uv", "root": "../..", "package": "agent"},
|
||||
"auth": map[string]any{"path": "../../libs/shared/src/shared/auth.py:create_auth"},
|
||||
})
|
||||
|
||||
docker, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
|
||||
BaseImage: "langchain/langgraph-api:0.2.47",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ConfigToDocker: %v", err)
|
||||
}
|
||||
|
||||
assertContains(t, docker,
|
||||
"COPY --from=uv-workspace-root libs/shared /deps/workspace/libs/shared")
|
||||
assertContains(t, docker,
|
||||
`"/deps/workspace/libs/shared/src/shared/auth.py:create_auth"`)
|
||||
}
|
||||
|
||||
func TestUvLockIgnoresUnrelatedWorkspacePackageSources(t *testing.T) {
|
||||
projectRoot, configPath := writeUvLockWorkspace(t, workspaceOpts{
|
||||
agentSources: "[tool.uv.sources]\nshared = { workspace = true }",
|
||||
})
|
||||
|
||||
// Add badlib with [tool.uv.sources] pointing outside (an unrelated member).
|
||||
badlibDir := filepath.Join(projectRoot, "libs", "badlib")
|
||||
writeFile(t, filepath.Join(badlibDir, "pyproject.toml"),
|
||||
"[project]\n"+
|
||||
"name = \"badlib\"\n"+
|
||||
"version = \"0.1.0\"\n"+
|
||||
"\n"+
|
||||
"[tool.uv.sources]\n"+
|
||||
"outside = { path = \"../outside\" }\n"+
|
||||
"\n"+
|
||||
"[build-system]\n"+
|
||||
"requires = [\"setuptools>=61\"]\n"+
|
||||
"build-backend = \"setuptools.build_meta\"\n")
|
||||
|
||||
cfg := mustValidate(t, map[string]any{
|
||||
"python_version": "3.11",
|
||||
"graphs": map[string]any{
|
||||
"agent": "../../apps/agent/src/agent/graph.py:graph",
|
||||
},
|
||||
"source": map[string]any{"kind": "uv", "root": "../..", "package": "agent"},
|
||||
"auth": map[string]any{"path": "../../libs/shared/src/shared/auth.py:create_auth"},
|
||||
})
|
||||
|
||||
docker, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
|
||||
BaseImage: "langchain/langgraph-api:0.2.47",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ConfigToDocker: %v", err)
|
||||
}
|
||||
|
||||
assertContains(t, docker,
|
||||
"COPY --from=uv-workspace-root libs/shared /deps/workspace/libs/shared")
|
||||
assertNotContains(t, docker,
|
||||
"COPY --from=uv-workspace-root libs/badlib /deps/workspace/libs/badlib")
|
||||
}
|
||||
|
||||
func TestUvLockIgnoresUnrelatedRootSources(t *testing.T) {
|
||||
_, configPath := writeUvLockWorkspace(t, workspaceOpts{
|
||||
rootSources: "[tool.uv.sources]\nshared = { workspace = true }\nunused = { path = \"libs/extra\", editable = true }",
|
||||
})
|
||||
|
||||
cfg := mustValidate(t, map[string]any{
|
||||
"python_version": "3.11",
|
||||
"graphs": map[string]any{
|
||||
"agent": "../../apps/agent/src/agent/graph.py:graph",
|
||||
},
|
||||
"source": map[string]any{"kind": "uv", "root": "../..", "package": "agent"},
|
||||
"auth": map[string]any{"path": "../../libs/shared/src/shared/auth.py:create_auth"},
|
||||
})
|
||||
|
||||
docker, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
|
||||
BaseImage: "langchain/langgraph-api:0.2.47",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ConfigToDocker: %v", err)
|
||||
}
|
||||
|
||||
assertContains(t, docker,
|
||||
"COPY --from=uv-workspace-root libs/shared /deps/workspace/libs/shared")
|
||||
assertNotContains(t, docker,
|
||||
"COPY --from=uv-workspace-root libs/extra /deps/workspace/libs/extra")
|
||||
}
|
||||
|
||||
func TestUvLockValidatesRootPathSourcesRelativeToProjectRoot(t *testing.T) {
|
||||
projectRoot, configPath := writeUvLockWorkspace(t, workspaceOpts{
|
||||
rootSources: "[tool.uv.sources]\nshared = { path = \"../outside\", editable = true }",
|
||||
})
|
||||
|
||||
// Create the outside directory the source points to.
|
||||
outsideDir := filepath.Join(filepath.Dir(projectRoot), "outside")
|
||||
writeFile(t, filepath.Join(outsideDir, "pyproject.toml"),
|
||||
"[project]\n"+
|
||||
"name = \"shared\"\n"+
|
||||
"version = \"0.1.0\"\n")
|
||||
|
||||
cfg := mustValidate(t, map[string]any{
|
||||
"python_version": "3.11",
|
||||
"graphs": map[string]any{
|
||||
"agent": "../../apps/agent/src/agent/graph.py:graph",
|
||||
},
|
||||
"source": map[string]any{"kind": "uv", "root": "../..", "package": "agent"},
|
||||
})
|
||||
|
||||
_, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
|
||||
BaseImage: "langchain/langgraph-api:0.2.47",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for path outside project_root")
|
||||
}
|
||||
assertContains(t, err.Error(), "outside project_root")
|
||||
}
|
||||
|
||||
func TestUvLockRequiresExplicitWorkspaceSources(t *testing.T) {
|
||||
// No agent_sources or root_sources => shared is NOT a workspace dep.
|
||||
_, configPath := writeUvLockWorkspace(t, workspaceOpts{})
|
||||
|
||||
cfg := mustValidate(t, map[string]any{
|
||||
"python_version": "3.11",
|
||||
"graphs": map[string]any{
|
||||
"agent": "../../apps/agent/src/agent/graph.py:graph",
|
||||
},
|
||||
"source": map[string]any{"kind": "uv", "root": "../..", "package": "agent"},
|
||||
"auth": map[string]any{"path": "../../libs/shared/src/shared/auth.py:create_auth"},
|
||||
})
|
||||
|
||||
_, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
|
||||
BaseImage: "langchain/langgraph-api:0.2.47",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error because shared is not an explicit workspace source")
|
||||
}
|
||||
assertContains(t, err.Error(), "not inside the target package 'agent'")
|
||||
}
|
||||
|
||||
func TestUvLockAcceptsPathWorkspaceSources(t *testing.T) {
|
||||
_, configPath := writeUvLockWorkspace(t, workspaceOpts{
|
||||
agentSources: "[tool.uv.sources]\nshared = { path = \"../../libs/shared\", editable = true }",
|
||||
})
|
||||
|
||||
cfg := mustValidate(t, map[string]any{
|
||||
"python_version": "3.11",
|
||||
"graphs": map[string]any{
|
||||
"agent": "../../apps/agent/src/agent/graph.py:graph",
|
||||
},
|
||||
"source": map[string]any{"kind": "uv", "root": "../..", "package": "agent"},
|
||||
})
|
||||
|
||||
docker, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
|
||||
BaseImage: "langchain/langgraph-api:0.2.47",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ConfigToDocker: %v", err)
|
||||
}
|
||||
|
||||
assertContains(t, docker,
|
||||
"COPY --from=uv-workspace-root libs/shared /deps/workspace/libs/shared")
|
||||
}
|
||||
|
||||
func TestUvLockRejectsPackageFalseWorkspaceDependency(t *testing.T) {
|
||||
_, configPath := writeUvLockWorkspace(t, workspaceOpts{
|
||||
agentSources: "[tool.uv.sources]\nshared = { workspace = true }",
|
||||
sharedUvConfig: "[tool.uv]\npackage = false",
|
||||
})
|
||||
|
||||
cfg := mustValidate(t, map[string]any{
|
||||
"python_version": "3.11",
|
||||
"graphs": map[string]any{
|
||||
"agent": "../../apps/agent/src/agent/graph.py:graph",
|
||||
},
|
||||
"source": map[string]any{"kind": "uv", "root": "../..", "package": "agent"},
|
||||
})
|
||||
|
||||
_, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
|
||||
BaseImage: "langchain/langgraph-api:0.2.47",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for package = false workspace dependency")
|
||||
}
|
||||
assertContains(t, err.Error(), "tool.uv.package = false")
|
||||
}
|
||||
|
||||
func TestUvLockAcceptsRootPathWorkspaceSources(t *testing.T) {
|
||||
_, configPath := writeUvLockWorkspace(t, workspaceOpts{
|
||||
rootSources: "[tool.uv.sources]\nshared = { path = \"libs/shared\", editable = true }",
|
||||
})
|
||||
|
||||
cfg := mustValidate(t, map[string]any{
|
||||
"python_version": "3.11",
|
||||
"graphs": map[string]any{
|
||||
"agent": "../../apps/agent/src/agent/graph.py:graph",
|
||||
},
|
||||
"source": map[string]any{"kind": "uv", "root": "../..", "package": "agent"},
|
||||
"auth": map[string]any{"path": "../../libs/shared/src/shared/auth.py:create_auth"},
|
||||
})
|
||||
|
||||
docker, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
|
||||
BaseImage: "langchain/langgraph-api:0.2.47",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ConfigToDocker: %v", err)
|
||||
}
|
||||
|
||||
assertContains(t, docker,
|
||||
"COPY --from=uv-workspace-root libs/shared /deps/workspace/libs/shared")
|
||||
assertContains(t, docker,
|
||||
`"/deps/workspace/libs/shared/src/shared/auth.py:create_auth"`)
|
||||
}
|
||||
|
||||
func TestUvLockRejectsMismatchedPathWorkspaceSources(t *testing.T) {
|
||||
// agent sources point to libs/extra with the name "shared" -> mismatch.
|
||||
_, configPath := writeUvLockWorkspace(t, workspaceOpts{
|
||||
agentSources: "[tool.uv.sources]\nshared = { path = \"../../libs/extra\", editable = true }",
|
||||
})
|
||||
|
||||
cfg := mustValidate(t, map[string]any{
|
||||
"python_version": "3.11",
|
||||
"graphs": map[string]any{
|
||||
"agent": "../../apps/agent/src/agent/graph.py:graph",
|
||||
},
|
||||
"source": map[string]any{"kind": "uv", "root": "../..", "package": "agent"},
|
||||
})
|
||||
|
||||
_, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
|
||||
BaseImage: "langchain/langgraph-api:0.2.47",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for mismatched path workspace sources")
|
||||
}
|
||||
assertContains(t, err.Error(), "dependency name and the workspace package name must match")
|
||||
}
|
||||
|
||||
func TestUvLockDetectsJsPmFromTargetPackageRoot(t *testing.T) {
|
||||
projectRoot, configPath := writeUvLockWorkspace(t, workspaceOpts{
|
||||
agentSources: "[tool.uv.sources]\nshared = { workspace = true }",
|
||||
})
|
||||
|
||||
agentDir := filepath.Join(projectRoot, "apps", "agent")
|
||||
writeFile(t, filepath.Join(agentDir, "package.json"), "{\"packageManager\":\"pnpm@9.0.0\"}\n")
|
||||
writeFile(t, filepath.Join(agentDir, "pnpm-lock.yaml"), "lockfileVersion: 9.0\n")
|
||||
writeFile(t, filepath.Join(agentDir, "ui.tsx"), "export const ui = null;\n")
|
||||
|
||||
cfg := mustValidate(t, map[string]any{
|
||||
"python_version": "3.11",
|
||||
"graphs": map[string]any{
|
||||
"agent": "../../apps/agent/src/agent/graph.py:graph",
|
||||
},
|
||||
"source": map[string]any{"kind": "uv", "root": "../..", "package": "agent"},
|
||||
"ui": map[string]any{"agent": "../../apps/agent/ui.tsx"},
|
||||
})
|
||||
|
||||
docker, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
|
||||
BaseImage: "langchain/langgraph-api:0.2.47",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ConfigToDocker: %v", err)
|
||||
}
|
||||
|
||||
assertContains(t, docker, "WORKDIR /deps/workspace/apps/agent")
|
||||
assertContains(t, docker,
|
||||
"RUN pnpm i --frozen-lockfile && tsx /api/langgraph_api/js/build.mts")
|
||||
assertContains(t, docker, `/deps/workspace/apps/agent/ui.tsx`)
|
||||
}
|
||||
|
||||
func TestUvLockUsesWorkdirForJsInstallWithSpecialChars(t *testing.T) {
|
||||
projectRoot, _ := writeUvLockWorkspace(t, workspaceOpts{
|
||||
configRelativeDir: "apps/agent;echo pwned",
|
||||
})
|
||||
|
||||
// The custom configRelativeDir creates its own langgraph.json.
|
||||
// We need to place the agent pyproject.toml at this directory.
|
||||
agentDir := filepath.Join(projectRoot, "apps", "agent;echo pwned")
|
||||
writeFile(t, filepath.Join(agentDir, "package.json"), "{\"packageManager\":\"pnpm@9.0.0\"}\n")
|
||||
writeFile(t, filepath.Join(agentDir, "pnpm-lock.yaml"), "lockfileVersion: 9.0\n")
|
||||
writeFile(t, filepath.Join(agentDir, "ui.tsx"), "export const ui = null;\n")
|
||||
writeFile(t, filepath.Join(agentDir, "pyproject.toml"),
|
||||
"[project]\n"+
|
||||
"name = \"agent\"\n"+
|
||||
"version = \"0.1.0\"\n"+
|
||||
"dependencies = [\"shared\", \"httpx>=0.28\"]\n"+
|
||||
"\n"+
|
||||
"[build-system]\n"+
|
||||
"requires = [\"setuptools>=61\"]\n"+
|
||||
"build-backend = \"setuptools.build_meta\"\n")
|
||||
writeFile(t, filepath.Join(agentDir, "src", "agent", "graph.py"), "")
|
||||
|
||||
// Remove the default apps/agent so there's no duplicate package name.
|
||||
if err := os.RemoveAll(filepath.Join(projectRoot, "apps", "agent")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Update workspace members to include the special-char directory.
|
||||
writeFile(t, filepath.Join(projectRoot, "pyproject.toml"),
|
||||
"[project]\n"+
|
||||
"name = \"workspace-root\"\n"+
|
||||
"version = \"0.1.0\"\n"+
|
||||
"\n"+
|
||||
"[tool.uv.workspace]\n"+
|
||||
"members = [\"apps/*\", \"libs/*\"]\n"+
|
||||
"\n"+
|
||||
"[build-system]\n"+
|
||||
"requires = [\"setuptools>=61\"]\n"+
|
||||
"build-backend = \"setuptools.build_meta\"\n")
|
||||
|
||||
configPath := filepath.Join(agentDir, "langgraph.json")
|
||||
|
||||
cfg := mustValidate(t, map[string]any{
|
||||
"python_version": "3.11",
|
||||
"graphs": map[string]any{
|
||||
"agent": "./src/agent/graph.py:graph",
|
||||
},
|
||||
"source": map[string]any{"kind": "uv", "root": "../..", "package": "agent"},
|
||||
"ui": map[string]any{"agent": "./ui.tsx"},
|
||||
})
|
||||
|
||||
docker, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
|
||||
BaseImage: "langchain/langgraph-api:0.2.47",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ConfigToDocker: %v", err)
|
||||
}
|
||||
|
||||
assertContains(t, docker, "WORKDIR /deps/workspace/apps/agent;echo pwned")
|
||||
assertContains(t, docker,
|
||||
"RUN pnpm i --frozen-lockfile && tsx /api/langgraph_api/js/build.mts")
|
||||
assertNotContains(t, docker, "RUN cd /deps/workspace/apps/agent;echo pwned")
|
||||
}
|
||||
|
||||
func TestUvLockSupportsSingleUvProjectRoot(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
projectRoot := filepath.Join(base, "single")
|
||||
if err := os.MkdirAll(projectRoot, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
writeFile(t, filepath.Join(projectRoot, "uv.lock"), "# uv lock file\n")
|
||||
writeFile(t, filepath.Join(projectRoot, "pyproject.toml"),
|
||||
"[project]\n"+
|
||||
"name = \"single-app\"\n"+
|
||||
"version = \"0.1.0\"\n"+
|
||||
"dependencies = [\"httpx>=0.28\"]\n"+
|
||||
"\n"+
|
||||
"[build-system]\n"+
|
||||
"requires = [\"setuptools>=61\"]\n"+
|
||||
"build-backend = \"setuptools.build_meta\"\n")
|
||||
configPath := filepath.Join(projectRoot, "langgraph.json")
|
||||
writeFile(t, configPath, "{}\n")
|
||||
writeFile(t, filepath.Join(projectRoot, "src", "agent.py"), "graph = object()\n")
|
||||
|
||||
cfg := mustValidate(t, map[string]any{
|
||||
"python_version": "3.11",
|
||||
"graphs": map[string]any{"agent": "./src/agent.py:graph"},
|
||||
"source": map[string]any{"kind": "uv"},
|
||||
})
|
||||
|
||||
docker, contexts, err := ConfigToDocker(configPath, cfg, DockerOpts{
|
||||
BaseImage: "langchain/langgraph-api:0.2.47",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ConfigToDocker: %v", err)
|
||||
}
|
||||
|
||||
assertContains(t, docker,
|
||||
"uv export --package 'single-app' --frozen --no-hashes --no-emit-project --no-emit-workspace")
|
||||
assertContains(t, docker, `"/deps/workspace/src/agent.py:graph"`)
|
||||
if len(contexts) != 0 {
|
||||
t.Fatalf("expected no additional contexts for single-project root, got %d: %v",
|
||||
len(contexts), contexts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUvLockRejectsInvalidSourcePackageType(t *testing.T) {
|
||||
_, configPath := writeUvLockWorkspace(t, workspaceOpts{})
|
||||
|
||||
cfg := mustValidate(t, map[string]any{
|
||||
"python_version": "3.11",
|
||||
"graphs": map[string]any{
|
||||
"agent": "../../apps/agent/src/agent/graph.py:graph",
|
||||
},
|
||||
"source": map[string]any{"kind": "uv", "root": "../..", "package": "agent"},
|
||||
})
|
||||
// Override the validated source.package to an integer.
|
||||
cfg["source"].(map[string]any)["package"] = 123
|
||||
|
||||
_, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
|
||||
BaseImage: "langchain/langgraph-api:0.2.47",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-string source.package")
|
||||
}
|
||||
assertContains(t, err.Error(), "`source.package` must be a non-empty string")
|
||||
}
|
||||
|
||||
func TestUvLockRejectsPathsOutsideTargetClosure(t *testing.T) {
|
||||
_, configPath := writeUvLockWorkspace(t, workspaceOpts{
|
||||
agentSources: "[tool.uv.sources]\nshared = { workspace = true }",
|
||||
})
|
||||
|
||||
cfg := mustValidate(t, map[string]any{
|
||||
"python_version": "3.11",
|
||||
"graphs": map[string]any{
|
||||
// Graph path points into libs/extra which is NOT a dependency of agent.
|
||||
"agent": "../../libs/extra/src/extra/graph.py:graph",
|
||||
},
|
||||
"source": map[string]any{"kind": "uv", "root": "../..", "package": "agent"},
|
||||
})
|
||||
|
||||
_, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
|
||||
BaseImage: "langchain/langgraph-api:0.2.47",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for graph path outside target closure")
|
||||
}
|
||||
assertContains(t, err.Error(), "not inside the target package 'agent'")
|
||||
}
|
||||
|
||||
func TestUvLockRejectsUnrelatedMemberWhenRootInClosure(t *testing.T) {
|
||||
_, configPath := writeUvLockWorkspace(t, workspaceOpts{
|
||||
agentDependencies: []string{"workspace-root", "shared", "httpx>=0.28"},
|
||||
rootSources: "[tool.uv.sources]\nshared = { workspace = true }\nworkspace-root = { workspace = true }",
|
||||
agentSources: "[tool.uv.sources]\nshared = { workspace = true }\nworkspace-root = { workspace = true }",
|
||||
})
|
||||
|
||||
cfg := mustValidate(t, map[string]any{
|
||||
"python_version": "3.11",
|
||||
"graphs": map[string]any{
|
||||
// extra is a workspace member but NOT a dependency of agent.
|
||||
"agent": "../../libs/extra/src/extra/graph.py:graph",
|
||||
},
|
||||
"source": map[string]any{"kind": "uv", "root": "../..", "package": "agent"},
|
||||
})
|
||||
|
||||
_, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
|
||||
BaseImage: "langchain/langgraph-api:0.2.47",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unrelated member when root is in closure")
|
||||
}
|
||||
assertContains(t, err.Error(), "not inside the target package 'agent'")
|
||||
}
|
||||
|
||||
func TestUvLockRootPackageCopySkipsUnrelatedMembers(t *testing.T) {
|
||||
projectRoot, configPath := writeUvLockWorkspace(t, workspaceOpts{
|
||||
agentDependencies: []string{"workspace-root", "shared", "httpx>=0.28"},
|
||||
rootSources: "[tool.uv.sources]\nshared = { workspace = true }\nworkspace-root = { workspace = true }",
|
||||
agentSources: "[tool.uv.sources]\nshared = { workspace = true }\nworkspace-root = { workspace = true }",
|
||||
})
|
||||
|
||||
// Add files in the workspace root package itself.
|
||||
writeFile(t, filepath.Join(projectRoot, "src", "workspace_root", "__init__.py"), "__all__ = []\n")
|
||||
writeFile(t, filepath.Join(projectRoot, "README.md"), "workspace root package\n")
|
||||
|
||||
cfg := mustValidate(t, map[string]any{
|
||||
"python_version": "3.11",
|
||||
"graphs": map[string]any{
|
||||
"agent": "../../apps/agent/src/agent/graph.py:graph",
|
||||
},
|
||||
"source": map[string]any{"kind": "uv", "root": "../..", "package": "agent"},
|
||||
})
|
||||
|
||||
docker, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
|
||||
BaseImage: "langchain/langgraph-api:0.2.47",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ConfigToDocker: %v", err)
|
||||
}
|
||||
|
||||
// Should NOT do a blanket COPY of the whole workspace root.
|
||||
assertNotContains(t, docker, "COPY --from=uv-workspace-root . /deps/workspace")
|
||||
// Should copy specific entries.
|
||||
assertContains(t, docker, "COPY --from=uv-workspace-root src /deps/workspace/src")
|
||||
assertContains(t, docker, "COPY --from=uv-workspace-root README.md /deps/workspace/README.md")
|
||||
// Should NOT copy unrelated member dirs.
|
||||
assertNotContains(t, docker,
|
||||
"COPY --from=uv-workspace-root libs/extra /deps/workspace/libs/extra")
|
||||
// Should install workspace root from /deps/workspace.
|
||||
assertContains(t, docker, "WORKDIR /deps/workspace")
|
||||
assertContains(t, docker,
|
||||
"uv pip install --system --no-cache-dir -c /api/constraints.txt --no-deps -e .")
|
||||
}
|
||||
|
||||
func TestUvLockMissingLockfile(t *testing.T) {
|
||||
projectRoot, configPath := writeUvLockWorkspace(t, workspaceOpts{})
|
||||
|
||||
// Remove uv.lock.
|
||||
if err := os.Remove(filepath.Join(projectRoot, "uv.lock")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg := mustValidate(t, map[string]any{
|
||||
"python_version": "3.11",
|
||||
"graphs": map[string]any{"agent": "../../apps/agent/src/agent/graph.py:graph"},
|
||||
"source": map[string]any{"kind": "uv", "root": "../..", "package": "agent"},
|
||||
})
|
||||
|
||||
_, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
|
||||
BaseImage: "langchain/langgraph-api:0.2.47",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing uv.lock")
|
||||
}
|
||||
assertContains(t, err.Error(), "No uv.lock found")
|
||||
}
|
||||
|
||||
func TestUvLockMissingPyproject(t *testing.T) {
|
||||
projectRoot, configPath := writeUvLockWorkspace(t, workspaceOpts{})
|
||||
|
||||
// Remove root pyproject.toml.
|
||||
if err := os.Remove(filepath.Join(projectRoot, "pyproject.toml")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg := mustValidate(t, map[string]any{
|
||||
"python_version": "3.11",
|
||||
"graphs": map[string]any{"agent": "../../apps/agent/src/agent/graph.py:graph"},
|
||||
"source": map[string]any{"kind": "uv", "root": "../..", "package": "agent"},
|
||||
})
|
||||
|
||||
_, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
|
||||
BaseImage: "langchain/langgraph-api:0.2.47",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing pyproject.toml")
|
||||
}
|
||||
assertContains(t, err.Error(), "No pyproject.toml found")
|
||||
}
|
||||
|
||||
func TestUvLockOldImage(t *testing.T) {
|
||||
_, configPath := writeUvLockWorkspace(t, workspaceOpts{})
|
||||
|
||||
cfg := mustValidate(t, map[string]any{
|
||||
"python_version": "3.11",
|
||||
"graphs": map[string]any{"agent": "../../apps/agent/src/agent/graph.py:graph"},
|
||||
"source": map[string]any{"kind": "uv", "root": "../..", "package": "agent"},
|
||||
})
|
||||
|
||||
_, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
|
||||
BaseImage: "langchain/langgraph-api:0.2.46",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for old image without uv support")
|
||||
}
|
||||
assertContains(t, err.Error(), "requires a base image with uv support")
|
||||
}
|
||||
@@ -1,272 +0,0 @@
|
||||
// Package deploy provides an HTTP client for the LangGraph host backend
|
||||
// deployment service.
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Secret represents a name/value pair sent as a deployment secret.
|
||||
type Secret struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// HostBackendClient is a minimal JSON HTTP client for the host backend
|
||||
// deployment service.
|
||||
type HostBackendClient struct {
|
||||
BaseURL string
|
||||
APIKey string
|
||||
TenantID string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// retryTransport wraps an http.RoundTripper and retries failed requests.
|
||||
type retryTransport struct {
|
||||
base http.RoundTripper
|
||||
retries int
|
||||
}
|
||||
|
||||
func (t *retryTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
var resp *http.Response
|
||||
var err error
|
||||
|
||||
// We need to buffer the body so we can replay it on retries.
|
||||
var bodyBytes []byte
|
||||
if req.Body != nil {
|
||||
bodyBytes, err = io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Body.Close()
|
||||
}
|
||||
|
||||
for attempt := 0; attempt <= t.retries; attempt++ {
|
||||
if bodyBytes != nil {
|
||||
req.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||
}
|
||||
resp, err = t.base.RoundTrip(req)
|
||||
if err == nil {
|
||||
return resp, nil
|
||||
}
|
||||
// Only retry on transport-level errors; do not retry on HTTP error
|
||||
// status codes (the caller handles those).
|
||||
}
|
||||
return resp, err
|
||||
}
|
||||
|
||||
// NewClient creates a new HostBackendClient. The baseURL is stripped of any
|
||||
// trailing slash. The underlying http.Client uses a 30-second timeout and
|
||||
// retries transport-level failures up to 3 times.
|
||||
func NewClient(baseURL, apiKey string) *HostBackendClient {
|
||||
return &HostBackendClient{
|
||||
BaseURL: strings.TrimRight(baseURL, "/"),
|
||||
APIKey: apiKey,
|
||||
TenantID: "",
|
||||
client: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Transport: &retryTransport{
|
||||
base: http.DefaultTransport,
|
||||
retries: 3,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// request executes an HTTP request against the host backend and returns the
|
||||
// parsed JSON response. It attaches required headers and handles errors.
|
||||
func (c *HostBackendClient) request(method, path string, payload map[string]any, params map[string]string) (map[string]any, error) {
|
||||
fullURL := c.BaseURL + path
|
||||
|
||||
// Append query parameters.
|
||||
if len(params) > 0 {
|
||||
q := url.Values{}
|
||||
for k, v := range params {
|
||||
q.Set(k, v)
|
||||
}
|
||||
fullURL += "?" + q.Encode()
|
||||
}
|
||||
|
||||
var body io.Reader
|
||||
if payload != nil {
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshalling request payload: %w", err)
|
||||
}
|
||||
body = bytes.NewReader(data)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, fullURL, body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("X-Api-Key", c.APIKey)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if payload != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if c.TenantID != "" {
|
||||
req.Header.Set("X-Tenant-ID", c.TenantID)
|
||||
}
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s %s: %w", method, path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading response from %s: %w", path, err)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
detail := string(respBody)
|
||||
if detail == "" {
|
||||
detail = fmt.Sprintf("%d", resp.StatusCode)
|
||||
}
|
||||
return nil, fmt.Errorf("%s %s failed with status %d: %s", method, path, resp.StatusCode, detail)
|
||||
}
|
||||
|
||||
if len(respBody) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response from %s: %w", path, err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// requestNoBody is a convenience wrapper for requests that return no parsed body.
|
||||
func (c *HostBackendClient) requestNoBody(method, path string, payload map[string]any, params map[string]string) error {
|
||||
_, err := c.request(method, path, payload, params)
|
||||
return err
|
||||
}
|
||||
|
||||
// CreateDeployment creates a new deployment.
|
||||
func (c *HostBackendClient) CreateDeployment(name, deploymentType, source string, configPath string, secrets []Secret) (map[string]any, error) {
|
||||
sourceRevisionConfig := map[string]any{}
|
||||
if source == "internal_source" && configPath != "" {
|
||||
sourceRevisionConfig["langgraph_config_path"] = configPath
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"name": name,
|
||||
"source": source,
|
||||
"source_config": map[string]any{"deployment_type": deploymentType},
|
||||
"source_revision_config": sourceRevisionConfig,
|
||||
}
|
||||
if secrets != nil {
|
||||
payload["secrets"] = secrets
|
||||
}
|
||||
return c.request("POST", "/v2/deployments", payload, nil)
|
||||
}
|
||||
|
||||
// ListDeployments lists deployments, optionally filtering by name.
|
||||
func (c *HostBackendClient) ListDeployments(nameContains string) (map[string]any, error) {
|
||||
params := map[string]string{"name_contains": nameContains}
|
||||
return c.request("GET", "/v2/deployments", nil, params)
|
||||
}
|
||||
|
||||
// GetDeployment retrieves a single deployment by ID.
|
||||
func (c *HostBackendClient) GetDeployment(deploymentID string) (map[string]any, error) {
|
||||
return c.request("GET", fmt.Sprintf("/v2/deployments/%s", deploymentID), nil, nil)
|
||||
}
|
||||
|
||||
// DeleteDeployment deletes a deployment by ID.
|
||||
func (c *HostBackendClient) DeleteDeployment(deploymentID string) error {
|
||||
return c.requestNoBody("DELETE", fmt.Sprintf("/v2/deployments/%s", deploymentID), nil, nil)
|
||||
}
|
||||
|
||||
// RequestPushToken requests a push token for a deployment.
|
||||
func (c *HostBackendClient) RequestPushToken(deploymentID string) (map[string]any, error) {
|
||||
return c.request("POST", fmt.Sprintf("/v2/deployments/%s/push-token", deploymentID), nil, nil)
|
||||
}
|
||||
|
||||
// RequestUploadURL gets a signed URL for uploading the source tarball.
|
||||
func (c *HostBackendClient) RequestUploadURL(deploymentID string) (map[string]any, error) {
|
||||
return c.request("POST", fmt.Sprintf("/v2/deployments/%s/upload-url", deploymentID), nil, nil)
|
||||
}
|
||||
|
||||
// UpdateDeployment triggers a new revision using a pre-pushed Docker image.
|
||||
func (c *HostBackendClient) UpdateDeployment(deploymentID, imageURI string, secrets []Secret) (map[string]any, error) {
|
||||
payload := map[string]any{
|
||||
"revision_source": "internal_docker",
|
||||
"source_revision_config": map[string]any{"image_uri": imageURI},
|
||||
}
|
||||
if secrets != nil {
|
||||
payload["secrets"] = secrets
|
||||
}
|
||||
return c.request("PATCH", fmt.Sprintf("/v2/deployments/%s", deploymentID), payload, nil)
|
||||
}
|
||||
|
||||
// UpdateDeploymentInternalSource triggers a remote-build revision using an
|
||||
// uploaded source tarball.
|
||||
func (c *HostBackendClient) UpdateDeploymentInternalSource(
|
||||
deploymentID, sourceTarballPath, configPath string,
|
||||
secrets []Secret,
|
||||
installCommand, buildCommand string,
|
||||
) (map[string]any, error) {
|
||||
payload := map[string]any{
|
||||
"revision_source": "internal_source",
|
||||
"source_revision_config": map[string]any{
|
||||
"source_tarball_path": sourceTarballPath,
|
||||
"langgraph_config_path": configPath,
|
||||
},
|
||||
}
|
||||
|
||||
sourceConfig := map[string]any{}
|
||||
if installCommand != "" {
|
||||
sourceConfig["install_command"] = installCommand
|
||||
}
|
||||
if buildCommand != "" {
|
||||
sourceConfig["build_command"] = buildCommand
|
||||
}
|
||||
if len(sourceConfig) > 0 {
|
||||
payload["source_config"] = sourceConfig
|
||||
}
|
||||
|
||||
if secrets != nil {
|
||||
payload["secrets"] = secrets
|
||||
}
|
||||
return c.request("PATCH", fmt.Sprintf("/v2/deployments/%s", deploymentID), payload, nil)
|
||||
}
|
||||
|
||||
// ListRevisions lists revisions for a deployment.
|
||||
func (c *HostBackendClient) ListRevisions(deploymentID string, limit int) (map[string]any, error) {
|
||||
return c.request("GET", fmt.Sprintf("/v2/deployments/%s/revisions", deploymentID), nil, map[string]string{
|
||||
"limit": fmt.Sprintf("%d", limit),
|
||||
})
|
||||
}
|
||||
|
||||
// GetRevision retrieves a single revision.
|
||||
func (c *HostBackendClient) GetRevision(deploymentID, revisionID string) (map[string]any, error) {
|
||||
return c.request("GET", fmt.Sprintf("/v2/deployments/%s/revisions/%s", deploymentID, revisionID), nil, nil)
|
||||
}
|
||||
|
||||
// GetBuildLogs retrieves build logs for a revision.
|
||||
func (c *HostBackendClient) GetBuildLogs(projectID, revisionID string, payload map[string]any) (map[string]any, error) {
|
||||
return c.request("POST", fmt.Sprintf("/v1/projects/%s/revisions/%s/build_logs", projectID, revisionID), payload, nil)
|
||||
}
|
||||
|
||||
// GetDeployLogs retrieves deploy logs. If revisionID is non-empty, it is
|
||||
// included in the path to scope the logs.
|
||||
func (c *HostBackendClient) GetDeployLogs(projectID string, payload map[string]any, revisionID string) (map[string]any, error) {
|
||||
var path string
|
||||
if revisionID != "" {
|
||||
path = fmt.Sprintf("/v1/projects/%s/revisions/%s/deploy_logs", projectID, revisionID)
|
||||
} else {
|
||||
path = fmt.Sprintf("/v1/projects/%s/deploy_logs", projectID)
|
||||
}
|
||||
return c.request("POST", path, payload, nil)
|
||||
}
|
||||
@@ -1,306 +0,0 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// APIKeyEnvNames lists the environment variable names checked (in order) when
|
||||
// resolving a LangSmith / LangGraph API key.
|
||||
var APIKeyEnvNames = []string{
|
||||
"LANGGRAPH_HOST_API_KEY",
|
||||
"LANGSMITH_API_KEY",
|
||||
"LANGCHAIN_API_KEY",
|
||||
}
|
||||
|
||||
// DefaultHostURL is the default host backend URL.
|
||||
const DefaultHostURL = "https://api.host.langchain.com"
|
||||
|
||||
// reservedEnvVars contains environment variable names that must not be sent as
|
||||
// deployment secrets. The set mirrors the Python CLI's RESERVED_ENV_VARS.
|
||||
var reservedEnvVars = map[string]bool{
|
||||
// LANGCHAIN_RESERVED_ENV_VARS from host-backend
|
||||
"LANGCHAIN_TRACING_V2": true,
|
||||
"LANGSMITH_TRACING_V2": true,
|
||||
"LANGCHAIN_ENDPOINT": true,
|
||||
"LANGCHAIN_PROJECT": true,
|
||||
"LANGSMITH_PROJECT": true,
|
||||
"LANGSMITH_LANGGRAPH_GIT_REPO": true,
|
||||
"LANGGRAPH_GIT_REPO_PATH": true,
|
||||
"LANGCHAIN_API_KEY": true,
|
||||
"LANGSMITH_CONTROL_PLANE_API_KEY": true,
|
||||
"POSTGRES_URI": true,
|
||||
"POSTGRES_PASSWORD": true,
|
||||
"DATABASE_URI": true,
|
||||
"LANGSMITH_LANGGRAPH_GIT_REF": true,
|
||||
"LANGSMITH_LANGGRAPH_GIT_REF_SHA": true,
|
||||
"LANGGRAPH_AUTH_TYPE": true,
|
||||
"LANGSMITH_AUTH_ENDPOINT": true,
|
||||
"LANGSMITH_TENANT_ID": true,
|
||||
"LANGSMITH_AUTH_VERIFY_TENANT_ID": true,
|
||||
"LANGSMITH_HOST_PROJECT_ID": true,
|
||||
"LANGSMITH_HOST_PROJECT_NAME": true,
|
||||
"LANGSMITH_HOST_REVISION_ID": true,
|
||||
"LOG_JSON": true,
|
||||
"LOG_DICT_TRACEBACKS": true,
|
||||
"REDIS_URI": true,
|
||||
"LANGCHAIN_CALLBACKS_BACKGROUND": true,
|
||||
"DD_TRACE_PSYCOPG_ENABLED": true,
|
||||
"DD_TRACE_REDIS_ENABLED": true,
|
||||
"LANGSMITH_DEPLOYMENT_NAME": true,
|
||||
"LANGGRAPH_CLOUD_LICENSE_KEY": true,
|
||||
// ALLOWED_SELF_HOSTED_ENV_VARS (rejected for non-self-hosted)
|
||||
"LANGSMITH_API_KEY": true,
|
||||
"LANGSMITH_ENDPOINT": true,
|
||||
"POSTGRES_URI_CUSTOM": true,
|
||||
"REDIS_URI_CUSTOM": true,
|
||||
"PATH": true,
|
||||
"PORT": true,
|
||||
"MOUNT_PREFIX": true,
|
||||
"LSD_ENV": true,
|
||||
"LSD_DD_API_KEY": true,
|
||||
"LSD_DD_ENDPOINT": true,
|
||||
"LSD_DEPLOYMENT_TYPE": true,
|
||||
}
|
||||
|
||||
var (
|
||||
invalidImageNameChars = regexp.MustCompile(`[^a-z0-9._-]+`)
|
||||
validImageTag = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`)
|
||||
)
|
||||
|
||||
// NormalizeImageName sanitizes a deployment/directory name into a valid Docker
|
||||
// repository name. Invalid characters are replaced with hyphens and the result
|
||||
// is lowercased. Returns "app" if the result would be empty.
|
||||
func NormalizeImageName(name string) string {
|
||||
if name == "" {
|
||||
return "app"
|
||||
}
|
||||
slug := invalidImageNameChars.ReplaceAllString(strings.ToLower(name), "-")
|
||||
slug = strings.TrimLeft(slug, "-.")
|
||||
slug = strings.TrimRight(slug, "-.")
|
||||
if slug == "" {
|
||||
return "app"
|
||||
}
|
||||
return slug
|
||||
}
|
||||
|
||||
// NormalizeImageTag validates and returns a Docker image tag. Tags may only
|
||||
// contain [A-Za-z0-9_.-]. Defaults to "latest" when empty.
|
||||
func NormalizeImageTag(tag string) (string, error) {
|
||||
if tag == "" {
|
||||
return "latest", nil
|
||||
}
|
||||
if !validImageTag.MatchString(tag) {
|
||||
return "", fmt.Errorf("image tag may only contain characters A-Z, a-z, 0-9, '_', '-', '.'")
|
||||
}
|
||||
return tag, nil
|
||||
}
|
||||
|
||||
// ResolveAPIKey resolves an API key by checking (in order): the explicit flag
|
||||
// value, the provided envVars map, and the process environment. Returns an
|
||||
// empty string if no key is found (the caller should prompt the user).
|
||||
func ResolveAPIKey(flagValue string, envVars map[string]string) string {
|
||||
if flagValue != "" {
|
||||
return flagValue
|
||||
}
|
||||
for _, keyName := range APIKeyEnvNames {
|
||||
if envVars != nil {
|
||||
if v, ok := envVars[keyName]; ok && v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
if v := os.Getenv(keyName); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ParseEnvFromConfig resolves environment variables from the langgraph.json
|
||||
// config. If the "env" field is a dict (map), those values are used directly.
|
||||
// If it is a string, it is treated as a path to a .env file (resolved relative
|
||||
// to the config file's directory). Otherwise, a .env file in the config
|
||||
// directory is attempted as a fallback.
|
||||
func ParseEnvFromConfig(configJSON map[string]any, configPath string) map[string]string {
|
||||
envField, ok := configJSON["env"]
|
||||
if !ok {
|
||||
// Fallback: try .env in config dir.
|
||||
return parseDotEnvFile(filepath.Join(filepath.Dir(configPath), ".env"))
|
||||
}
|
||||
|
||||
// If env is a dict (map[string]any), convert to map[string]string.
|
||||
if envMap, ok := envField.(map[string]any); ok && len(envMap) > 0 {
|
||||
result := make(map[string]string, len(envMap))
|
||||
for k, v := range envMap {
|
||||
result[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// If env is a string path, parse that .env file.
|
||||
if envStr, ok := envField.(string); ok && envStr != "" {
|
||||
envPath := filepath.Join(filepath.Dir(configPath), envStr)
|
||||
absPath, err := filepath.Abs(envPath)
|
||||
if err != nil {
|
||||
return map[string]string{}
|
||||
}
|
||||
if _, err := os.Stat(absPath); os.IsNotExist(err) {
|
||||
return map[string]string{}
|
||||
}
|
||||
return parseDotEnvFile(absPath)
|
||||
}
|
||||
|
||||
// Fallback: try .env in config dir.
|
||||
return parseDotEnvFile(filepath.Join(filepath.Dir(configPath), ".env"))
|
||||
}
|
||||
|
||||
// parseDotEnvFile reads a .env file and returns its key-value pairs. Lines
|
||||
// starting with # are treated as comments. Empty values are skipped.
|
||||
func parseDotEnvFile(path string) map[string]string {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return map[string]string{}
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
result := map[string]string{}
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
idx := strings.IndexByte(line, '=')
|
||||
if idx < 0 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(line[:idx])
|
||||
value := strings.TrimSpace(line[idx+1:])
|
||||
// Strip surrounding quotes if present.
|
||||
if len(value) >= 2 {
|
||||
if (value[0] == '"' && value[len(value)-1] == '"') ||
|
||||
(value[0] == '\'' && value[len(value)-1] == '\'') {
|
||||
value = value[1 : len(value)-1]
|
||||
}
|
||||
}
|
||||
if key != "" && value != "" {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// FindDeploymentIDByName lists deployments matching the given name and returns
|
||||
// the ID of the first exact match. Returns an empty string (and no error) if
|
||||
// no exact match is found.
|
||||
func FindDeploymentIDByName(client *HostBackendClient, name string) (string, error) {
|
||||
if name == "" {
|
||||
return "", nil
|
||||
}
|
||||
existing, err := client.ListDeployments(name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resources, ok := existing["resources"]
|
||||
if !ok {
|
||||
return "", nil
|
||||
}
|
||||
resourceList, ok := resources.([]any)
|
||||
if !ok {
|
||||
return "", nil
|
||||
}
|
||||
for _, item := range resourceList {
|
||||
dep, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
depName, _ := dep["name"].(string)
|
||||
if depName == name {
|
||||
if id, ok := dep["id"]; ok {
|
||||
return fmt.Sprintf("%v", id), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// ValidateDeploymentSelector ensures at least one of deploymentID or name is
|
||||
// provided.
|
||||
func ValidateDeploymentSelector(deploymentID, name string) error {
|
||||
if deploymentID != "" {
|
||||
return nil
|
||||
}
|
||||
if name == "" {
|
||||
return fmt.Errorf("either --deployment-id or --name is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResolvedReservedEnvVars returns the set of reserved environment variable
|
||||
// names that must not be sent as deployment secrets.
|
||||
func ResolvedReservedEnvVars() map[string]bool {
|
||||
// Return a copy to prevent callers from mutating the package-level map.
|
||||
result := make(map[string]bool, len(reservedEnvVars))
|
||||
for k, v := range reservedEnvVars {
|
||||
result[k] = v
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// TerminalStatuses are deployment statuses that indicate completion.
|
||||
var TerminalStatuses = map[string]bool{
|
||||
"DEPLOYED": true,
|
||||
"CREATE_FAILED": true,
|
||||
"BUILD_FAILED": true,
|
||||
"DEPLOY_FAILED": true,
|
||||
"SKIPPED": true,
|
||||
}
|
||||
|
||||
// PollDeploymentStatus polls a deployment until it reaches a terminal status or times out.
|
||||
func PollDeploymentStatus(client *HostBackendClient, deploymentID string, timeoutSeconds, pollIntervalSeconds int, onStatus func(string)) (string, error) {
|
||||
deadline := time.Now().Add(time.Duration(timeoutSeconds) * time.Second)
|
||||
interval := time.Duration(pollIntervalSeconds) * time.Second
|
||||
|
||||
for time.Now().Before(deadline) {
|
||||
resp, err := client.ListRevisions(deploymentID, 1)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
revisions, _ := resp["revisions"].([]any)
|
||||
if len(revisions) == 0 {
|
||||
time.Sleep(interval)
|
||||
continue
|
||||
}
|
||||
rev, _ := revisions[0].(map[string]any)
|
||||
status, _ := rev["status"].(string)
|
||||
if onStatus != nil {
|
||||
onStatus(status)
|
||||
}
|
||||
if TerminalStatuses[status] {
|
||||
return status, nil
|
||||
}
|
||||
time.Sleep(interval)
|
||||
}
|
||||
return "", fmt.Errorf("deployment timed out after %d seconds", timeoutSeconds)
|
||||
}
|
||||
|
||||
// SecretsFromEnv converts an env var map into a Secret slice, filtering out
|
||||
// reserved variable names and empty values.
|
||||
func SecretsFromEnv(envVars map[string]string) []Secret {
|
||||
var secrets []Secret
|
||||
for name, value := range envVars {
|
||||
if reservedEnvVars[name] {
|
||||
continue
|
||||
}
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
secrets = append(secrets, Secret{Name: name, Value: value})
|
||||
}
|
||||
return secrets
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"os"
|
||||
"sort"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeImageName(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"MyApp", "myapp"},
|
||||
{"", "app"},
|
||||
{"!!!", "app"},
|
||||
{"my-app", "my-app"},
|
||||
{"My Cool App", "my-cool-app"},
|
||||
{"..leading-dots", "leading-dots"},
|
||||
{"trailing-dots..", "trailing-dots"},
|
||||
{"hello_world.v2", "hello_world.v2"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.input, func(t *testing.T) {
|
||||
got := NormalizeImageName(tc.input)
|
||||
if got != tc.want {
|
||||
t.Errorf("NormalizeImageName(%q) = %q, want %q", tc.input, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeImageTag(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{"v1.2.3", "v1.2.3", false},
|
||||
{"", "latest", false},
|
||||
{"has space", "", true},
|
||||
{"valid_tag-1.0", "valid_tag-1.0", false},
|
||||
{"tag/slash", "", true},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.input, func(t *testing.T) {
|
||||
got, err := NormalizeImageTag(tc.input)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("NormalizeImageTag(%q) expected error, got nil", tc.input)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("NormalizeImageTag(%q) unexpected error: %v", tc.input, err)
|
||||
return
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("NormalizeImageTag(%q) = %q, want %q", tc.input, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDeploymentSelector(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
deploymentID string
|
||||
depName string
|
||||
wantErr bool
|
||||
}{
|
||||
{"both empty", "", "", true},
|
||||
{"id set", "abc-123", "", false},
|
||||
{"name set", "", "my-deploy", false},
|
||||
{"both set", "abc-123", "my-deploy", false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := ValidateDeploymentSelector(tc.deploymentID, tc.depName)
|
||||
if tc.wantErr && err == nil {
|
||||
t.Error("expected error, got nil")
|
||||
}
|
||||
if !tc.wantErr && err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretsFromEnv(t *testing.T) {
|
||||
envVars := map[string]string{
|
||||
"MY_SECRET": "value1",
|
||||
"ANOTHER_SECRET": "value2",
|
||||
"LANGCHAIN_API_KEY": "should-be-filtered",
|
||||
"POSTGRES_URI": "should-be-filtered",
|
||||
"EMPTY_VAR": "",
|
||||
}
|
||||
|
||||
secrets := SecretsFromEnv(envVars)
|
||||
|
||||
// Sort for deterministic comparison
|
||||
sort.Slice(secrets, func(i, j int) bool {
|
||||
return secrets[i].Name < secrets[j].Name
|
||||
})
|
||||
|
||||
if len(secrets) != 2 {
|
||||
t.Fatalf("expected 2 secrets, got %d: %+v", len(secrets), secrets)
|
||||
}
|
||||
|
||||
if secrets[0].Name != "ANOTHER_SECRET" || secrets[0].Value != "value2" {
|
||||
t.Errorf("unexpected secret[0]: %+v", secrets[0])
|
||||
}
|
||||
if secrets[1].Name != "MY_SECRET" || secrets[1].Value != "value1" {
|
||||
t.Errorf("unexpected secret[1]: %+v", secrets[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAPIKey(t *testing.T) {
|
||||
// Flag value takes precedence
|
||||
got := ResolveAPIKey("flag-key", map[string]string{"LANGSMITH_API_KEY": "env-key"})
|
||||
if got != "flag-key" {
|
||||
t.Errorf("expected flag-key, got %q", got)
|
||||
}
|
||||
|
||||
// envVars map is checked next
|
||||
got = ResolveAPIKey("", map[string]string{"LANGSMITH_API_KEY": "env-key"})
|
||||
if got != "env-key" {
|
||||
t.Errorf("expected env-key, got %q", got)
|
||||
}
|
||||
|
||||
// os.Getenv fallback
|
||||
os.Setenv("LANGSMITH_API_KEY", "os-env-key")
|
||||
defer os.Unsetenv("LANGSMITH_API_KEY")
|
||||
|
||||
got = ResolveAPIKey("", nil)
|
||||
if got != "os-env-key" {
|
||||
t.Errorf("expected os-env-key, got %q", got)
|
||||
}
|
||||
|
||||
// Flag still takes precedence over os env
|
||||
got = ResolveAPIKey("flag-value", nil)
|
||||
if got != "flag-value" {
|
||||
t.Errorf("expected flag-value, got %q", got)
|
||||
}
|
||||
|
||||
// Empty everything returns empty
|
||||
os.Unsetenv("LANGSMITH_API_KEY")
|
||||
os.Unsetenv("LANGGRAPH_HOST_API_KEY")
|
||||
os.Unsetenv("LANGCHAIN_API_KEY")
|
||||
got = ResolveAPIKey("", nil)
|
||||
if got != "" {
|
||||
t.Errorf("expected empty string, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -1,529 +0,0 @@
|
||||
// Package docker provides Docker compose generation, capability detection,
|
||||
// and image building for the LangGraph CLI.
|
||||
package docker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/langchain-ai/langgraph/libs/cli/internal/config"
|
||||
)
|
||||
|
||||
// DefaultPostgresURI is the default connection string used when no custom
|
||||
// Postgres URI is provided.
|
||||
const DefaultPostgresURI = "postgres://postgres:postgres@langgraph-postgres:5432/postgres?sslmode=disable"
|
||||
|
||||
// Version represents a semantic version with major, minor, and patch components.
|
||||
type Version struct {
|
||||
Major, Minor, Patch int
|
||||
}
|
||||
|
||||
// GreaterOrEqual returns true if v >= other.
|
||||
func (v Version) GreaterOrEqual(other Version) bool {
|
||||
if v.Major != other.Major {
|
||||
return v.Major > other.Major
|
||||
}
|
||||
if v.Minor != other.Minor {
|
||||
return v.Minor > other.Minor
|
||||
}
|
||||
return v.Patch >= other.Patch
|
||||
}
|
||||
|
||||
// DockerCapabilities describes the Docker environment available on the host.
|
||||
type DockerCapabilities struct {
|
||||
VersionDocker Version
|
||||
VersionCompose Version
|
||||
HealthcheckStartInterval bool
|
||||
ComposeType string // "plugin" or "standalone"
|
||||
}
|
||||
|
||||
// ComposeOpts configures the generated docker-compose YAML.
|
||||
type ComposeOpts struct {
|
||||
Port int
|
||||
DebuggerPort int // 0 means no debugger
|
||||
DebuggerBaseURL string // optional base URL for the debugger
|
||||
PostgresURI string // empty means use DefaultPostgresURI
|
||||
Image string // pre-built image name
|
||||
BaseImage string
|
||||
APIVersion string
|
||||
EngineRuntimeMode string // "combined_queue_worker" or "distributed"
|
||||
}
|
||||
|
||||
// BuildImageOpts configures docker image building.
|
||||
type BuildImageOpts struct {
|
||||
ConfigPath string
|
||||
ConfigJSON map[string]any
|
||||
BaseImage string
|
||||
APIVersion string
|
||||
Pull bool
|
||||
Tag string
|
||||
Passthrough []string
|
||||
InstallCommand string
|
||||
BuildCommand string
|
||||
DockerCommand []string // default: ["docker", "build"]
|
||||
ExtraFlags []string
|
||||
Verbose bool
|
||||
}
|
||||
|
||||
// OrderedMap preserves insertion order for map keys.
|
||||
type OrderedMap struct {
|
||||
Keys []string
|
||||
Values map[string]any
|
||||
}
|
||||
|
||||
// NewOrderedMap creates an empty OrderedMap.
|
||||
func NewOrderedMap() *OrderedMap {
|
||||
return &OrderedMap{
|
||||
Values: make(map[string]any),
|
||||
}
|
||||
}
|
||||
|
||||
// Set adds or updates a key-value pair, preserving insertion order.
|
||||
func (om *OrderedMap) Set(key string, value any) {
|
||||
if _, exists := om.Values[key]; !exists {
|
||||
om.Keys = append(om.Keys, key)
|
||||
}
|
||||
om.Values[key] = value
|
||||
}
|
||||
|
||||
// Get retrieves the value for a key.
|
||||
func (om *OrderedMap) Get(key string) (any, bool) {
|
||||
v, ok := om.Values[key]
|
||||
return v, ok
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ParseVersion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ParseVersion parses a version string like "1.2.3", "v1.2.3-alpha+build",
|
||||
// "1.2", or "1" into a Version.
|
||||
func ParseVersion(version string) Version {
|
||||
parts := strings.SplitN(version, ".", 3)
|
||||
|
||||
major := "0"
|
||||
minor := "0"
|
||||
patch := "0"
|
||||
|
||||
switch len(parts) {
|
||||
case 1:
|
||||
major = parts[0]
|
||||
case 2:
|
||||
major = parts[0]
|
||||
minor = parts[1]
|
||||
default:
|
||||
major = parts[0]
|
||||
minor = parts[1]
|
||||
patch = parts[2]
|
||||
}
|
||||
|
||||
// Strip "v" prefix from major
|
||||
major = strings.TrimPrefix(major, "v")
|
||||
|
||||
// Strip "-" and "+" suffixes from patch
|
||||
if idx := strings.IndexAny(patch, "-+"); idx >= 0 {
|
||||
patch = patch[:idx]
|
||||
}
|
||||
|
||||
majorInt, _ := strconv.Atoi(major)
|
||||
minorInt, _ := strconv.Atoi(minor)
|
||||
patchInt, _ := strconv.Atoi(patch)
|
||||
|
||||
return Version{Major: majorInt, Minor: minorInt, Patch: patchInt}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CanBuildLocally
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// CanBuildLocally checks whether local deployment builds can run on this machine.
|
||||
// It returns (ok, errorMessage). If ok is true, errorMessage is empty.
|
||||
func CanBuildLocally() (bool, string) {
|
||||
if _, err := exec.LookPath("docker"); err != nil {
|
||||
return false, "Docker is required but not installed.\n" +
|
||||
"Install Docker Desktop: https://docs.docker.com/get-docker/"
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, "docker", "info")
|
||||
cmd.Stdout = nil
|
||||
cmd.Stderr = nil
|
||||
if err := cmd.Run(); err != nil {
|
||||
return false, "Docker is installed but not running.\nStart Docker and try again."
|
||||
}
|
||||
|
||||
if runtime.GOARCH != "amd64" {
|
||||
ctx2, cancel2 := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel2()
|
||||
|
||||
buildx := exec.CommandContext(ctx2, "docker", "buildx", "version")
|
||||
buildx.Stdout = nil
|
||||
buildx.Stderr = nil
|
||||
if err := buildx.Run(); err != nil {
|
||||
arch := runtime.GOARCH
|
||||
// Try to match Python's platform.machine() naming for the error message
|
||||
if arch == "arm64" {
|
||||
arch = "aarch64"
|
||||
}
|
||||
return false, "Docker Buildx is required but not installed.\n" +
|
||||
"Your machine architecture (" + arch + ") requires Buildx to cross-compile images for linux/amd64.\n" +
|
||||
"Install Buildx: https://docs.docker.com/build/install-buildx/"
|
||||
}
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CheckCapabilities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// CheckCapabilities detects the Docker and Docker Compose versions available
|
||||
// on the host and returns a DockerCapabilities describing them.
|
||||
func CheckCapabilities() (*DockerCapabilities, error) {
|
||||
if _, err := exec.LookPath("docker"); err != nil {
|
||||
return nil, fmt.Errorf("Docker not installed")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
out, err := exec.CommandContext(ctx, "docker", "info", "-f", "{{json .}}").Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Docker not installed or not running")
|
||||
}
|
||||
|
||||
var info map[string]any
|
||||
if err := json.Unmarshal(out, &info); err != nil {
|
||||
return nil, fmt.Errorf("Docker not installed or not running")
|
||||
}
|
||||
|
||||
serverVersion, _ := info["ServerVersion"].(string)
|
||||
if serverVersion == "" {
|
||||
return nil, fmt.Errorf("Docker not running")
|
||||
}
|
||||
|
||||
// Try to find compose plugin
|
||||
var composeVersionStr string
|
||||
composeType := "plugin"
|
||||
|
||||
found := false
|
||||
if clientInfo, ok := info["ClientInfo"].(map[string]any); ok {
|
||||
if plugins, ok := clientInfo["Plugins"].([]any); ok {
|
||||
for _, p := range plugins {
|
||||
pm, ok := p.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
name, _ := pm["Name"].(string)
|
||||
if name == "compose" {
|
||||
composeVersionStr, _ = pm["Version"].(string)
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
// Fall back to standalone docker-compose
|
||||
if _, err := exec.LookPath("docker-compose"); err != nil {
|
||||
return nil, fmt.Errorf("Docker Compose not installed")
|
||||
}
|
||||
|
||||
ctx2, cancel2 := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel2()
|
||||
|
||||
out2, err := exec.CommandContext(ctx2, "docker-compose", "--version", "--short").Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Docker Compose not installed")
|
||||
}
|
||||
composeVersionStr = strings.TrimSpace(string(out2))
|
||||
composeType = "standalone"
|
||||
}
|
||||
|
||||
dockerVersion := ParseVersion(serverVersion)
|
||||
composeVersion := ParseVersion(composeVersionStr)
|
||||
|
||||
return &DockerCapabilities{
|
||||
VersionDocker: dockerVersion,
|
||||
VersionCompose: composeVersion,
|
||||
HealthcheckStartInterval: dockerVersion.GreaterOrEqual(Version{25, 0, 0}),
|
||||
ComposeType: composeType,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DebuggerCompose
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// DebuggerCompose returns a service config map for the langgraph-debugger
|
||||
// container, or nil if port is 0 (no debugger requested).
|
||||
func DebuggerCompose(port int, baseURL string) *OrderedMap {
|
||||
if port == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
dependsOn := NewOrderedMap()
|
||||
postgresCondition := NewOrderedMap()
|
||||
postgresCondition.Set("condition", "service_healthy")
|
||||
dependsOn.Set("langgraph-postgres", postgresCondition)
|
||||
|
||||
service := NewOrderedMap()
|
||||
service.Set("image", "langchain/langgraph-debugger")
|
||||
service.Set("restart", "on-failure")
|
||||
service.Set("depends_on", dependsOn)
|
||||
service.Set("ports", []any{fmt.Sprintf(`"%d:3968"`, port)})
|
||||
|
||||
if baseURL != "" {
|
||||
env := NewOrderedMap()
|
||||
env.Set("VITE_STUDIO_LOCAL_GRAPH_URL", baseURL)
|
||||
service.Set("environment", env)
|
||||
}
|
||||
|
||||
result := NewOrderedMap()
|
||||
result.Set("langgraph-debugger", service)
|
||||
return result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DictToYAML
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// DictToYAML converts an OrderedMap to a YAML string. For top-level keys
|
||||
// (indent < 2) it adds a blank line between entries (except the first).
|
||||
func DictToYAML(d *OrderedMap, indent int) string {
|
||||
var b strings.Builder
|
||||
for idx, key := range d.Keys {
|
||||
// Add blank line between top-level entries (except first)
|
||||
if idx >= 1 && indent < 2 {
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
space := strings.Repeat(" ", indent)
|
||||
value := d.Values[key]
|
||||
|
||||
switch v := value.(type) {
|
||||
case *OrderedMap:
|
||||
b.WriteString(fmt.Sprintf("%s%s:\n", space, key))
|
||||
b.WriteString(DictToYAML(v, indent+1))
|
||||
case []any:
|
||||
b.WriteString(fmt.Sprintf("%s%s:\n", space, key))
|
||||
for _, item := range v {
|
||||
b.WriteString(fmt.Sprintf("%s - %v\n", space, item))
|
||||
}
|
||||
default:
|
||||
b.WriteString(fmt.Sprintf("%s%s: %v\n", space, key, value))
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ComposeAsDict
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ComposeAsDict builds the docker-compose configuration as an OrderedMap.
|
||||
func ComposeAsDict(caps *DockerCapabilities, opts ComposeOpts) *OrderedMap {
|
||||
postgresURI := opts.PostgresURI
|
||||
includeDB := false
|
||||
if postgresURI == "" {
|
||||
includeDB = true
|
||||
postgresURI = DefaultPostgresURI
|
||||
}
|
||||
|
||||
services := NewOrderedMap()
|
||||
|
||||
// --- Redis service ---
|
||||
redisHealthcheck := NewOrderedMap()
|
||||
redisHealthcheck.Set("test", "redis-cli ping")
|
||||
redisHealthcheck.Set("interval", "5s")
|
||||
redisHealthcheck.Set("timeout", "1s")
|
||||
redisHealthcheck.Set("retries", 5)
|
||||
|
||||
redisService := NewOrderedMap()
|
||||
redisService.Set("image", "redis:6")
|
||||
redisService.Set("healthcheck", redisHealthcheck)
|
||||
services.Set("langgraph-redis", redisService)
|
||||
|
||||
// --- Postgres service (if no custom URI) ---
|
||||
if includeDB {
|
||||
pgEnv := NewOrderedMap()
|
||||
pgEnv.Set("POSTGRES_DB", "postgres")
|
||||
pgEnv.Set("POSTGRES_USER", "postgres")
|
||||
pgEnv.Set("POSTGRES_PASSWORD", "postgres")
|
||||
|
||||
pgHealthcheck := NewOrderedMap()
|
||||
pgHealthcheck.Set("test", "pg_isready -U postgres")
|
||||
pgHealthcheck.Set("start_period", "10s")
|
||||
pgHealthcheck.Set("timeout", "1s")
|
||||
pgHealthcheck.Set("retries", 5)
|
||||
|
||||
if caps.HealthcheckStartInterval {
|
||||
pgHealthcheck.Set("interval", "60s")
|
||||
pgHealthcheck.Set("start_interval", "1s")
|
||||
} else {
|
||||
pgHealthcheck.Set("interval", "5s")
|
||||
}
|
||||
|
||||
pgService := NewOrderedMap()
|
||||
pgService.Set("image", "pgvector/pgvector:pg16")
|
||||
pgService.Set("ports", []any{`"5433:5432"`})
|
||||
pgService.Set("environment", pgEnv)
|
||||
pgService.Set("command", []any{"postgres", "-c", "shared_preload_libraries=vector"})
|
||||
pgService.Set("volumes", []any{"langgraph-data:/var/lib/postgresql/data"})
|
||||
pgService.Set("healthcheck", pgHealthcheck)
|
||||
|
||||
services.Set("langgraph-postgres", pgService)
|
||||
}
|
||||
|
||||
// --- Debugger service (optional) ---
|
||||
if opts.DebuggerPort != 0 {
|
||||
debuggerMap := DebuggerCompose(opts.DebuggerPort, opts.DebuggerBaseURL)
|
||||
if debuggerMap != nil {
|
||||
debuggerService, _ := debuggerMap.Get("langgraph-debugger")
|
||||
services.Set("langgraph-debugger", debuggerService)
|
||||
}
|
||||
}
|
||||
|
||||
// --- langgraph-api service ---
|
||||
apiEnv := NewOrderedMap()
|
||||
apiEnv.Set("REDIS_URI", "redis://langgraph-redis:6379")
|
||||
apiEnv.Set("POSTGRES_URI", postgresURI)
|
||||
|
||||
if opts.EngineRuntimeMode == "distributed" {
|
||||
apiEnv.Set("N_JOBS_PER_WORKER", `"0"`)
|
||||
}
|
||||
|
||||
apiDependsOn := NewOrderedMap()
|
||||
redisCondition := NewOrderedMap()
|
||||
redisCondition.Set("condition", "service_healthy")
|
||||
apiDependsOn.Set("langgraph-redis", redisCondition)
|
||||
|
||||
if includeDB {
|
||||
pgCondition := NewOrderedMap()
|
||||
pgCondition.Set("condition", "service_healthy")
|
||||
apiDependsOn.Set("langgraph-postgres", pgCondition)
|
||||
}
|
||||
|
||||
apiService := NewOrderedMap()
|
||||
apiService.Set("ports", []any{fmt.Sprintf(`"%d:8000"`, opts.Port)})
|
||||
apiService.Set("depends_on", apiDependsOn)
|
||||
apiService.Set("environment", apiEnv)
|
||||
|
||||
if opts.Image != "" {
|
||||
apiService.Set("image", opts.Image)
|
||||
}
|
||||
|
||||
if caps.HealthcheckStartInterval {
|
||||
apiHealthcheck := NewOrderedMap()
|
||||
apiHealthcheck.Set("test", "python /api/healthcheck.py")
|
||||
apiHealthcheck.Set("interval", "60s")
|
||||
apiHealthcheck.Set("start_interval", "1s")
|
||||
apiHealthcheck.Set("start_period", "10s")
|
||||
apiService.Set("healthcheck", apiHealthcheck)
|
||||
}
|
||||
|
||||
services.Set("langgraph-api", apiService)
|
||||
|
||||
// --- Build final compose dict ---
|
||||
composeDict := NewOrderedMap()
|
||||
if includeDB {
|
||||
volumes := NewOrderedMap()
|
||||
volumeDriver := NewOrderedMap()
|
||||
volumeDriver.Set("driver", "local")
|
||||
volumes.Set("langgraph-data", volumeDriver)
|
||||
composeDict.Set("volumes", volumes)
|
||||
}
|
||||
composeDict.Set("services", services)
|
||||
|
||||
return composeDict
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Compose
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Compose generates a docker-compose YAML string from the given capabilities
|
||||
// and options.
|
||||
func Compose(caps *DockerCapabilities, opts ComposeOpts) string {
|
||||
d := ComposeAsDict(caps, opts)
|
||||
return DictToYAML(d, 0)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BuildDockerImage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// BuildDockerImage builds a Docker image from a LangGraph configuration.
|
||||
// It shells out to docker build (or a custom docker command) with the
|
||||
// generated Dockerfile piped via stdin.
|
||||
func BuildDockerImage(opts BuildImageOpts) error {
|
||||
dockerCmd := opts.DockerCommand
|
||||
if len(dockerCmd) == 0 {
|
||||
dockerCmd = []string{"docker", "build"}
|
||||
}
|
||||
|
||||
// Pull the base image first if requested.
|
||||
if opts.Pull {
|
||||
pullCmd := exec.Command("docker", "pull", opts.Tag)
|
||||
pullCmd.Stdout = os.Stdout
|
||||
pullCmd.Stderr = os.Stderr
|
||||
if err := pullCmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to pull image %s: %w", opts.Tag, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Build the docker build arguments.
|
||||
args := []string{
|
||||
"-f", "-", // read Dockerfile from stdin
|
||||
"-t", opts.Tag,
|
||||
}
|
||||
|
||||
// Determine build context.
|
||||
buildContext := "."
|
||||
if opts.ConfigPath != "" {
|
||||
// Use the parent directory of the config file by default.
|
||||
idx := strings.LastIndex(opts.ConfigPath, "/")
|
||||
if idx >= 0 {
|
||||
buildContext = opts.ConfigPath[:idx]
|
||||
}
|
||||
}
|
||||
|
||||
// Generate the Dockerfile using the real config.ConfigToDocker.
|
||||
dockerfile, additionalContexts, err := config.ConfigToDocker(opts.ConfigPath, opts.ConfigJSON, config.DockerOpts{
|
||||
BaseImage: opts.BaseImage,
|
||||
APIVersion: opts.APIVersion,
|
||||
InstallCommand: opts.InstallCommand,
|
||||
BuildCommand: opts.BuildCommand,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("generating Dockerfile: %w", err)
|
||||
}
|
||||
|
||||
// Add additional build contexts (for dependencies outside the main context).
|
||||
for name, path := range additionalContexts {
|
||||
args = append(args, "--build-context", fmt.Sprintf("%s=%s", name, path))
|
||||
}
|
||||
|
||||
// Assemble the full command.
|
||||
fullArgs := append(dockerCmd[1:], args...)
|
||||
fullArgs = append(fullArgs, opts.ExtraFlags...)
|
||||
fullArgs = append(fullArgs, opts.Passthrough...)
|
||||
fullArgs = append(fullArgs, buildContext)
|
||||
|
||||
cmd := exec.Command(dockerCmd[0], fullArgs...)
|
||||
cmd.Stdin = strings.NewReader(dockerfile)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
return cmd.Run()
|
||||
}
|
||||
@@ -1,290 +0,0 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func cleanEmptyLines(s string) string {
|
||||
lines := strings.Split(s, "\n")
|
||||
var result []string
|
||||
for _, line := range lines {
|
||||
if strings.TrimSpace(line) != "" {
|
||||
result = append(result, line)
|
||||
}
|
||||
}
|
||||
return strings.Join(result, "\n")
|
||||
}
|
||||
|
||||
var defaultCaps = &DockerCapabilities{
|
||||
VersionDocker: Version{Major: 26, Minor: 1, Patch: 1},
|
||||
VersionCompose: Version{Major: 2, Minor: 27, Patch: 0},
|
||||
HealthcheckStartInterval: false,
|
||||
}
|
||||
|
||||
func TestComposeCustomDBNoDebugger(t *testing.T) {
|
||||
port := 8123
|
||||
actual := Compose(defaultCaps, ComposeOpts{
|
||||
Port: port,
|
||||
PostgresURI: "custom_postgres_uri",
|
||||
})
|
||||
expected := fmt.Sprintf(`services:
|
||||
langgraph-redis:
|
||||
image: redis:6
|
||||
healthcheck:
|
||||
test: redis-cli ping
|
||||
interval: 5s
|
||||
timeout: 1s
|
||||
retries: 5
|
||||
langgraph-api:
|
||||
ports:
|
||||
- "%d:8000"
|
||||
depends_on:
|
||||
langgraph-redis:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
REDIS_URI: redis://langgraph-redis:6379
|
||||
POSTGRES_URI: custom_postgres_uri`, port)
|
||||
|
||||
if cleanEmptyLines(actual) != expected {
|
||||
t.Errorf("mismatch.\nExpected:\n%s\n\nGot:\n%s", expected, cleanEmptyLines(actual))
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeCustomDBWithHealthcheck(t *testing.T) {
|
||||
port := 8123
|
||||
capsHC := &DockerCapabilities{
|
||||
VersionDocker: Version{Major: 26, Minor: 1, Patch: 1},
|
||||
VersionCompose: Version{Major: 2, Minor: 27, Patch: 0},
|
||||
HealthcheckStartInterval: true,
|
||||
}
|
||||
actual := Compose(capsHC, ComposeOpts{
|
||||
Port: port,
|
||||
PostgresURI: "custom_postgres_uri",
|
||||
})
|
||||
expected := fmt.Sprintf(`services:
|
||||
langgraph-redis:
|
||||
image: redis:6
|
||||
healthcheck:
|
||||
test: redis-cli ping
|
||||
interval: 5s
|
||||
timeout: 1s
|
||||
retries: 5
|
||||
langgraph-api:
|
||||
ports:
|
||||
- "%d:8000"
|
||||
depends_on:
|
||||
langgraph-redis:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
REDIS_URI: redis://langgraph-redis:6379
|
||||
POSTGRES_URI: custom_postgres_uri
|
||||
healthcheck:
|
||||
test: python /api/healthcheck.py
|
||||
interval: 60s
|
||||
start_interval: 1s
|
||||
start_period: 10s`, port)
|
||||
|
||||
if cleanEmptyLines(actual) != expected {
|
||||
t.Errorf("mismatch.\nExpected:\n%s\n\nGot:\n%s", expected, cleanEmptyLines(actual))
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeDefaultDB(t *testing.T) {
|
||||
port := 8123
|
||||
actual := Compose(defaultCaps, ComposeOpts{Port: port})
|
||||
expected := fmt.Sprintf(`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-api:
|
||||
ports:
|
||||
- "%d:8000"
|
||||
depends_on:
|
||||
langgraph-redis:
|
||||
condition: service_healthy
|
||||
langgraph-postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
REDIS_URI: redis://langgraph-redis:6379
|
||||
POSTGRES_URI: %s`, port, DefaultPostgresURI)
|
||||
|
||||
if cleanEmptyLines(actual) != expected {
|
||||
t.Errorf("mismatch.\nExpected:\n%s\n\nGot:\n%s", expected, cleanEmptyLines(actual))
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeDistributedMode(t *testing.T) {
|
||||
port := 8123
|
||||
actual := Compose(defaultCaps, ComposeOpts{
|
||||
Port: port,
|
||||
PostgresURI: "custom_postgres_uri",
|
||||
EngineRuntimeMode: "distributed",
|
||||
})
|
||||
expected := fmt.Sprintf(`services:
|
||||
langgraph-redis:
|
||||
image: redis:6
|
||||
healthcheck:
|
||||
test: redis-cli ping
|
||||
interval: 5s
|
||||
timeout: 1s
|
||||
retries: 5
|
||||
langgraph-api:
|
||||
ports:
|
||||
- "%d:8000"
|
||||
depends_on:
|
||||
langgraph-redis:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
REDIS_URI: redis://langgraph-redis:6379
|
||||
POSTGRES_URI: custom_postgres_uri
|
||||
N_JOBS_PER_WORKER: "0"`, port)
|
||||
|
||||
if cleanEmptyLines(actual) != expected {
|
||||
t.Errorf("mismatch.\nExpected:\n%s\n\nGot:\n%s", expected, cleanEmptyLines(actual))
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeCombinedModeNoNJobs(t *testing.T) {
|
||||
actual := Compose(defaultCaps, ComposeOpts{
|
||||
Port: 8123,
|
||||
EngineRuntimeMode: "combined_queue_worker",
|
||||
})
|
||||
if strings.Contains(actual, "N_JOBS_PER_WORKER") {
|
||||
t.Error("combined mode should not contain N_JOBS_PER_WORKER")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeDebuggerDefaultDB(t *testing.T) {
|
||||
port := 8123
|
||||
debuggerPort := 8001
|
||||
actual := Compose(defaultCaps, ComposeOpts{
|
||||
Port: port,
|
||||
DebuggerPort: debuggerPort,
|
||||
})
|
||||
expected := fmt.Sprintf(`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:
|
||||
- "%d:3968"
|
||||
langgraph-api:
|
||||
ports:
|
||||
- "%d:8000"
|
||||
depends_on:
|
||||
langgraph-redis:
|
||||
condition: service_healthy
|
||||
langgraph-postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
REDIS_URI: redis://langgraph-redis:6379
|
||||
POSTGRES_URI: %s`, debuggerPort, port, DefaultPostgresURI)
|
||||
|
||||
if cleanEmptyLines(actual) != expected {
|
||||
t.Errorf("mismatch.\nExpected:\n%s\n\nGot:\n%s", expected, cleanEmptyLines(actual))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected Version
|
||||
}{
|
||||
{"1.2.3", Version{1, 2, 3}},
|
||||
{"v1.2.3", Version{1, 2, 3}},
|
||||
{"1.2.3-alpha", Version{1, 2, 3}},
|
||||
{"1.2.3+1", Version{1, 2, 3}},
|
||||
{"1.2.3-alpha+build", Version{1, 2, 3}},
|
||||
{"1.2", Version{1, 2, 0}},
|
||||
{"1", Version{1, 0, 0}},
|
||||
{"v28.1.1+1", Version{28, 1, 1}},
|
||||
{"2.0.0-beta.1+exp.sha.5114f85", Version{2, 0, 0}},
|
||||
{"v3.4.5-rc1+build.123", Version{3, 4, 5}},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
result := ParseVersion(tc.input)
|
||||
if result != tc.expected {
|
||||
t.Errorf("ParseVersion(%q) = %v, want %v", tc.input, result, tc.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionGreaterOrEqual(t *testing.T) {
|
||||
tests := []struct {
|
||||
v, other Version
|
||||
want bool
|
||||
}{
|
||||
{Version{25, 0, 0}, Version{25, 0, 0}, true},
|
||||
{Version{26, 1, 1}, Version{25, 0, 0}, true},
|
||||
{Version{24, 9, 9}, Version{25, 0, 0}, false},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
got := tc.v.GreaterOrEqual(tc.other)
|
||||
if got != tc.want {
|
||||
t.Errorf("%v.GreaterOrEqual(%v) = %v, want %v", tc.v, tc.other, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
// Package lgexec provides subprocess execution helpers for the LangGraph CLI.
|
||||
//
|
||||
// The package name is lgexec (rather than exec) to avoid shadowing the
|
||||
// standard library os/exec package.
|
||||
package lgexec
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// RunOpts configures how a subprocess is executed.
|
||||
type RunOpts struct {
|
||||
Stdin string // input to pass via stdin
|
||||
Verbose bool // pipe stdout/stderr to os.Stdout/os.Stderr
|
||||
Dir string // working directory
|
||||
Env []string // environment variables (KEY=VALUE)
|
||||
}
|
||||
|
||||
// Run executes the named program with the given arguments.
|
||||
//
|
||||
// When Verbose is true stdout and stderr are forwarded to the process's
|
||||
// os.Stdout / os.Stderr. Otherwise output is silently discarded.
|
||||
// A non-zero exit code is returned as an *ExitError.
|
||||
func Run(name string, args []string, opts RunOpts) error {
|
||||
cmd := exec.Command(name, args...)
|
||||
|
||||
if opts.Dir != "" {
|
||||
cmd.Dir = opts.Dir
|
||||
}
|
||||
if len(opts.Env) > 0 {
|
||||
cmd.Env = append(os.Environ(), opts.Env...)
|
||||
}
|
||||
|
||||
if opts.Stdin != "" {
|
||||
cmd.Stdin = strings.NewReader(opts.Stdin)
|
||||
}
|
||||
|
||||
if opts.Verbose {
|
||||
if opts.Stdin != "" {
|
||||
cmdStr := fmt.Sprintf("+ %s %s", name, strings.Join(args, " "))
|
||||
fmt.Printf("%s <\n%s\n", cmdStr, strings.Join(
|
||||
nonEmptyLines(opts.Stdin), "\n"))
|
||||
} else {
|
||||
fmt.Printf("+ %s %s\n", name, strings.Join(args, " "))
|
||||
}
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
} else {
|
||||
cmd.Stdout = io.Discard
|
||||
cmd.Stderr = io.Discard
|
||||
}
|
||||
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// RunCollect executes the named program and collects stdout and stderr.
|
||||
// Both are returned as strings. A non-zero exit code results in a non-nil error.
|
||||
func RunCollect(name string, args []string) (stdout, stderr string, err error) {
|
||||
cmd := exec.Command(name, args...)
|
||||
|
||||
var outBuf, errBuf bytes.Buffer
|
||||
cmd.Stdout = &outBuf
|
||||
cmd.Stderr = &errBuf
|
||||
|
||||
err = cmd.Run()
|
||||
return outBuf.String(), errBuf.String(), err
|
||||
}
|
||||
|
||||
// RunWithCallback executes the named program and invokes onStdout for each
|
||||
// line of stdout output. If onStdout returns true the callback is no longer
|
||||
// called and remaining stdout is forwarded directly to os.Stdout (matching
|
||||
// the Python CLI's monitor_stream behaviour).
|
||||
// Stderr is always forwarded to os.Stderr.
|
||||
func RunWithCallback(name string, args []string, onStdout func(string) bool) error {
|
||||
cmd := exec.Command(name, args...)
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
pipe, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot create stdout pipe: %w", err)
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("cannot start command: %w", err)
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(pipe)
|
||||
stopped := false
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if stopped {
|
||||
// After callback signalled stop, forward remaining output.
|
||||
fmt.Fprintln(os.Stdout, line)
|
||||
continue
|
||||
}
|
||||
if onStdout(line) {
|
||||
stopped = true
|
||||
}
|
||||
}
|
||||
if scanErr := scanner.Err(); scanErr != nil {
|
||||
// Drain but ignore read errors on stdout — the exit code matters.
|
||||
_ = scanErr
|
||||
}
|
||||
|
||||
return cmd.Wait()
|
||||
}
|
||||
|
||||
// nonEmptyLines splits s on newlines and returns lines that are not empty.
|
||||
func nonEmptyLines(s string) []string {
|
||||
var out []string
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
if line != "" {
|
||||
out = append(out, line)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,287 +0,0 @@
|
||||
package root
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRunHelp(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
exitCode := Run(nil, &stdout, &stderr)
|
||||
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("expected exit code 0, got %d", exitCode)
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected no stderr output, got %q", stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "validate") {
|
||||
t.Fatalf("expected help text to contain 'validate', got %q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunVersion(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
exitCode := Run([]string{"version"}, &stdout, &stderr)
|
||||
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("expected exit code 0, got %d", exitCode)
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected no stderr output, got %q", stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "langgraph") {
|
||||
t.Fatalf("unexpected stdout: %q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunUnknownCommand(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
exitCode := Run([]string{"nonexistent-cmd"}, &stdout, &stderr)
|
||||
|
||||
if exitCode != 1 {
|
||||
t.Fatalf("expected exit code 1, got %d", exitCode)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "is not a langgraph command") {
|
||||
t.Fatalf("unexpected stderr: %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func writeTempConfig(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "langgraph.json")
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("failed to write temp config: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestRunValidateWithValidConfig(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
path := writeTempConfig(t, `{"dependencies": ["langchain"], "graphs": {"agent": "./agent.py:graph"}}`)
|
||||
|
||||
exitCode := Run([]string{"validate", "-c", path}, &stdout, &stderr)
|
||||
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("expected exit code 0, got %d; stderr: %q", exitCode, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "is valid") {
|
||||
t.Fatalf("expected stdout to contain 'is valid', got %q", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "1 graph") {
|
||||
t.Fatalf("expected stdout to contain '1 graph', got %q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunValidateWithInvalidConfig(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
path := writeTempConfig(t, `{"graphs": {}}`)
|
||||
|
||||
exitCode := Run([]string{"validate", "-c", path}, &stdout, &stderr)
|
||||
|
||||
if exitCode != 1 {
|
||||
t.Fatalf("expected exit code 1, got %d", exitCode)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "No graphs found") {
|
||||
t.Fatalf("expected stderr to contain 'No graphs found', got %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunValidateWithInvalidJSON(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
path := writeTempConfig(t, `{invalid json`)
|
||||
|
||||
exitCode := Run([]string{"validate", "-c", path}, &stdout, &stderr)
|
||||
|
||||
if exitCode != 1 {
|
||||
t.Fatalf("expected exit code 1, got %d", exitCode)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "Invalid JSON") {
|
||||
t.Fatalf("expected stderr to contain 'Invalid JSON', got %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunValidateWithNonObjectJSON(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
path := writeTempConfig(t, `[]`)
|
||||
|
||||
exitCode := Run([]string{"validate", "-c", path}, &stdout, &stderr)
|
||||
|
||||
if exitCode != 1 {
|
||||
t.Fatalf("expected exit code 1, got %d", exitCode)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "top-level JSON value must be an object") {
|
||||
t.Fatalf("expected stderr to mention object-shaped config, got %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunValidateDefaultConfigMissing(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
// Use a path that definitely does not exist.
|
||||
nonexistent := filepath.Join(t.TempDir(), "langgraph.json")
|
||||
|
||||
exitCode := Run([]string{"validate", "-c", nonexistent}, &stdout, &stderr)
|
||||
|
||||
if exitCode != 1 {
|
||||
t.Fatalf("expected exit code 1, got %d", exitCode)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "does not exist") {
|
||||
t.Fatalf("expected stderr to contain 'does not exist', got %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunValidateWithUnknownKeys(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
path := writeTempConfig(t, `{"dependencies": ["langchain"], "graphs": {"agent": "./agent.py:graph"}, "grpahs": {}}`)
|
||||
|
||||
exitCode := Run([]string{"validate", "-c", path}, &stdout, &stderr)
|
||||
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("expected exit code 0, got %d; stderr: %q", exitCode, stderr.String())
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(strings.ToLower(out), "warning") {
|
||||
t.Fatalf("expected stdout to contain 'warning', got %q", out)
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(out), "did you mean") {
|
||||
t.Fatalf("expected stdout to contain 'did you mean', got %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunValidateHelp(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
exitCode := Run([]string{"validate", "--help"}, &stdout, &stderr)
|
||||
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("expected exit code 0, got %d", exitCode)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "Validate the LangGraph configuration file") {
|
||||
t.Fatalf("expected stdout to contain validate help text, got %q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunValidateMultipleGraphs(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
path := writeTempConfig(t, `{"dependencies": ["langchain"], "graphs": {"agent": "./a.py:g", "bot": "./b.py:g"}}`)
|
||||
|
||||
exitCode := Run([]string{"validate", "-c", path}, &stdout, &stderr)
|
||||
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("expected exit code 0, got %d; stderr: %q", exitCode, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "2 graphs found") {
|
||||
t.Fatalf("expected stdout to contain '2 graphs found', got %q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunValidateWithWarningsAndErrors(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
path := writeTempConfig(t, `{"graphs": {}, "grpahs": {}}`)
|
||||
|
||||
exitCode := Run([]string{"validate", "-c", path}, &stdout, &stderr)
|
||||
|
||||
if exitCode != 1 {
|
||||
t.Fatalf("expected exit code 1, got %d", exitCode)
|
||||
}
|
||||
errOut := stderr.String()
|
||||
if !strings.Contains(errOut, "No graphs found") {
|
||||
t.Fatalf("expected stderr to contain 'No graphs found', got %q", errOut)
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(errOut), "warning") {
|
||||
t.Fatalf("expected stderr to contain 'warning', got %q", errOut)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunValidateWithInvalidPackageJSON(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "langgraph.json")
|
||||
packagePath := filepath.Join(dir, "package.json")
|
||||
if err := os.WriteFile(
|
||||
configPath,
|
||||
[]byte(`{"node_version":"20","graphs":{"agent":"./agent.js:graph"}}`),
|
||||
0644,
|
||||
); err != nil {
|
||||
t.Fatalf("failed to write config: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(packagePath, []byte(`{invalid json`), 0644); err != nil {
|
||||
t.Fatalf("failed to write package.json: %v", err)
|
||||
}
|
||||
|
||||
exitCode := Run([]string{"validate", "-c", configPath}, &stdout, &stderr)
|
||||
|
||||
if exitCode != 1 {
|
||||
t.Fatalf("expected exit code 1, got %d", exitCode)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "Invalid package.json found") {
|
||||
t.Fatalf("expected stderr to mention invalid package.json, got %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDeployDelegatesToPythonCLI(t *testing.T) {
|
||||
t.Setenv("LANGGRAPH_CALLING_PYTHON", "/custom/python")
|
||||
|
||||
originalRunPythonSubprocess := runPythonSubprocess
|
||||
t.Cleanup(func() {
|
||||
runPythonSubprocess = originalRunPythonSubprocess
|
||||
})
|
||||
|
||||
var gotPython string
|
||||
var gotArgs []string
|
||||
runPythonSubprocess = func(
|
||||
pythonExe string,
|
||||
args []string,
|
||||
stdout io.Writer,
|
||||
stderr io.Writer,
|
||||
) error {
|
||||
gotPython = pythonExe
|
||||
gotArgs = append([]string(nil), args...)
|
||||
return nil
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := Run([]string{"deploy", "--remote", "--install-command", "make deps"}, &stdout, &stderr)
|
||||
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("expected exit code 0, got %d; stderr: %q", exitCode, stderr.String())
|
||||
}
|
||||
if gotPython != "/custom/python" {
|
||||
t.Fatalf("expected delegated python to be /custom/python, got %q", gotPython)
|
||||
}
|
||||
expected := []string{"-m", "langgraph_cli.cli", "deploy", "--remote", "--install-command", "make deps"}
|
||||
if strings.Join(gotArgs, "\x00") != strings.Join(expected, "\x00") {
|
||||
t.Fatalf("unexpected delegated args: got %q want %q", gotArgs, expected)
|
||||
}
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
// Package templates provides template definitions and project scaffolding
|
||||
// for the LangGraph CLI `new` command.
|
||||
package templates
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Template describes a project template with language-specific download URLs.
|
||||
type Template struct {
|
||||
Name string
|
||||
Description string
|
||||
Languages map[string]string // lang -> download URL
|
||||
}
|
||||
|
||||
// Templates is the ordered list of available project templates.
|
||||
var Templates = []Template{
|
||||
{
|
||||
Name: "Deep Agent",
|
||||
Description: "An opinionated deployment template for a Deep Agent.",
|
||||
Languages: map[string]string{
|
||||
"python": "https://github.com/langchain-ai/deep-agent-template/archive/refs/heads/main.zip",
|
||||
"js": "https://github.com/langchain-ai/deep-agent-template-js/archive/refs/heads/main.zip",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Agent",
|
||||
Description: "A simple agent that can be flexibly extended to many tools.",
|
||||
Languages: map[string]string{
|
||||
"python": "https://github.com/langchain-ai/simple-agent-template/archive/refs/heads/main.zip",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "New LangGraph Project",
|
||||
Description: "A simple, minimal chatbot with memory.",
|
||||
Languages: map[string]string{
|
||||
"python": "https://github.com/langchain-ai/new-langgraph-project/archive/refs/heads/main.zip",
|
||||
"js": "https://github.com/langchain-ai/new-langgraphjs-project/archive/refs/heads/main.zip",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// templateIDEntry maps a template ID to its download URL, template name, and language.
|
||||
type templateIDEntry struct {
|
||||
URL string
|
||||
Name string
|
||||
Language string
|
||||
}
|
||||
|
||||
// templateIDMap is built once at init time from the Templates slice.
|
||||
var templateIDMap map[string]templateIDEntry
|
||||
|
||||
func init() {
|
||||
templateIDMap = make(map[string]templateIDEntry)
|
||||
for _, t := range Templates {
|
||||
for lang, url := range t.Languages {
|
||||
if lang != "python" && lang != "js" {
|
||||
continue
|
||||
}
|
||||
id := toTemplateID(t.Name, lang)
|
||||
templateIDMap[id] = templateIDEntry{
|
||||
URL: url,
|
||||
Name: t.Name,
|
||||
Language: lang,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// toTemplateID converts a template name and language into a slug like "deep-agent-python".
|
||||
func toTemplateID(name, lang string) string {
|
||||
return strings.ToLower(strings.ReplaceAll(name, " ", "-")) + "-" + lang
|
||||
}
|
||||
|
||||
// ListTemplateIDs returns a sorted list of all available template IDs.
|
||||
func ListTemplateIDs() []string {
|
||||
ids := make([]string, 0, len(templateIDMap))
|
||||
for id := range templateIDMap {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
return ids
|
||||
}
|
||||
|
||||
// TemplateHelp returns a formatted help string listing available templates.
|
||||
func TemplateHelp() string {
|
||||
var b strings.Builder
|
||||
b.WriteString("The name of the template to use. Available options:\n")
|
||||
for _, id := range ListTemplateIDs() {
|
||||
entry := templateIDMap[id]
|
||||
// Find the description from the Templates slice.
|
||||
var desc string
|
||||
for _, t := range Templates {
|
||||
if t.Name == entry.Name {
|
||||
desc = t.Description
|
||||
break
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(&b, " %s: %s\n", id, desc)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// CreateNew creates a new LangGraph project at path using the given templateID.
|
||||
//
|
||||
// If templateID is empty an error listing available templates is returned (the
|
||||
// Go CLI is non-interactive, so we cannot prompt). If path is empty an error
|
||||
// is returned.
|
||||
func CreateNew(path, templateID string) error {
|
||||
if path == "" {
|
||||
return fmt.Errorf("path is required: specify the directory for the new project")
|
||||
}
|
||||
|
||||
// Resolve to absolute path.
|
||||
absPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot resolve path: %w", err)
|
||||
}
|
||||
path = absPath
|
||||
|
||||
// Check if path exists and is not empty.
|
||||
entries, err := os.ReadDir(path)
|
||||
if err == nil && len(entries) > 0 {
|
||||
return fmt.Errorf(
|
||||
"the specified directory already exists and is not empty: %s. "+
|
||||
"Aborting to prevent overwriting files", path)
|
||||
}
|
||||
|
||||
if templateID == "" {
|
||||
return fmt.Errorf(
|
||||
"template is required. Use one of the following template IDs:\n%s",
|
||||
TemplateHelp())
|
||||
}
|
||||
|
||||
entry, ok := templateIDMap[templateID]
|
||||
if !ok {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("template %q not found.\n", templateID))
|
||||
sb.WriteString("Please select from the available options:\n")
|
||||
for _, id := range ListTemplateIDs() {
|
||||
e := templateIDMap[id]
|
||||
var desc string
|
||||
for _, t := range Templates {
|
||||
if t.Name == e.Name {
|
||||
desc = t.Description
|
||||
break
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(&sb, " - %s: %s\n", id, desc)
|
||||
}
|
||||
return fmt.Errorf("%s", sb.String())
|
||||
}
|
||||
|
||||
if err := DownloadAndExtract(entry.URL, path); err != nil {
|
||||
return fmt.Errorf("failed to download template: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DownloadAndExtract downloads a ZIP archive from url and extracts it to
|
||||
// destPath, stripping the top-level wrapper directory that GitHub includes
|
||||
// in repository archives.
|
||||
func DownloadAndExtract(url, destPath string) error {
|
||||
// Ensure destination directory exists.
|
||||
if err := os.MkdirAll(destPath, 0o755); err != nil {
|
||||
return fmt.Errorf("cannot create destination directory: %w", err)
|
||||
}
|
||||
|
||||
// Download to a temporary file.
|
||||
tmpFile, err := os.CreateTemp("", "langgraph-template-*.zip")
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot create temp file: %w", err)
|
||||
}
|
||||
tmpPath := tmpFile.Name()
|
||||
defer os.Remove(tmpPath)
|
||||
|
||||
resp, err := http.Get(url) //nolint:gosec
|
||||
if err != nil {
|
||||
tmpFile.Close()
|
||||
return fmt.Errorf("HTTP request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
tmpFile.Close()
|
||||
return fmt.Errorf("HTTP %d: failed to download %s", resp.StatusCode, url)
|
||||
}
|
||||
|
||||
if _, err := io.Copy(tmpFile, resp.Body); err != nil {
|
||||
tmpFile.Close()
|
||||
return fmt.Errorf("failed to write ZIP data: %w", err)
|
||||
}
|
||||
tmpFile.Close()
|
||||
|
||||
// Open the ZIP archive.
|
||||
zr, err := zip.OpenReader(tmpPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open ZIP archive: %w", err)
|
||||
}
|
||||
defer zr.Close()
|
||||
|
||||
for _, f := range zr.File {
|
||||
// Strip the first path component (GitHub's wrapper directory).
|
||||
parts := strings.SplitN(f.Name, "/", 2)
|
||||
if len(parts) < 2 || parts[1] == "" {
|
||||
continue // skip the wrapper directory entry itself
|
||||
}
|
||||
relPath := parts[1]
|
||||
|
||||
outPath := filepath.Join(destPath, relPath)
|
||||
|
||||
// Ensure the output path is within destPath (zip-slip protection).
|
||||
if !strings.HasPrefix(filepath.Clean(outPath), filepath.Clean(destPath)+string(os.PathSeparator)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if f.FileInfo().IsDir() {
|
||||
if err := os.MkdirAll(outPath, f.Mode()); err != nil {
|
||||
return fmt.Errorf("cannot create directory %s: %w", outPath, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Create parent directories.
|
||||
if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
|
||||
return fmt.Errorf("cannot create parent directory: %w", err)
|
||||
}
|
||||
|
||||
outFile, err := os.OpenFile(outPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, f.Mode())
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot create file %s: %w", outPath, err)
|
||||
}
|
||||
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
outFile.Close()
|
||||
return fmt.Errorf("cannot read ZIP entry %s: %w", f.Name, err)
|
||||
}
|
||||
|
||||
if _, err := io.Copy(outFile, rc); err != nil {
|
||||
rc.Close()
|
||||
outFile.Close()
|
||||
return fmt.Errorf("failed writing %s: %w", outPath, err)
|
||||
}
|
||||
rc.Close()
|
||||
outFile.Close()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package version
|
||||
|
||||
var (
|
||||
Version = "dev"
|
||||
Commit = "unknown"
|
||||
Date = "unknown"
|
||||
)
|
||||
@@ -1086,11 +1086,6 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.3.tgz#6209321eb2c1712a7e7466422b8cb1fc0d9dd5d8"
|
||||
integrity sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==
|
||||
|
||||
"@types/uuid@^10.0.0":
|
||||
version "10.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-10.0.0.tgz#e9c07fe50da0f53dc24970cca94d619ff03f6f6d"
|
||||
integrity sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==
|
||||
|
||||
"@types/yargs-parser@*":
|
||||
version "21.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.3.tgz#815e30b786d2e8f0dcd85fd5bcf5e1a04d008f15"
|
||||
@@ -1782,13 +1777,6 @@ concat-map@0.0.1:
|
||||
resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
|
||||
integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==
|
||||
|
||||
console-table-printer@^2.12.1:
|
||||
version "2.15.0"
|
||||
resolved "https://registry.yarnpkg.com/console-table-printer/-/console-table-printer-2.15.0.tgz#5c808204640b8f024d545bde8aabe5d344dfadc1"
|
||||
integrity sha512-SrhBq4hYVjLCkBVOWaTzceJalvn5K1Zq5aQA6wXC/cYjI3frKWNPEMK3sZsJfNNQApvCQmgBcc13ZKmFj8qExw==
|
||||
dependencies:
|
||||
simple-wcswidth "^1.1.2"
|
||||
|
||||
convert-source-map@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a"
|
||||
@@ -3688,16 +3676,12 @@ keyv@^4.5.4:
|
||||
json-buffer "3.0.1"
|
||||
|
||||
"langsmith@>=0.5.0 <1.0.0":
|
||||
version "0.5.4"
|
||||
resolved "https://registry.yarnpkg.com/langsmith/-/langsmith-0.5.4.tgz#f75b82b08e30db72a7d1d595b341e9666bd525e5"
|
||||
integrity sha512-qYkNIoKpf0ZYt+cYzrDV+XI3FCexApmZmp8EMs3eDTMv0OvrHMLoxJ9IpkeoXJSX24+GPk0/jXjKx2hWerpy9w==
|
||||
version "0.5.20"
|
||||
resolved "https://registry.yarnpkg.com/langsmith/-/langsmith-0.5.20.tgz#4021847d2ccd5a86c5eb96060f9bb5f19f80eca5"
|
||||
integrity sha512-ULhLM8RswvQDXufLtNtvclHrWCBx8Cb5UPI6lAZC+8Dq59iHsVPz/3Ac9khWNm1VIvChRsuykixD/WrmzuuA3Q==
|
||||
dependencies:
|
||||
"@types/uuid" "^10.0.0"
|
||||
chalk "^4.1.2"
|
||||
console-table-printer "^2.12.1"
|
||||
p-queue "^6.6.2"
|
||||
semver "^7.6.3"
|
||||
uuid "^10.0.0"
|
||||
p-queue "6.6.2"
|
||||
uuid "10.0.0"
|
||||
|
||||
leven@^3.1.0:
|
||||
version "3.1.0"
|
||||
@@ -4007,7 +3991,7 @@ p-locate@^5.0.0:
|
||||
dependencies:
|
||||
p-limit "^3.0.2"
|
||||
|
||||
p-queue@^6.6.2:
|
||||
p-queue@6.6.2, p-queue@^6.6.2:
|
||||
version "6.6.2"
|
||||
resolved "https://registry.yarnpkg.com/p-queue/-/p-queue-6.6.2.tgz#2068a9dcf8e67dd0ec3e7a2bcb76810faa85e426"
|
||||
integrity sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==
|
||||
@@ -4303,7 +4287,7 @@ semver@^6.3.1:
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4"
|
||||
integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==
|
||||
|
||||
semver@^7.5.3, semver@^7.5.4, semver@^7.6.3, semver@^7.7.2, semver@^7.7.3:
|
||||
semver@^7.5.3, semver@^7.5.4, semver@^7.7.2, semver@^7.7.3:
|
||||
version "7.7.4"
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a"
|
||||
integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==
|
||||
@@ -4411,11 +4395,6 @@ signal-exit@^4.0.1:
|
||||
resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04"
|
||||
integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==
|
||||
|
||||
simple-wcswidth@^1.1.2:
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/simple-wcswidth/-/simple-wcswidth-1.1.2.tgz#66722f37629d5203f9b47c5477b1225b85d6525b"
|
||||
integrity sha512-j7piyCjAeTDSjzTSQ7DokZtMNwNlEAyxqSZeCS+CXH7fJ4jx3FuJ/mTW3mE+6JLs4VJBbcll0Kjn+KXI5t21Iw==
|
||||
|
||||
slash@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634"
|
||||
@@ -4870,7 +4849,7 @@ uri-js@^4.2.2:
|
||||
dependencies:
|
||||
punycode "^2.1.0"
|
||||
|
||||
uuid@^10.0.0:
|
||||
uuid@10.0.0, uuid@^10.0.0:
|
||||
version "10.0.0"
|
||||
resolved "https://registry.yarnpkg.com/uuid/-/uuid-10.0.0.tgz#5a95aa454e6e002725c79055fd42aaba30ca6294"
|
||||
integrity sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==
|
||||
|
||||
@@ -217,11 +217,6 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
|
||||
integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==
|
||||
|
||||
"@types/uuid@^10.0.0":
|
||||
version "10.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-10.0.0.tgz#e9c07fe50da0f53dc24970cca94d619ff03f6f6d"
|
||||
integrity sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==
|
||||
|
||||
"@typescript-eslint/eslint-plugin@^8.58.0":
|
||||
version "8.58.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz#ad40e492f1931f46da1bd888e52b9e56df9063aa"
|
||||
@@ -343,13 +338,6 @@ ajv@^6.14.0:
|
||||
json-schema-traverse "^0.4.1"
|
||||
uri-js "^4.2.2"
|
||||
|
||||
ansi-styles@^4.1.0:
|
||||
version "4.3.0"
|
||||
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937"
|
||||
integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==
|
||||
dependencies:
|
||||
color-convert "^2.0.1"
|
||||
|
||||
ansi-styles@^5.0.0:
|
||||
version "5.2.0"
|
||||
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b"
|
||||
@@ -508,38 +496,11 @@ camelcase@6:
|
||||
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a"
|
||||
integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==
|
||||
|
||||
chalk@^4.1.2:
|
||||
version "4.1.2"
|
||||
resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01"
|
||||
integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==
|
||||
dependencies:
|
||||
ansi-styles "^4.1.0"
|
||||
supports-color "^7.1.0"
|
||||
|
||||
color-convert@^2.0.1:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3"
|
||||
integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==
|
||||
dependencies:
|
||||
color-name "~1.1.4"
|
||||
|
||||
color-name@~1.1.4:
|
||||
version "1.1.4"
|
||||
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
|
||||
integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
|
||||
|
||||
concat-map@0.0.1:
|
||||
version "0.0.1"
|
||||
resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
|
||||
integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==
|
||||
|
||||
console-table-printer@^2.12.1:
|
||||
version "2.14.6"
|
||||
resolved "https://registry.yarnpkg.com/console-table-printer/-/console-table-printer-2.14.6.tgz#edfe0bf311fa2701922ed509443145ab51e06436"
|
||||
integrity sha512-MCBl5HNVaFuuHW6FGbL/4fB7N/ormCy+tQ+sxTrF6QtSbSNETvPuOVbkJBhzDgYhvjWGrTma4eYJa37ZuoQsPw==
|
||||
dependencies:
|
||||
simple-wcswidth "^1.0.1"
|
||||
|
||||
cross-spawn@^7.0.6:
|
||||
version "7.0.6"
|
||||
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f"
|
||||
@@ -1059,11 +1020,6 @@ has-bigints@^1.0.2:
|
||||
resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.1.0.tgz#28607e965ac967e03cd2a2c70a2636a1edad49fe"
|
||||
integrity sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==
|
||||
|
||||
has-flag@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b"
|
||||
integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==
|
||||
|
||||
has-property-descriptors@^1.0.0, has-property-descriptors@^1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz#963ed7d071dc7bf5f084c5bfbe0d1b6222586854"
|
||||
@@ -1372,16 +1328,12 @@ keyv@^4.5.4:
|
||||
json-buffer "3.0.1"
|
||||
|
||||
"langsmith@>=0.5.0 <1.0.0":
|
||||
version "0.5.4"
|
||||
resolved "https://registry.yarnpkg.com/langsmith/-/langsmith-0.5.4.tgz#f75b82b08e30db72a7d1d595b341e9666bd525e5"
|
||||
integrity sha512-qYkNIoKpf0ZYt+cYzrDV+XI3FCexApmZmp8EMs3eDTMv0OvrHMLoxJ9IpkeoXJSX24+GPk0/jXjKx2hWerpy9w==
|
||||
version "0.5.20"
|
||||
resolved "https://registry.yarnpkg.com/langsmith/-/langsmith-0.5.20.tgz#4021847d2ccd5a86c5eb96060f9bb5f19f80eca5"
|
||||
integrity sha512-ULhLM8RswvQDXufLtNtvclHrWCBx8Cb5UPI6lAZC+8Dq59iHsVPz/3Ac9khWNm1VIvChRsuykixD/WrmzuuA3Q==
|
||||
dependencies:
|
||||
"@types/uuid" "^10.0.0"
|
||||
chalk "^4.1.2"
|
||||
console-table-printer "^2.12.1"
|
||||
p-queue "^6.6.2"
|
||||
semver "^7.6.3"
|
||||
uuid "^10.0.0"
|
||||
p-queue "6.6.2"
|
||||
uuid "10.0.0"
|
||||
|
||||
levn@^0.4.1:
|
||||
version "0.4.1"
|
||||
@@ -1528,7 +1480,7 @@ p-locate@^5.0.0:
|
||||
dependencies:
|
||||
p-limit "^3.0.2"
|
||||
|
||||
p-queue@^6.6.2:
|
||||
p-queue@6.6.2, p-queue@^6.6.2:
|
||||
version "6.6.2"
|
||||
resolved "https://registry.yarnpkg.com/p-queue/-/p-queue-6.6.2.tgz#2068a9dcf8e67dd0ec3e7a2bcb76810faa85e426"
|
||||
integrity sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==
|
||||
@@ -1690,11 +1642,6 @@ semver@^6.3.1:
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4"
|
||||
integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==
|
||||
|
||||
semver@^7.6.3:
|
||||
version "7.7.2"
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.2.tgz#67d99fdcd35cec21e6f8b87a7fd515a33f982b58"
|
||||
integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==
|
||||
|
||||
semver@^7.7.3:
|
||||
version "7.7.4"
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a"
|
||||
@@ -1783,11 +1730,6 @@ side-channel@^1.1.0:
|
||||
side-channel-map "^1.0.1"
|
||||
side-channel-weakmap "^1.0.2"
|
||||
|
||||
simple-wcswidth@^1.0.1:
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/simple-wcswidth/-/simple-wcswidth-1.1.2.tgz#66722f37629d5203f9b47c5477b1225b85d6525b"
|
||||
integrity sha512-j7piyCjAeTDSjzTSQ7DokZtMNwNlEAyxqSZeCS+CXH7fJ4jx3FuJ/mTW3mE+6JLs4VJBbcll0Kjn+KXI5t21Iw==
|
||||
|
||||
stop-iteration-iterator@^1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz#f481ff70a548f6124d0312c3aa14cbfa7aa542ad"
|
||||
@@ -1838,13 +1780,6 @@ strip-json-comments@^3.1.1:
|
||||
resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006"
|
||||
integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==
|
||||
|
||||
supports-color@^7.1.0:
|
||||
version "7.2.0"
|
||||
resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da"
|
||||
integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==
|
||||
dependencies:
|
||||
has-flag "^4.0.0"
|
||||
|
||||
supports-preserve-symlinks-flag@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09"
|
||||
@@ -1966,7 +1901,7 @@ uri-js@^4.2.2:
|
||||
dependencies:
|
||||
punycode "^2.1.0"
|
||||
|
||||
uuid@^10.0.0:
|
||||
uuid@10.0.0, uuid@^10.0.0:
|
||||
version "10.0.0"
|
||||
resolved "https://registry.yarnpkg.com/uuid/-/uuid-10.0.0.tgz#5a95aa454e6e002725c79055fd42aaba30ca6294"
|
||||
integrity sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.4.21"
|
||||
__version__ = "0.4.24"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from .entrypoint import main
|
||||
from .cli import cli
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
cli()
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Shared ignore-file handling for local source filtering."""
|
||||
|
||||
import pathlib
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pathspec
|
||||
|
||||
_ALWAYS_EXCLUDE = [
|
||||
"__pycache__/",
|
||||
".git/",
|
||||
".venv/",
|
||||
"venv/",
|
||||
"node_modules/",
|
||||
".tox/",
|
||||
".mypy_cache/",
|
||||
]
|
||||
_ALWAYS_EXCLUDE_NAMES = frozenset(
|
||||
pattern.rstrip("/").split("/")[-1] for pattern in _ALWAYS_EXCLUDE
|
||||
)
|
||||
_GLOB_CHARS = frozenset("*?[")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _NegatedDockerignoreHints:
|
||||
exact_dirs: frozenset[pathlib.PurePosixPath] = frozenset()
|
||||
wildcard_prefixes: frozenset[pathlib.PurePosixPath] = frozenset()
|
||||
recurse_all: bool = False
|
||||
|
||||
def requires_dir_walk(self, path: pathlib.PurePosixPath) -> bool:
|
||||
if self.recurse_all or path in self.exact_dirs:
|
||||
return True
|
||||
return any(
|
||||
path == prefix or path in prefix.parents or prefix in path.parents
|
||||
for prefix in self.wildcard_prefixes
|
||||
)
|
||||
|
||||
|
||||
def _build_ignore_spec(
|
||||
directory: pathlib.Path, *, include_gitignore: bool = True
|
||||
) -> pathspec.PathSpec:
|
||||
"""Build a PathSpec combining built-in exclusions with ignore files.
|
||||
|
||||
Always excludes common non-source directories (`_ALWAYS_EXCLUDE`). On top
|
||||
of that, patterns from `.dockerignore` are merged in. `.gitignore` patterns
|
||||
are optional because some callers need Docker build-context semantics,
|
||||
while archive creation wants both files.
|
||||
"""
|
||||
lines: list[str] = list(_ALWAYS_EXCLUDE)
|
||||
ignore_files = [".dockerignore"]
|
||||
if include_gitignore:
|
||||
ignore_files.append(".gitignore")
|
||||
for name in ignore_files:
|
||||
ignore_file = directory / name
|
||||
if ignore_file.is_file():
|
||||
lines.extend(ignore_file.read_text(encoding="utf-8").splitlines())
|
||||
return pathspec.PathSpec.from_lines("gitwildmatch", lines)
|
||||
|
||||
|
||||
def _is_always_excluded(path: pathlib.PurePosixPath, *, is_dir: bool) -> bool:
|
||||
"""Whether `path` lives inside a built-in excluded directory."""
|
||||
parent_parts = path.parts if is_dir else path.parts[:-1]
|
||||
return any(part in _ALWAYS_EXCLUDE_NAMES for part in parent_parts)
|
||||
|
||||
|
||||
def _build_dockerignore_negation_hints(
|
||||
directory: pathlib.Path,
|
||||
) -> _NegatedDockerignoreHints:
|
||||
"""Summarize which ignored directories must still be traversed.
|
||||
|
||||
Most negations only require walking a small, concrete chain of parent
|
||||
directories (for example `!assets/keep.txt` requires entering `assets/`).
|
||||
Broader glob negations may force a wider walk.
|
||||
"""
|
||||
ignore_file = directory / ".dockerignore"
|
||||
if not ignore_file.is_file():
|
||||
return _NegatedDockerignoreHints()
|
||||
|
||||
exact_dirs: set[pathlib.PurePosixPath] = set()
|
||||
wildcard_prefixes: set[pathlib.PurePosixPath] = set()
|
||||
recurse_all = False
|
||||
|
||||
for raw_line in ignore_file.read_text(encoding="utf-8").splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#") or line.startswith("\\!"):
|
||||
continue
|
||||
if line.startswith("\\#"):
|
||||
line = line[1:]
|
||||
if not line.startswith("!"):
|
||||
continue
|
||||
|
||||
pattern = line[1:].lstrip("/")
|
||||
while pattern.startswith("./"):
|
||||
pattern = pattern[2:]
|
||||
pattern = pattern.rstrip("/")
|
||||
parts = [part for part in pattern.split("/") if part and part != "."]
|
||||
if not parts:
|
||||
recurse_all = True
|
||||
continue
|
||||
|
||||
wildcard_index = next(
|
||||
(
|
||||
idx
|
||||
for idx, part in enumerate(parts)
|
||||
if any(char in part for char in _GLOB_CHARS)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if wildcard_index is not None:
|
||||
literal_parts = parts[:wildcard_index]
|
||||
if not literal_parts:
|
||||
recurse_all = True
|
||||
continue
|
||||
wildcard_prefixes.add(pathlib.PurePosixPath(*literal_parts))
|
||||
continue
|
||||
|
||||
parent_parts = parts[:-1]
|
||||
for idx in range(1, len(parent_parts) + 1):
|
||||
exact_dirs.add(pathlib.PurePosixPath(*parent_parts[:idx]))
|
||||
|
||||
return _NegatedDockerignoreHints(
|
||||
exact_dirs=frozenset(exact_dirs),
|
||||
wildcard_prefixes=frozenset(wildcard_prefixes),
|
||||
recurse_all=recurse_all,
|
||||
)
|
||||
@@ -26,8 +26,15 @@ class LogData(TypedDict):
|
||||
params: dict[str, Any]
|
||||
|
||||
|
||||
def get_anonymized_params(kwargs: dict[str, Any]) -> dict[str, bool]:
|
||||
params = {}
|
||||
def get_anonymized_params(
|
||||
kwargs: dict[str, Any], *, cli_command: str
|
||||
) -> dict[str, bool | str]:
|
||||
params: dict[str, bool | str] = {}
|
||||
|
||||
if cli_command == "deploy" and (
|
||||
analytics_source := os.getenv("LANGGRAPH_CLI_ANALYTICS_SOURCE")
|
||||
):
|
||||
params["source"] = analytics_source
|
||||
|
||||
# anonymize params with values
|
||||
if config := kwargs.get("config"):
|
||||
@@ -88,7 +95,7 @@ def log_command(func):
|
||||
"python_version": platform.python_version(),
|
||||
"cli_version": __version__,
|
||||
"cli_command": func.__name__,
|
||||
"params": get_anonymized_params(kwargs),
|
||||
"params": get_anonymized_params(kwargs, cli_command=func.__name__),
|
||||
}
|
||||
|
||||
background_thread = threading.Thread(target=log_data, args=(data,))
|
||||
|
||||
@@ -9,35 +9,12 @@ from contextlib import contextmanager
|
||||
import click
|
||||
import pathspec
|
||||
|
||||
from langgraph_cli._ignore import _build_ignore_spec
|
||||
from langgraph_cli.config import Config, _assemble_local_deps
|
||||
|
||||
_WARN_SIZE = 50 * 1024 * 1024 # 50 MB
|
||||
_MAX_SIZE = 200 * 1024 * 1024 # 200 MB
|
||||
|
||||
_ALWAYS_EXCLUDE = [
|
||||
"__pycache__/",
|
||||
".git/",
|
||||
".venv/",
|
||||
"venv/",
|
||||
"node_modules/",
|
||||
".tox/",
|
||||
".mypy_cache/",
|
||||
]
|
||||
|
||||
|
||||
def _build_ignore_spec(directory: pathlib.Path) -> pathspec.PathSpec:
|
||||
"""Build a PathSpec combining built-in exclusions with .dockerignore and .gitignore.
|
||||
|
||||
Always excludes common non-source directories (_ALWAYS_EXCLUDE). On top of
|
||||
that, patterns from .dockerignore and .gitignore (if present) are merged in.
|
||||
"""
|
||||
lines: list[str] = list(_ALWAYS_EXCLUDE)
|
||||
for name in (".dockerignore", ".gitignore"):
|
||||
ignore_file = directory / name
|
||||
if ignore_file.is_file():
|
||||
lines.extend(ignore_file.read_text(encoding="utf-8").splitlines())
|
||||
return pathspec.PathSpec.from_lines("gitwildmatch", lines)
|
||||
|
||||
|
||||
def _tar_filter(tarinfo: tarfile.TarInfo) -> tarfile.TarInfo | None:
|
||||
"""Strip symlinks, hardlinks, and traversal paths from archive."""
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
"""User-facing entrypoint for the LangGraph CLI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
from collections.abc import Sequence
|
||||
|
||||
import click
|
||||
|
||||
from .cli import cli
|
||||
|
||||
_GO_CLI_FLAG = "LANGGRAPH_USE_GO_CLI"
|
||||
_GO_CLI_PATH_ENV = "LANGGRAPH_GO_CLI_PATH"
|
||||
_CALLING_PYTHON_ENV = "LANGGRAPH_CALLING_PYTHON"
|
||||
|
||||
_TRUE_VALUES = frozenset({"1", "true", "yes", "on"})
|
||||
|
||||
|
||||
def _legacy_cli(argv: Sequence[str] | None = None) -> None:
|
||||
cli.main(args=list(argv) if argv is not None else None, prog_name="langgraph")
|
||||
|
||||
|
||||
def _should_use_go_cli() -> bool:
|
||||
value = os.environ.get(_GO_CLI_FLAG, "")
|
||||
return value.strip().lower() in _TRUE_VALUES
|
||||
|
||||
|
||||
def _bundled_go_cli_path() -> pathlib.Path:
|
||||
binary_name = "langgraph.exe" if os.name == "nt" else "langgraph"
|
||||
return pathlib.Path(__file__).resolve().parent / "bin" / binary_name
|
||||
|
||||
|
||||
def _resolve_go_cli_path() -> pathlib.Path | None:
|
||||
override = os.environ.get(_GO_CLI_PATH_ENV)
|
||||
if override:
|
||||
path = pathlib.Path(override).expanduser()
|
||||
return path.resolve()
|
||||
|
||||
bundled = _bundled_go_cli_path()
|
||||
if bundled.is_file():
|
||||
return bundled
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _exec_go_cli(argv: Sequence[str]) -> None:
|
||||
path = _resolve_go_cli_path()
|
||||
if path is None:
|
||||
raise click.ClickException(
|
||||
"Go CLI requested via LANGGRAPH_USE_GO_CLI, but no langgraph binary was "
|
||||
"found. Set LANGGRAPH_GO_CLI_PATH or install a wheel that bundles the "
|
||||
"binary."
|
||||
)
|
||||
if not path.is_file():
|
||||
raise click.ClickException(
|
||||
f"LANGGRAPH_GO_CLI_PATH points to a missing file: {path}"
|
||||
)
|
||||
|
||||
env = os.environ.copy()
|
||||
env.setdefault(_CALLING_PYTHON_ENV, sys.executable)
|
||||
os.execvpe(str(path), [str(path), *argv], env)
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> None:
|
||||
args = list(sys.argv[1:] if argv is None else argv)
|
||||
try:
|
||||
if _should_use_go_cli():
|
||||
_exec_go_cli(args)
|
||||
_legacy_cli(args)
|
||||
except click.ClickException as exc:
|
||||
exc.show()
|
||||
raise SystemExit(exc.exit_code) from exc
|
||||
@@ -10,7 +10,13 @@ except ModuleNotFoundError: # pragma: no cover - exercised on Python 3.10.
|
||||
import tomli as tomllib
|
||||
|
||||
import click
|
||||
import pathspec
|
||||
|
||||
from langgraph_cli._ignore import (
|
||||
_build_dockerignore_negation_hints,
|
||||
_build_ignore_spec,
|
||||
_is_always_excluded,
|
||||
)
|
||||
from langgraph_cli.schemas import Config
|
||||
|
||||
|
||||
@@ -440,16 +446,32 @@ def _container_root_for_uv_lock_package(
|
||||
|
||||
|
||||
def _uv_lock_package_copy_items(
|
||||
package: UvLockPackage, plan: UvLockPlan
|
||||
package: UvLockPackage,
|
||||
plan: UvLockPlan,
|
||||
ignore_spec: pathspec.PathSpec,
|
||||
) -> tuple[tuple[pathlib.PurePosixPath, pathlib.PurePosixPath], ...]:
|
||||
# Skip entries that .dockerignore / built-in exclusions would strip from
|
||||
# the build context. Emitting `ADD <path>` for a file that Docker has
|
||||
# filtered out causes the build to fail with
|
||||
# "failed to compute cache key: <path> not found".
|
||||
if package.root != plan.project_root:
|
||||
relative_root = pathlib.PurePosixPath(
|
||||
*package.root.relative_to(plan.project_root).parts
|
||||
)
|
||||
if _is_always_excluded(relative_root, is_dir=True) or ignore_spec.match_file(
|
||||
f"{relative_root.as_posix()}/"
|
||||
):
|
||||
raise click.UsageError(
|
||||
f"Workspace member '{package.name}' at {relative_root} is "
|
||||
"excluded from the Docker build context, but uv.lock requires "
|
||||
"it to be copied into the build context. Remove the matching "
|
||||
"pattern or drop the member from [tool.uv.workspace].members."
|
||||
)
|
||||
return ((relative_root, plan.container_roots[package.root]),)
|
||||
|
||||
root_container = plan.container_roots[package.root]
|
||||
workspace_member_roots = plan.all_workspace_roots - {plan.project_root}
|
||||
negated_dockerignore_hints = _build_dockerignore_negation_hints(plan.project_root)
|
||||
|
||||
def iter_entries(
|
||||
current_dir: pathlib.Path,
|
||||
@@ -461,18 +483,32 @@ def _uv_lock_package_copy_items(
|
||||
# and excluded entirely otherwise.
|
||||
continue
|
||||
|
||||
descendant_member_roots = [
|
||||
ws_root
|
||||
for ws_root in workspace_member_roots
|
||||
if child in ws_root.parents
|
||||
]
|
||||
if child.is_dir() and descendant_member_roots:
|
||||
entries.extend(iter_entries(child))
|
||||
continue
|
||||
|
||||
relative_child = pathlib.PurePosixPath(
|
||||
*child.relative_to(plan.project_root).parts
|
||||
)
|
||||
is_dir = child.is_dir()
|
||||
if _is_always_excluded(relative_child, is_dir=is_dir):
|
||||
continue
|
||||
ignored = ignore_spec.match_file(
|
||||
f"{relative_child.as_posix()}/" if is_dir else relative_child.as_posix()
|
||||
)
|
||||
is_workspace_parent = is_dir and any(
|
||||
child in ws_root.parents for ws_root in workspace_member_roots
|
||||
)
|
||||
|
||||
if is_workspace_parent:
|
||||
entries.extend(iter_entries(child))
|
||||
continue
|
||||
if (
|
||||
is_dir
|
||||
and ignored
|
||||
and negated_dockerignore_hints.requires_dir_walk(relative_child)
|
||||
):
|
||||
entries.extend(iter_entries(child))
|
||||
continue
|
||||
if ignored:
|
||||
continue
|
||||
|
||||
entries.append(
|
||||
(relative_child, root_container.joinpath(*relative_child.parts))
|
||||
)
|
||||
@@ -956,10 +992,13 @@ def python_config_to_docker_uv_lock(
|
||||
docker_plan.add_raw("# -- End of uv.lock dependencies install --")
|
||||
docker_plan.add_blank()
|
||||
|
||||
ignore_spec = _build_ignore_spec(plan.project_root, include_gitignore=False)
|
||||
for package in plan.install_order:
|
||||
package_label = package.root.relative_to(plan.project_root).as_posix() or "."
|
||||
docker_plan.add_raw(f"# -- Adding workspace package {package_label} --")
|
||||
for source, destination in _uv_lock_package_copy_items(package, plan):
|
||||
for source, destination in _uv_lock_package_copy_items(
|
||||
package, plan, ignore_spec
|
||||
):
|
||||
docker_plan.add_raw(copy_from_project_root(source, destination.as_posix()))
|
||||
docker_plan.add_instruction(
|
||||
"WORKDIR", plan.container_roots[package.root].as_posix()
|
||||
|
||||
@@ -23,18 +23,18 @@ dependencies = [
|
||||
path = "langgraph_cli/__init__.py"
|
||||
[project.optional-dependencies]
|
||||
inmem = [
|
||||
"langgraph-api>=0.5.35,<0.8.0 ; python_version >= '3.11'",
|
||||
"langgraph-api>=0.5.35,<0.9.0 ; python_version >= '3.11'",
|
||||
"langgraph-runtime-inmem>=0.7 ; python_version >= '3.11'",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Source = "https://github.com/langchain-ai/langgraph/tree/main/libs/cli"
|
||||
Twitter = "https://x.com/LangChain"
|
||||
Twitter = "https://x.com/langchain_oss"
|
||||
Slack = "https://www.langchain.com/join-community"
|
||||
Reddit = "https://www.reddit.com/r/LangChain/"
|
||||
|
||||
[project.scripts]
|
||||
langgraph = "langgraph_cli.entrypoint:main"
|
||||
langgraph = "langgraph_cli.cli:cli"
|
||||
|
||||
[dependency-groups]
|
||||
test = [
|
||||
@@ -61,9 +61,6 @@ default-groups = ['dev']
|
||||
[tool.hatch.build.targets.wheel]
|
||||
include = ["langgraph_cli"]
|
||||
|
||||
[tool.hatch.build.targets.wheel.hooks.custom]
|
||||
path = "hatch_build.py"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
addopts = "--strict-markers --strict-config --durations=5 -vv"
|
||||
asyncio_mode = "auto"
|
||||
|
||||
@@ -99,6 +99,13 @@ class TestBuildIgnoreSpec:
|
||||
assert spec.match_file("app.log")
|
||||
assert spec.match_file("mod.pyc")
|
||||
|
||||
def test_can_skip_gitignore(self, tmp_path):
|
||||
(tmp_path / ".dockerignore").write_text("*.log\n")
|
||||
(tmp_path / ".gitignore").write_text("*.pyc\n")
|
||||
spec = _build_ignore_spec(tmp_path, include_gitignore=False)
|
||||
assert spec.match_file("app.log")
|
||||
assert not spec.match_file("mod.pyc")
|
||||
|
||||
def test_no_ignore_files_only_builtins(self, tmp_path):
|
||||
spec = _build_ignore_spec(tmp_path)
|
||||
assert spec.match_file("__pycache__/")
|
||||
|
||||
@@ -4,6 +4,7 @@ import os
|
||||
import pathlib
|
||||
import tempfile
|
||||
import textwrap
|
||||
from unittest.mock import patch
|
||||
|
||||
import click
|
||||
import pytest
|
||||
@@ -1855,6 +1856,364 @@ def test_config_to_docker_uv_lock_supports_single_uv_project_root():
|
||||
assert additional_contexts == {}
|
||||
|
||||
|
||||
def test_config_to_docker_uv_lock_skips_dockerignore_entries():
|
||||
"""Entries filtered by .dockerignore / built-in excludes must not appear
|
||||
as ADD lines. Docker fails to compute the cache key for paths that the
|
||||
build context has stripped."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmpdir_path = pathlib.Path(tmpdir)
|
||||
project_root = tmpdir_path / "single"
|
||||
project_root.mkdir()
|
||||
(project_root / "uv.lock").write_text("# uv lock file\n")
|
||||
(project_root / "pyproject.toml").write_text(
|
||||
textwrap.dedent(
|
||||
"""
|
||||
[project]
|
||||
name = "single-app"
|
||||
version = "0.1.0"
|
||||
dependencies = ["httpx>=0.28"]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=61"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
"""
|
||||
).strip()
|
||||
+ "\n"
|
||||
)
|
||||
(project_root / "langgraph.json").write_text("{}\n")
|
||||
(project_root / "src").mkdir()
|
||||
(project_root / "src" / "agent.py").write_text("graph = object()\n")
|
||||
(project_root / "README.md").write_text("# hi\n")
|
||||
|
||||
# Built-in exclusions — must never appear as ADD lines.
|
||||
(project_root / ".git").mkdir()
|
||||
(project_root / ".git" / "HEAD").write_text("ref: refs/heads/main\n")
|
||||
(project_root / ".venv").mkdir()
|
||||
(project_root / ".venv" / "pyvenv.cfg").write_text("home = /usr\n")
|
||||
(project_root / "__pycache__").mkdir()
|
||||
(project_root / "__pycache__" / "x.cpython-311.pyc").write_bytes(b"\x00")
|
||||
|
||||
# .dockerignore excludes .gitignore and a custom path.
|
||||
(project_root / ".dockerignore").write_text(".gitignore\nsecrets.env\n")
|
||||
(project_root / ".gitignore").write_text("*.pyc\n")
|
||||
(project_root / "secrets.env").write_text("TOKEN=abc\n")
|
||||
|
||||
config = validate_config(
|
||||
{
|
||||
"python_version": "3.11",
|
||||
"graphs": {"agent": "./src/agent.py:graph"},
|
||||
"source": {"kind": "uv"},
|
||||
}
|
||||
)
|
||||
docker, _ = config_to_docker(
|
||||
project_root / "langgraph.json",
|
||||
config,
|
||||
base_image="langchain/langgraph-api:0.2.47",
|
||||
)
|
||||
|
||||
for excluded in (
|
||||
"ADD .git ",
|
||||
"ADD .gitignore ",
|
||||
"ADD .venv ",
|
||||
"ADD __pycache__ ",
|
||||
"ADD secrets.env ",
|
||||
):
|
||||
assert excluded not in docker, (
|
||||
f"{excluded!r} should be filtered out of Dockerfile:\n{docker}"
|
||||
)
|
||||
|
||||
# The .dockerignore itself is still part of the context and should be
|
||||
# ADDed (Docker needs it at build time, and archive.py includes it).
|
||||
assert "ADD .dockerignore /deps/workspace/.dockerignore" in docker
|
||||
assert "ADD src /deps/workspace/src" in docker
|
||||
assert "ADD README.md /deps/workspace/README.md" in docker
|
||||
|
||||
|
||||
def test_config_to_docker_uv_lock_does_not_apply_gitignore():
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmpdir_path = pathlib.Path(tmpdir)
|
||||
project_root = tmpdir_path / "single"
|
||||
project_root.mkdir()
|
||||
(project_root / "uv.lock").write_text("# uv lock file\n")
|
||||
(project_root / "pyproject.toml").write_text(
|
||||
textwrap.dedent(
|
||||
"""
|
||||
[project]
|
||||
name = "single-app"
|
||||
version = "0.1.0"
|
||||
dependencies = ["httpx>=0.28"]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=61"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
"""
|
||||
).strip()
|
||||
+ "\n"
|
||||
)
|
||||
(project_root / "langgraph.json").write_text("{}\n")
|
||||
(project_root / "src").mkdir()
|
||||
(project_root / "src" / "agent.py").write_text("graph = object()\n")
|
||||
(project_root / "README.md").write_text("# hi\n")
|
||||
(project_root / ".gitignore").write_text("README.md\n")
|
||||
|
||||
config = validate_config(
|
||||
{
|
||||
"python_version": "3.11",
|
||||
"graphs": {"agent": "./src/agent.py:graph"},
|
||||
"source": {"kind": "uv"},
|
||||
}
|
||||
)
|
||||
docker, _ = config_to_docker(
|
||||
project_root / "langgraph.json",
|
||||
config,
|
||||
base_image="langchain/langgraph-api:0.2.47",
|
||||
)
|
||||
|
||||
assert "ADD README.md /deps/workspace/README.md" in docker
|
||||
|
||||
|
||||
def test_config_to_docker_uv_lock_skips_dockerignore_entries_in_workspace():
|
||||
"""Multi-member workspace: ignore patterns must filter root-level entries
|
||||
AND entries encountered while recursing into directories that contain
|
||||
workspace members (the `descendant_member_roots` branch)."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmpdir_path = pathlib.Path(tmpdir)
|
||||
project_root, config_path = _write_uv_lock_workspace(
|
||||
tmpdir_path,
|
||||
agent_dependencies=["workspace-root", "shared", "httpx>=0.28"],
|
||||
root_sources="[tool.uv.sources]\nshared = { workspace = true }\nworkspace-root = { workspace = true }",
|
||||
agent_sources="[tool.uv.sources]\nshared = { workspace = true }\nworkspace-root = { workspace = true }",
|
||||
)
|
||||
root_src = project_root / "src" / "workspace_root"
|
||||
root_src.mkdir(parents=True)
|
||||
(root_src / "__init__.py").write_text("__all__ = []\n")
|
||||
(project_root / "README.md").write_text("workspace root package\n")
|
||||
|
||||
# A non-member sibling of the `apps/agent` member that should be
|
||||
# filtered out via .dockerignore. This exercises the recursion into
|
||||
# `apps/` where `apps/agent` is kept (it's a member) but its sibling is
|
||||
# filtered.
|
||||
(project_root / "apps" / "scratch.txt").write_text("scratch\n")
|
||||
# A root-level path that .dockerignore excludes.
|
||||
(project_root / "secrets.env").write_text("TOKEN=abc\n")
|
||||
(project_root / ".dockerignore").write_text("secrets.env\napps/scratch.txt\n")
|
||||
|
||||
config = validate_config(
|
||||
{
|
||||
"python_version": "3.11",
|
||||
"graphs": {
|
||||
"agent": "../../apps/agent/src/agent/graph.py:graph",
|
||||
},
|
||||
"source": {"kind": "uv", "root": "../..", "package": "agent"},
|
||||
}
|
||||
)
|
||||
docker, _ = config_to_docker(
|
||||
config_path, config, base_image="langchain/langgraph-api:0.2.47"
|
||||
)
|
||||
|
||||
assert "COPY --from=uv-workspace-root src /deps/workspace/src" in docker
|
||||
assert (
|
||||
"COPY --from=uv-workspace-root README.md /deps/workspace/README.md"
|
||||
in docker
|
||||
)
|
||||
assert (
|
||||
"COPY --from=uv-workspace-root .dockerignore /deps/workspace/.dockerignore"
|
||||
in docker
|
||||
)
|
||||
assert "secrets.env" not in docker
|
||||
assert "apps/scratch.txt" not in docker
|
||||
# Workspace members themselves are still copied via their own per-member
|
||||
# COPY line — the sibling filter must not disturb this.
|
||||
assert (
|
||||
"COPY --from=uv-workspace-root apps/agent /deps/workspace/apps/agent"
|
||||
in docker
|
||||
)
|
||||
|
||||
|
||||
def test_config_to_docker_uv_lock_preserves_negated_dockerignore_descendants():
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmpdir_path = pathlib.Path(tmpdir)
|
||||
project_root = tmpdir_path / "single"
|
||||
project_root.mkdir()
|
||||
(project_root / "uv.lock").write_text("# uv lock file\n")
|
||||
(project_root / "pyproject.toml").write_text(
|
||||
textwrap.dedent(
|
||||
"""
|
||||
[project]
|
||||
name = "single-app"
|
||||
version = "0.1.0"
|
||||
dependencies = ["httpx>=0.28"]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=61"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
"""
|
||||
).strip()
|
||||
+ "\n"
|
||||
)
|
||||
(project_root / "langgraph.json").write_text("{}\n")
|
||||
(project_root / "src").mkdir()
|
||||
(project_root / "src" / "agent.py").write_text("graph = object()\n")
|
||||
(project_root / "assets").mkdir()
|
||||
(project_root / "assets" / "keep.txt").write_text("keep\n")
|
||||
(project_root / "assets" / "drop.txt").write_text("drop\n")
|
||||
(project_root / ".dockerignore").write_text("assets/\n!assets/keep.txt\n")
|
||||
|
||||
config = validate_config(
|
||||
{
|
||||
"python_version": "3.11",
|
||||
"graphs": {"agent": "./src/agent.py:graph"},
|
||||
"source": {"kind": "uv"},
|
||||
}
|
||||
)
|
||||
docker, _ = config_to_docker(
|
||||
project_root / "langgraph.json",
|
||||
config,
|
||||
base_image="langchain/langgraph-api:0.2.47",
|
||||
)
|
||||
|
||||
assert "ADD assets /deps/workspace/assets" not in docker
|
||||
assert "ADD assets/keep.txt /deps/workspace/assets/keep.txt" in docker
|
||||
assert "assets/drop.txt" not in docker
|
||||
|
||||
|
||||
def test_config_to_docker_uv_lock_prunes_unrelated_ignored_subtrees():
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmpdir_path = pathlib.Path(tmpdir)
|
||||
project_root = tmpdir_path / "single"
|
||||
project_root.mkdir()
|
||||
(project_root / "uv.lock").write_text("# uv lock file\n")
|
||||
(project_root / "pyproject.toml").write_text(
|
||||
textwrap.dedent(
|
||||
"""
|
||||
[project]
|
||||
name = "single-app"
|
||||
version = "0.1.0"
|
||||
dependencies = ["httpx>=0.28"]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=61"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
"""
|
||||
).strip()
|
||||
+ "\n"
|
||||
)
|
||||
(project_root / "langgraph.json").write_text("{}\n")
|
||||
(project_root / "src").mkdir()
|
||||
(project_root / "src" / "agent.py").write_text("graph = object()\n")
|
||||
(project_root / "assets").mkdir()
|
||||
(project_root / "assets" / "keep.txt").write_text("keep\n")
|
||||
(project_root / "vendor").mkdir()
|
||||
(project_root / "vendor" / "huge.txt").write_text("large\n")
|
||||
(project_root / ".dockerignore").write_text(
|
||||
"vendor/\nassets/\n!assets/keep.txt\n"
|
||||
)
|
||||
|
||||
config = validate_config(
|
||||
{
|
||||
"python_version": "3.11",
|
||||
"graphs": {"agent": "./src/agent.py:graph"},
|
||||
"source": {"kind": "uv"},
|
||||
}
|
||||
)
|
||||
|
||||
original_iterdir = pathlib.Path.iterdir
|
||||
|
||||
def guarded_iterdir(self):
|
||||
if self == project_root / "vendor":
|
||||
raise AssertionError("should not walk unrelated ignored subtree")
|
||||
return original_iterdir(self)
|
||||
|
||||
with patch.object(
|
||||
pathlib.Path, "iterdir", autospec=True, side_effect=guarded_iterdir
|
||||
):
|
||||
docker, _ = config_to_docker(
|
||||
project_root / "langgraph.json",
|
||||
config,
|
||||
base_image="langchain/langgraph-api:0.2.47",
|
||||
)
|
||||
|
||||
assert "ADD assets/keep.txt /deps/workspace/assets/keep.txt" in docker
|
||||
assert "vendor/huge.txt" not in docker
|
||||
|
||||
|
||||
def test_config_to_docker_uv_lock_never_reincludes_always_excluded_subtrees():
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmpdir_path = pathlib.Path(tmpdir)
|
||||
project_root = tmpdir_path / "single"
|
||||
project_root.mkdir()
|
||||
(project_root / "uv.lock").write_text("# uv lock file\n")
|
||||
(project_root / "pyproject.toml").write_text(
|
||||
textwrap.dedent(
|
||||
"""
|
||||
[project]
|
||||
name = "single-app"
|
||||
version = "0.1.0"
|
||||
dependencies = ["httpx>=0.28"]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=61"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
"""
|
||||
).strip()
|
||||
+ "\n"
|
||||
)
|
||||
(project_root / "langgraph.json").write_text("{}\n")
|
||||
(project_root / "src").mkdir()
|
||||
(project_root / "src" / "agent.py").write_text("graph = object()\n")
|
||||
(project_root / ".venv" / "pkg").mkdir(parents=True)
|
||||
(project_root / ".venv" / "pkg" / "keep.txt").write_text("keep\n")
|
||||
(project_root / "node_modules" / "pkg").mkdir(parents=True)
|
||||
(project_root / "node_modules" / "pkg" / "package.json").write_text("{}\n")
|
||||
(project_root / ".dockerignore").write_text(
|
||||
"!.venv/pkg/keep.txt\n!node_modules/pkg/package.json\n"
|
||||
)
|
||||
|
||||
config = validate_config(
|
||||
{
|
||||
"python_version": "3.11",
|
||||
"graphs": {"agent": "./src/agent.py:graph"},
|
||||
"source": {"kind": "uv"},
|
||||
}
|
||||
)
|
||||
docker, _ = config_to_docker(
|
||||
project_root / "langgraph.json",
|
||||
config,
|
||||
base_image="langchain/langgraph-api:0.2.47",
|
||||
)
|
||||
|
||||
assert ".venv/pkg/keep.txt" not in docker
|
||||
assert "node_modules/pkg/package.json" not in docker
|
||||
assert "ADD src /deps/workspace/src" in docker
|
||||
|
||||
|
||||
def test_config_to_docker_uv_lock_rejects_ignored_workspace_member():
|
||||
"""A workspace member matched by .dockerignore cannot be copied into the
|
||||
build context — uv.lock requires it, so fail loudly with a clear message."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmpdir_path = pathlib.Path(tmpdir)
|
||||
project_root, config_path = _write_uv_lock_workspace(
|
||||
tmpdir_path,
|
||||
agent_sources="[tool.uv.sources]\nshared = { workspace = true }",
|
||||
)
|
||||
(project_root / ".dockerignore").write_text("libs/shared\n")
|
||||
|
||||
config = validate_config(
|
||||
{
|
||||
"python_version": "3.11",
|
||||
"graphs": {"agent": "../../apps/agent/src/agent/graph.py:graph"},
|
||||
"source": {"kind": "uv", "root": "../..", "package": "agent"},
|
||||
"auth": {"path": "../../libs/shared/src/shared/auth.py:create_auth"},
|
||||
}
|
||||
)
|
||||
with pytest.raises(
|
||||
click.UsageError, match=r"Workspace member 'shared' at libs/shared"
|
||||
):
|
||||
config_to_docker(
|
||||
config_path, config, base_image="langchain/langgraph-api:0.2.47"
|
||||
)
|
||||
|
||||
|
||||
def test_config_to_docker_uv_lock_rejects_invalid_source_package_type():
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmpdir_path = pathlib.Path(tmpdir)
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from langgraph_cli import entrypoint
|
||||
|
||||
|
||||
def test_main_uses_legacy_cli_when_go_flag_disabled(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_legacy(argv):
|
||||
captured["argv"] = list(argv)
|
||||
|
||||
monkeypatch.delenv("LANGGRAPH_USE_GO_CLI", raising=False)
|
||||
monkeypatch.setattr(entrypoint, "_legacy_cli", fake_legacy)
|
||||
|
||||
entrypoint.main(["build", "-t", "demo"])
|
||||
|
||||
assert captured == {"argv": ["build", "-t", "demo"]}
|
||||
|
||||
|
||||
def test_main_execs_go_cli_when_flag_enabled(monkeypatch, tmp_path):
|
||||
binary_path = tmp_path / "langgraph"
|
||||
binary_path.write_text("")
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_execvpe(file, args, env):
|
||||
captured["file"] = file
|
||||
captured["args"] = args
|
||||
captured["env"] = env.copy()
|
||||
raise SystemExit(0)
|
||||
|
||||
monkeypatch.setenv("LANGGRAPH_USE_GO_CLI", "1")
|
||||
monkeypatch.setenv("LANGGRAPH_GO_CLI_PATH", str(binary_path))
|
||||
monkeypatch.delenv("LANGGRAPH_CALLING_PYTHON", raising=False)
|
||||
monkeypatch.setattr(entrypoint.os, "execvpe", fake_execvpe)
|
||||
|
||||
with pytest.raises(SystemExit, match="0"):
|
||||
entrypoint.main(["dev", "--port", "8000"])
|
||||
|
||||
assert captured["file"] == str(binary_path)
|
||||
assert captured["args"] == [str(binary_path), "dev", "--port", "8000"]
|
||||
assert captured["env"]["LANGGRAPH_CALLING_PYTHON"] == sys.executable
|
||||
|
||||
|
||||
def test_main_preserves_existing_calling_python(monkeypatch, tmp_path):
|
||||
binary_path = tmp_path / "langgraph"
|
||||
binary_path.write_text("")
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_execvpe(file, args, env):
|
||||
captured["env"] = env.copy()
|
||||
raise SystemExit(0)
|
||||
|
||||
monkeypatch.setenv("LANGGRAPH_USE_GO_CLI", "true")
|
||||
monkeypatch.setenv("LANGGRAPH_GO_CLI_PATH", str(binary_path))
|
||||
monkeypatch.setenv("LANGGRAPH_CALLING_PYTHON", "/custom/python")
|
||||
monkeypatch.setattr(entrypoint.os, "execvpe", fake_execvpe)
|
||||
|
||||
with pytest.raises(SystemExit, match="0"):
|
||||
entrypoint.main(["dev"])
|
||||
|
||||
assert captured["env"]["LANGGRAPH_CALLING_PYTHON"] == "/custom/python"
|
||||
|
||||
|
||||
def test_main_errors_when_go_cli_requested_but_binary_missing(
|
||||
monkeypatch, capsys, tmp_path
|
||||
):
|
||||
missing_path = tmp_path / "missing-langgraph"
|
||||
|
||||
monkeypatch.setenv("LANGGRAPH_USE_GO_CLI", "1")
|
||||
monkeypatch.setenv("LANGGRAPH_GO_CLI_PATH", str(missing_path))
|
||||
|
||||
with pytest.raises(SystemExit, match="1"):
|
||||
entrypoint.main(["build"])
|
||||
|
||||
err = capsys.readouterr().err
|
||||
assert "LANGGRAPH_GO_CLI_PATH points to a missing file" in err
|
||||
|
||||
|
||||
def test_resolve_go_cli_path_prefers_override(monkeypatch, tmp_path):
|
||||
override = tmp_path / "custom-langgraph"
|
||||
override.write_text("")
|
||||
bundled = tmp_path / "bin" / "langgraph"
|
||||
bundled.parent.mkdir()
|
||||
bundled.write_text("")
|
||||
|
||||
monkeypatch.setenv("LANGGRAPH_GO_CLI_PATH", str(override))
|
||||
monkeypatch.setattr(entrypoint, "_bundled_go_cli_path", lambda: bundled)
|
||||
|
||||
assert entrypoint._resolve_go_cli_path() == override.resolve()
|
||||
|
||||
|
||||
def test_resolve_go_cli_path_uses_bundled_binary(monkeypatch, tmp_path):
|
||||
bundled = tmp_path / "bin" / "langgraph"
|
||||
bundled.parent.mkdir()
|
||||
bundled.write_text("")
|
||||
|
||||
monkeypatch.delenv("LANGGRAPH_GO_CLI_PATH", raising=False)
|
||||
monkeypatch.setattr(entrypoint, "_bundled_go_cli_path", lambda: bundled)
|
||||
|
||||
assert entrypoint._resolve_go_cli_path() == bundled
|
||||
|
||||
|
||||
def test_resolve_go_cli_path_returns_none_when_nothing_available(monkeypatch):
|
||||
monkeypatch.delenv("LANGGRAPH_GO_CLI_PATH", raising=False)
|
||||
monkeypatch.setattr(
|
||||
entrypoint,
|
||||
"_bundled_go_cli_path",
|
||||
lambda: pathlib.Path("/definitely/not/present/langgraph"),
|
||||
)
|
||||
|
||||
assert entrypoint._resolve_go_cli_path() is None
|
||||
@@ -1,67 +0,0 @@
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def go_cli_env():
|
||||
if os.environ.get("LANGGRAPH_USE_GO_CLI") != "1":
|
||||
pytest.skip("Go CLI smoke tests only run when LANGGRAPH_USE_GO_CLI=1")
|
||||
|
||||
langgraph = shutil.which("langgraph")
|
||||
assert langgraph is not None, "langgraph executable is not installed"
|
||||
|
||||
env = os.environ.copy()
|
||||
env["LANGGRAPH_USE_GO_CLI"] = "1"
|
||||
return langgraph, env
|
||||
|
||||
|
||||
def test_go_cli_help(go_cli_env):
|
||||
langgraph, env = go_cli_env
|
||||
|
||||
result = subprocess.run(
|
||||
[langgraph, "--help"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "Usage: langgraph" in result.stdout
|
||||
|
||||
|
||||
def test_go_cli_validate(go_cli_env, tmp_path):
|
||||
langgraph, env = go_cli_env
|
||||
config_path = tmp_path / "langgraph.json"
|
||||
config_path.write_text(
|
||||
'{"dependencies": ["langchain"], "graphs": {"agent": "./agent.py:graph"}}'
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
[langgraph, "validate", "-c", str(config_path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "is valid" in result.stdout
|
||||
|
||||
|
||||
def test_go_cli_dev_help(go_cli_env):
|
||||
langgraph, env = go_cli_env
|
||||
|
||||
result = subprocess.run(
|
||||
[langgraph, "dev", "--help"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "Run LangGraph API server in development mode" in result.stdout
|
||||
Generated
+6
-6
@@ -215,7 +215,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langchain-core"
|
||||
version = "1.2.27"
|
||||
version = "1.2.28"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jsonpatch" },
|
||||
@@ -227,9 +227,9 @@ dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "uuid-utils" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/13/5c/56d19a252bbb26247b7a7cd20821d48804d7ca03212fec709cd8db7c2516/langchain_core-1.2.27.tar.gz", hash = "sha256:c18372e4c4c1454d49bf23a2e484431e71bd39b64173a0f621f0fc283d7183a4", size = 844935, upload-time = "2026-04-07T14:56:32.364Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f8/a4/317a1a3ac1df33a64adb3670bf88bbe3b3d5baa274db6863a979db472897/langchain_core-1.2.28.tar.gz", hash = "sha256:271a3d8bd618f795fdeba112b0753980457fc90537c46a0c11998516a74dc2cb", size = 846119, upload-time = "2026-04-08T18:19:34.867Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/c3/6e0865bc130c448270eb9511b47863a3f9145cdb519b19f6e4758fa63d6f/langchain_core-1.2.27-py3-none-any.whl", hash = "sha256:9ecd6b0393b969fe88f6b9b309367134080ab095946d79e6937dd3911aa42bd5", size = 508315, upload-time = "2026-04-07T14:56:30.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/92/32f785f077c7e898da97064f113c73fbd9ad55d1e2169cf3a391b183dedb/langchain_core-1.2.28-py3-none-any.whl", hash = "sha256:80764232581eaf8057bcefa71dbf8adc1f6a28d257ebd8b95ba9b8b452e8c6ac", size = 508727, upload-time = "2026-04-08T18:19:32.823Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -290,7 +290,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langsmith"
|
||||
version = "0.7.26"
|
||||
version = "0.7.31"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
@@ -303,9 +303,9 @@ dependencies = [
|
||||
{ name = "xxhash" },
|
||||
{ name = "zstandard" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/76/86/6de4f6f0451a9658f26f633e0bb090552a4dafd7df3f1ae7f0d40558e67e/langsmith-0.7.26.tar.gz", hash = "sha256:a3e06f3d689ce7195717aa6b8f91082319819ec7ea9b9a62cdcd3d9dc25bfc7b", size = 1146118, upload-time = "2026-04-06T15:01:03.336Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e6/11/696019490992db5c87774dc20515529ef42a01e1d770fb754ed6d9b12fb0/langsmith-0.7.31.tar.gz", hash = "sha256:331ee4f7c26bb5be4022b9859b7d7b122cbf8c9d01d9f530114c1914b0349ffb", size = 1178480, upload-time = "2026-04-14T17:55:41.242Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/81/8e/7eb7d65ce62e98e74b9f18f193ea7ac3996d4fbd71fffcc67d0f7ba3103e/langsmith-0.7.26-py3-none-any.whl", hash = "sha256:fe5c877972cea450c1c48251c8fae0f18543c8d19dfdb9ff9a9c4263763dde4e", size = 360160, upload-time = "2026-04-06T15:01:01.516Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/a1/a013cf458c301cda86a213dd153ce0a01c93f1ab5833f951e6a44c9763ce/langsmith-0.7.31-py3-none-any.whl", hash = "sha256:0291d49203f6e80dda011af1afda61eb0595a4d697adb684590a8805e1d61fb6", size = 373276, upload-time = "2026-04-14T17:55:39.677Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Generated
+6
-6
@@ -191,7 +191,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langchain-core"
|
||||
version = "1.2.27"
|
||||
version = "1.2.28"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jsonpatch" },
|
||||
@@ -203,9 +203,9 @@ dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "uuid-utils" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/13/5c/56d19a252bbb26247b7a7cd20821d48804d7ca03212fec709cd8db7c2516/langchain_core-1.2.27.tar.gz", hash = "sha256:c18372e4c4c1454d49bf23a2e484431e71bd39b64173a0f621f0fc283d7183a4", size = 844935, upload-time = "2026-04-07T14:56:32.364Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f8/a4/317a1a3ac1df33a64adb3670bf88bbe3b3d5baa274db6863a979db472897/langchain_core-1.2.28.tar.gz", hash = "sha256:271a3d8bd618f795fdeba112b0753980457fc90537c46a0c11998516a74dc2cb", size = 846119, upload-time = "2026-04-08T18:19:34.867Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/c3/6e0865bc130c448270eb9511b47863a3f9145cdb519b19f6e4758fa63d6f/langchain_core-1.2.27-py3-none-any.whl", hash = "sha256:9ecd6b0393b969fe88f6b9b309367134080ab095946d79e6937dd3911aa42bd5", size = 508315, upload-time = "2026-04-07T14:56:30.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/92/32f785f077c7e898da97064f113c73fbd9ad55d1e2169cf3a391b183dedb/langchain_core-1.2.28-py3-none-any.whl", hash = "sha256:80764232581eaf8057bcefa71dbf8adc1f6a28d257ebd8b95ba9b8b452e8c6ac", size = 508727, upload-time = "2026-04-08T18:19:32.823Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -266,7 +266,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langsmith"
|
||||
version = "0.7.26"
|
||||
version = "0.7.31"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
@@ -279,9 +279,9 @@ dependencies = [
|
||||
{ name = "xxhash" },
|
||||
{ name = "zstandard" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/76/86/6de4f6f0451a9658f26f633e0bb090552a4dafd7df3f1ae7f0d40558e67e/langsmith-0.7.26.tar.gz", hash = "sha256:a3e06f3d689ce7195717aa6b8f91082319819ec7ea9b9a62cdcd3d9dc25bfc7b", size = 1146118, upload-time = "2026-04-06T15:01:03.336Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e6/11/696019490992db5c87774dc20515529ef42a01e1d770fb754ed6d9b12fb0/langsmith-0.7.31.tar.gz", hash = "sha256:331ee4f7c26bb5be4022b9859b7d7b122cbf8c9d01d9f530114c1914b0349ffb", size = 1178480, upload-time = "2026-04-14T17:55:41.242Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/81/8e/7eb7d65ce62e98e74b9f18f193ea7ac3996d4fbd71fffcc67d0f7ba3103e/langsmith-0.7.26-py3-none-any.whl", hash = "sha256:fe5c877972cea450c1c48251c8fae0f18543c8d19dfdb9ff9a9c4263763dde4e", size = 360160, upload-time = "2026-04-06T15:01:01.516Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/a1/a013cf458c301cda86a213dd153ce0a01c93f1ab5833f951e6a44c9763ce/langsmith-0.7.31-py3-none-any.whl", hash = "sha256:0291d49203f6e80dda011af1afda61eb0595a4d697adb684590a8805e1d61fb6", size = 373276, upload-time = "2026-04-14T17:55:39.677Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Generated
+515
-433
File diff suppressed because it is too large
Load Diff
@@ -18,7 +18,7 @@
|
||||
<a href="https://pypi.org/project/langgraph/" target="_blank"><img src="https://img.shields.io/pypi/v/langgraph.svg?label=%20" alt="Version"></a>
|
||||
<a href="https://github.com/langchain-ai/langgraph/issues" target="_blank"><img src="https://img.shields.io/github/issues-raw/langchain-ai/langgraph" alt="Open Issues"></a>
|
||||
<a href="https://docs.langchain.com/oss/python/langgraph/overview" target="_blank"><img src="https://img.shields.io/badge/docs-latest-blue" alt="Docs"></a>
|
||||
<a href="https://x.com/langchain" target="_blank"><img src="https://img.shields.io/twitter/url/https/twitter.com/langchain.svg?style=social&label=Follow%20%40LangChain" alt="Twitter / X"></a>
|
||||
<a href="https://x.com/langchain_oss" target="_blank"><img src="https://img.shields.io/twitter/url/https/twitter.com/langchain_oss.svg?style=social&label=Follow%20%40LangChain" alt="Twitter / X"></a>
|
||||
</div>
|
||||
|
||||
<br>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import ChainMap
|
||||
from collections.abc import Mapping, Sequence
|
||||
from collections.abc import Sequence
|
||||
from os import getenv
|
||||
from typing import Any, cast
|
||||
|
||||
@@ -217,14 +217,16 @@ def get_callback_manager_for_config(
|
||||
callbacks.add_tags(all_tags)
|
||||
if metadata := config.get("metadata"):
|
||||
callbacks.add_metadata(metadata)
|
||||
return callbacks
|
||||
manager = callbacks
|
||||
else:
|
||||
# otherwise create a new manager
|
||||
return CallbackManager.configure(
|
||||
manager = CallbackManager.configure(
|
||||
inheritable_callbacks=config.get("callbacks"),
|
||||
inheritable_tags=all_tags,
|
||||
inheritable_metadata=config.get("metadata"),
|
||||
langsmith_inheritable_metadata=_get_tracing_metadata_defaults(config),
|
||||
)
|
||||
return manager
|
||||
|
||||
|
||||
def get_async_callback_manager_for_config(
|
||||
@@ -255,14 +257,16 @@ def get_async_callback_manager_for_config(
|
||||
callbacks.add_tags(all_tags)
|
||||
if metadata := config.get("metadata"):
|
||||
callbacks.add_metadata(metadata)
|
||||
return callbacks
|
||||
manager = callbacks
|
||||
else:
|
||||
# otherwise create a new manager
|
||||
return AsyncCallbackManager.configure(
|
||||
manager = AsyncCallbackManager.configure(
|
||||
inheritable_callbacks=config.get("callbacks"),
|
||||
inheritable_tags=all_tags,
|
||||
inheritable_metadata=config.get("metadata"),
|
||||
langsmith_inheritable_metadata=_get_tracing_metadata_defaults(config),
|
||||
)
|
||||
return manager
|
||||
|
||||
|
||||
def _is_not_empty(value: Any) -> bool:
|
||||
@@ -308,22 +312,54 @@ def ensure_config(*configs: RunnableConfig | None) -> RunnableConfig:
|
||||
for k, v in config.items():
|
||||
if _is_not_empty(v) and k not in CONFIG_KEYS:
|
||||
empty[CONF][k] = v
|
||||
_empty_metadata = empty["metadata"]
|
||||
for key, value in empty[CONF].items():
|
||||
if _exclude_as_metadata(key, value, _empty_metadata):
|
||||
continue
|
||||
_empty_metadata[key] = value
|
||||
|
||||
configurable = empty.get("configurable")
|
||||
metadata = empty.get("metadata")
|
||||
if configurable and metadata is not None:
|
||||
for key in _PROPAGATE_TO_METADATA:
|
||||
if key in metadata:
|
||||
continue
|
||||
value = configurable.get(key)
|
||||
if value:
|
||||
metadata[key] = value
|
||||
return empty
|
||||
|
||||
|
||||
_OMIT = ("key", "token", "secret", "password", "auth")
|
||||
|
||||
|
||||
def _exclude_as_metadata(key: str, value: Any, metadata: Mapping[str, Any]) -> bool:
|
||||
def _exclude_as_metadata(key: str, value: Any) -> bool:
|
||||
key_lower = key.casefold()
|
||||
return (
|
||||
key.startswith("__")
|
||||
or not isinstance(value, (str, int, float, bool))
|
||||
or key in metadata
|
||||
or any(substr in key_lower for substr in _OMIT)
|
||||
)
|
||||
|
||||
|
||||
def _get_tracing_metadata_defaults(
|
||||
config: RunnableConfig,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Get tracer-only metadata defaults from configurable values."""
|
||||
configurable = config.get("configurable")
|
||||
if not configurable:
|
||||
return None
|
||||
metadata: dict[str, Any] = {}
|
||||
for key, value in configurable.items():
|
||||
if _exclude_as_metadata(key, value):
|
||||
continue
|
||||
metadata[key] = value
|
||||
return metadata or None
|
||||
|
||||
|
||||
_PROPAGATE_TO_METADATA = frozenset(
|
||||
(
|
||||
"thread_id",
|
||||
"checkpoint_id",
|
||||
"checkpoint_ns",
|
||||
"task_id",
|
||||
"run_id",
|
||||
"assistant_id",
|
||||
"graph_id",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -12,6 +12,9 @@ RESUME = sys.intern("__resume__")
|
||||
# for values passed to resume a node after an interrupt
|
||||
ERROR = sys.intern("__error__")
|
||||
# for errors raised by nodes
|
||||
ERROR_SOURCE_NODE = sys.intern("__error_source_node__")
|
||||
# failed source node name for node-level error handlers
|
||||
# value format in pending writes: `(task_id, ERROR_SOURCE_NODE, node_name: str)`
|
||||
NO_WRITES = sys.intern("__no_writes__")
|
||||
# marker to signal node didn't write anything
|
||||
TASKS = sys.intern("__pregel_tasks")
|
||||
@@ -56,6 +59,8 @@ CONFIG_KEY_CHECKPOINT_NS = sys.intern("checkpoint_ns")
|
||||
# holds the current checkpoint_ns, "" for root graph
|
||||
CONFIG_KEY_NODE_FINISHED = sys.intern("__pregel_node_finished")
|
||||
# holds a callback to be called when a node is finished
|
||||
CONFIG_KEY_TIMED_ATTEMPT_OBSERVER = sys.intern("__pregel_timed_attempt_observer")
|
||||
# holds a callback to be called when an idle-timed node attempt starts or finishes
|
||||
CONFIG_KEY_SCRATCHPAD = sys.intern("__pregel_scratchpad")
|
||||
# holds a mutable dict for temporary storage scoped to the current task
|
||||
CONFIG_KEY_RUNNER_SUBMIT = sys.intern("__pregel_runner_submit")
|
||||
@@ -66,6 +71,13 @@ CONFIG_KEY_RUNTIME = sys.intern("__pregel_runtime")
|
||||
# holds a `Runtime` instance with context, store, stream writer, etc.
|
||||
CONFIG_KEY_RESUME_MAP = sys.intern("__pregel_resume_map")
|
||||
# holds a mapping of task ns -> resume value for resuming tasks
|
||||
CONFIG_KEY_STREAM_MESSAGES_V2 = sys.intern("__pregel_stream_messages_v2")
|
||||
# when True, attach StreamMessagesHandlerV2 so content-block (v2) events
|
||||
# flow through stream_mode="messages"; set by StreamingHandler only.
|
||||
CONFIG_KEY_NODE_ERROR = sys.intern("__pregel_node_error")
|
||||
# holds a `NodeError` (failed source node + exception) for the current
|
||||
# node-level error handler invocation, injected when handler signature
|
||||
# requests `error: NodeError`
|
||||
|
||||
# --- Other constants ---
|
||||
PUSH = sys.intern("__pregel_push")
|
||||
@@ -93,6 +105,7 @@ RESERVED = {
|
||||
INTERRUPT,
|
||||
RESUME,
|
||||
ERROR,
|
||||
ERROR_SOURCE_NODE,
|
||||
NO_WRITES,
|
||||
# reserved config.configurable keys
|
||||
CONFIG_KEY_SEND,
|
||||
@@ -106,7 +119,9 @@ RESERVED = {
|
||||
CONFIG_KEY_CHECKPOINT_MAP,
|
||||
CONFIG_KEY_CHECKPOINT_ID,
|
||||
CONFIG_KEY_CHECKPOINT_NS,
|
||||
CONFIG_KEY_TIMED_ATTEMPT_OBSERVER,
|
||||
CONFIG_KEY_RESUME_MAP,
|
||||
CONFIG_KEY_STREAM_MESSAGES_V2,
|
||||
# other constants
|
||||
PUSH,
|
||||
PULL,
|
||||
|
||||
@@ -51,9 +51,11 @@ from langgraph._internal._config import (
|
||||
)
|
||||
from langgraph._internal._constants import (
|
||||
CONF,
|
||||
CONFIG_KEY_NODE_ERROR,
|
||||
CONFIG_KEY_RUNTIME,
|
||||
)
|
||||
from langgraph._internal._typing import MISSING
|
||||
from langgraph.errors import NodeError
|
||||
from langgraph.types import StreamWriter
|
||||
|
||||
try:
|
||||
@@ -117,6 +119,19 @@ def set_config_context(
|
||||
ctx.run(_unset_config_context, config_token, run)
|
||||
|
||||
|
||||
def create_task_in_config_context(
|
||||
coro_factory: Callable[[], Coroutine[Any, Any, Any]], config: RunnableConfig
|
||||
) -> asyncio.Task[Any]:
|
||||
"""Create an asyncio.Task that inherits `config` as the child runnable context.
|
||||
|
||||
`asyncio.create_task` snapshots the current contextvars onto the new task,
|
||||
so calling `create_task` while the config context is set ensures the task
|
||||
sees `config` via `var_child_runnable_config` and any tracing parent.
|
||||
"""
|
||||
with set_config_context(config) as context:
|
||||
return context.run(lambda: asyncio.create_task(coro_factory()))
|
||||
|
||||
|
||||
# Before Python 3.11 native StrEnum is not available
|
||||
class StrEnum(str, enum.Enum):
|
||||
"""A string enum."""
|
||||
@@ -181,6 +196,15 @@ KWARGS_CONFIG_KEYS: tuple[tuple[str, tuple[Any, ...], str, Any], ...] = (
|
||||
"N/A",
|
||||
inspect.Parameter.empty,
|
||||
),
|
||||
(
|
||||
"error",
|
||||
(NodeError, "NodeError"),
|
||||
# we never hit this block, we read directly from configurable
|
||||
"N/A",
|
||||
# default to None so non-handler nodes that happen to type a parameter
|
||||
# `error: NodeError` don't blow up; handlers always receive a NodeError.
|
||||
None,
|
||||
),
|
||||
)
|
||||
"""List of kwargs that can be passed to functions, and their corresponding
|
||||
config keys, default values and type annotations.
|
||||
@@ -354,6 +378,8 @@ class RunnableCallable(Runnable):
|
||||
kw_value: Any = MISSING
|
||||
if kw == "config":
|
||||
kw_value = config
|
||||
elif kw == "error":
|
||||
kw_value = config.get(CONF, {}).get(CONFIG_KEY_NODE_ERROR, MISSING)
|
||||
elif runtime:
|
||||
if kw == "runtime":
|
||||
kw_value = runtime
|
||||
@@ -426,6 +452,8 @@ class RunnableCallable(Runnable):
|
||||
kw_value: Any = MISSING
|
||||
if kw == "config":
|
||||
kw_value = config
|
||||
elif kw == "error":
|
||||
kw_value = config.get(CONF, {}).get(CONFIG_KEY_NODE_ERROR, MISSING)
|
||||
elif runtime:
|
||||
if kw == "runtime":
|
||||
kw_value = runtime
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Literal
|
||||
|
||||
from langgraph.types import TimeoutPolicy
|
||||
|
||||
_SYNC_TIMEOUT_PREFIX = (
|
||||
"Node timeouts are only supported for async nodes because sync Python "
|
||||
"execution cannot be safely cancelled in-process."
|
||||
)
|
||||
|
||||
|
||||
def coerce_timeout_policy(
|
||||
value: float | timedelta | TimeoutPolicy | None,
|
||||
) -> TimeoutPolicy | None:
|
||||
"""Normalize a timeout value to positive-second policy fields."""
|
||||
return TimeoutPolicy.coerce(value)
|
||||
|
||||
|
||||
def sync_timeout_unsupported(
|
||||
name: str, *, kind: Literal["Node", "Task"] = "Node"
|
||||
) -> ValueError:
|
||||
"""Build the canonical error for using `timeout` with a sync target."""
|
||||
return ValueError(f"{_SYNC_TIMEOUT_PREFIX} {kind} {name!r} is sync.")
|
||||
@@ -0,0 +1,394 @@
|
||||
"""Graph lifecycle callback interfaces and event payloads.
|
||||
|
||||
This module defines the public callback surface for observing LangGraph-specific
|
||||
lifecycle transitions such as interrupt and resume.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal, TypeAlias, TypeVar
|
||||
from uuid import UUID
|
||||
|
||||
from langchain_core.callbacks import BaseCallbackHandler, BaseCallbackManager
|
||||
from langchain_core.callbacks.manager import ahandle_event, handle_event
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
|
||||
from langgraph.types import Interrupt
|
||||
|
||||
__all__ = (
|
||||
"GraphCallbackHandler",
|
||||
"GraphInterruptEvent",
|
||||
"GraphLifecycleEvent",
|
||||
"GraphLifecycleStatus",
|
||||
"GraphResumeEvent",
|
||||
"get_async_graph_callback_manager_for_config",
|
||||
"get_sync_graph_callback_manager_for_config",
|
||||
)
|
||||
|
||||
|
||||
GraphLifecycleStatus: TypeAlias = Literal[
|
||||
"input",
|
||||
"pending",
|
||||
"done",
|
||||
"interrupt_before",
|
||||
"interrupt_after",
|
||||
"out_of_steps",
|
||||
]
|
||||
"""Allowed lifecycle statuses reported in graph lifecycle callback events."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GraphInterruptEvent:
|
||||
"""Graph lifecycle event emitted when execution pauses for interrupts."""
|
||||
|
||||
run_id: UUID | None
|
||||
"""Run id for the current graph execution, if available."""
|
||||
|
||||
status: GraphLifecycleStatus
|
||||
"""Loop status when the interrupt was captured."""
|
||||
|
||||
checkpoint_id: str
|
||||
"""Checkpoint id associated with the interrupted execution."""
|
||||
|
||||
checkpoint_ns: tuple[str, ...]
|
||||
"""Checkpoint namespace path for the current graph or subgraph."""
|
||||
|
||||
interrupts: tuple[Interrupt, ...]
|
||||
"""Interrupt payloads that caused the graph to pause."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GraphResumeEvent:
|
||||
"""Graph lifecycle event emitted when execution resumes from a checkpoint."""
|
||||
|
||||
run_id: UUID | None
|
||||
"""Run id for the current graph execution, if available."""
|
||||
|
||||
status: GraphLifecycleStatus
|
||||
"""Loop status when the resume was captured."""
|
||||
|
||||
checkpoint_id: str
|
||||
"""Checkpoint id the graph resumed from."""
|
||||
|
||||
checkpoint_ns: tuple[str, ...]
|
||||
"""Checkpoint namespace path for the current graph or subgraph."""
|
||||
|
||||
|
||||
GraphLifecycleEvent: TypeAlias = GraphInterruptEvent | GraphResumeEvent
|
||||
"""Union of all public graph lifecycle callback event payloads.
|
||||
|
||||
Use this alias when a callback or helper can receive either interrupt or resume
|
||||
lifecycle events.
|
||||
"""
|
||||
|
||||
|
||||
class GraphCallbackHandler(BaseCallbackHandler):
|
||||
"""Base class for graph-level lifecycle callbacks.
|
||||
|
||||
Subclass this handler to observe graph lifecycle transitions that are
|
||||
specific to LangGraph execution, rather than generic LangChain runnable
|
||||
callbacks.
|
||||
|
||||
Instances can be passed through `config["callbacks"]` when invoking a
|
||||
graph. Only handlers that inherit from `GraphCallbackHandler` receive these
|
||||
lifecycle events.
|
||||
"""
|
||||
|
||||
def on_interrupt(self, event: GraphInterruptEvent) -> Any:
|
||||
"""Run when graph execution pauses due to one or more interrupts.
|
||||
|
||||
Args:
|
||||
event: Interrupt lifecycle event payload.
|
||||
"""
|
||||
|
||||
def on_resume(self, event: GraphResumeEvent) -> Any:
|
||||
"""Run when graph execution resumes from a persisted checkpoint.
|
||||
|
||||
Args:
|
||||
event: Resume lifecycle event payload.
|
||||
"""
|
||||
|
||||
|
||||
_MISSING = object()
|
||||
|
||||
|
||||
def _filter_graph_handlers(
|
||||
handlers: list[BaseCallbackHandler],
|
||||
) -> list[GraphCallbackHandler]:
|
||||
return [h for h in handlers if isinstance(h, GraphCallbackHandler)]
|
||||
|
||||
|
||||
def _init_base_manager(
|
||||
manager: BaseCallbackManager,
|
||||
handlers: Sequence[GraphCallbackHandler] | None,
|
||||
inheritable_handlers: Sequence[GraphCallbackHandler] | None,
|
||||
parent_run_id: UUID | None,
|
||||
*,
|
||||
tags: list[str] | None,
|
||||
inheritable_tags: list[str] | None,
|
||||
metadata: dict[str, Any] | None,
|
||||
inheritable_metadata: dict[str, Any] | None,
|
||||
run_id: UUID | None,
|
||||
) -> None:
|
||||
base_handlers: list[BaseCallbackHandler] = []
|
||||
base_inheritable_handlers: list[BaseCallbackHandler] = []
|
||||
if handlers is not None:
|
||||
base_handlers.extend(handlers)
|
||||
if inheritable_handlers is not None:
|
||||
base_inheritable_handlers.extend(inheritable_handlers)
|
||||
BaseCallbackManager.__init__(
|
||||
manager,
|
||||
handlers=base_handlers,
|
||||
inheritable_handlers=base_inheritable_handlers,
|
||||
parent_run_id=parent_run_id,
|
||||
tags=tags,
|
||||
inheritable_tags=inheritable_tags,
|
||||
metadata=metadata,
|
||||
inheritable_metadata=inheritable_metadata,
|
||||
)
|
||||
manager.run_id = run_id # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def _configure_graph_callbacks(
|
||||
cls: type[_GraphManagerT],
|
||||
callbacks: object | None,
|
||||
*,
|
||||
run_id: UUID | None,
|
||||
) -> _GraphManagerT:
|
||||
if callbacks is None:
|
||||
return cls(run_id=run_id)
|
||||
if isinstance(callbacks, cls):
|
||||
return callbacks.copy(run_id=run_id)
|
||||
if isinstance(callbacks, (_GraphCallbackManager, _AsyncGraphCallbackManager)):
|
||||
# Cross-type: extract handlers into the requested cls.
|
||||
return cls(
|
||||
handlers=_filter_graph_handlers(callbacks.handlers),
|
||||
inheritable_handlers=_filter_graph_handlers(callbacks.inheritable_handlers),
|
||||
parent_run_id=callbacks.parent_run_id,
|
||||
tags=callbacks.tags.copy(),
|
||||
inheritable_tags=callbacks.inheritable_tags.copy(),
|
||||
metadata=callbacks.metadata.copy(),
|
||||
inheritable_metadata=callbacks.inheritable_metadata.copy(),
|
||||
run_id=run_id,
|
||||
)
|
||||
if isinstance(callbacks, BaseCallbackManager):
|
||||
return cls(
|
||||
handlers=_filter_graph_handlers(callbacks.handlers),
|
||||
inheritable_handlers=_filter_graph_handlers(callbacks.inheritable_handlers),
|
||||
parent_run_id=callbacks.parent_run_id,
|
||||
tags=callbacks.tags.copy(),
|
||||
inheritable_tags=callbacks.inheritable_tags.copy(),
|
||||
metadata=callbacks.metadata.copy(),
|
||||
inheritable_metadata=callbacks.inheritable_metadata.copy(),
|
||||
run_id=run_id,
|
||||
)
|
||||
if isinstance(callbacks, GraphCallbackHandler):
|
||||
return cls((callbacks,), run_id=run_id)
|
||||
if isinstance(callbacks, (str, bytes)) or not isinstance(callbacks, Sequence):
|
||||
raise TypeError("callbacks must be a handler, sequence, or manager")
|
||||
return cls(_filter_graph_handlers(list(callbacks)), run_id=run_id)
|
||||
|
||||
|
||||
def _copy_graph_manager(
|
||||
manager: _GraphCallbackManager | _AsyncGraphCallbackManager,
|
||||
cls: type[_GraphManagerT],
|
||||
run_id: UUID | None | object,
|
||||
) -> _GraphManagerT:
|
||||
resolved_run_id: UUID | None
|
||||
if run_id is _MISSING:
|
||||
resolved_run_id = manager.run_id
|
||||
else:
|
||||
if run_id is not None and not isinstance(run_id, UUID):
|
||||
raise TypeError("run_id must be a UUID or None")
|
||||
resolved_run_id = run_id
|
||||
|
||||
return cls(
|
||||
handlers=_filter_graph_handlers(manager.handlers),
|
||||
inheritable_handlers=_filter_graph_handlers(manager.inheritable_handlers),
|
||||
parent_run_id=manager.parent_run_id,
|
||||
tags=manager.tags.copy(),
|
||||
inheritable_tags=manager.inheritable_tags.copy(),
|
||||
metadata=manager.metadata.copy(),
|
||||
inheritable_metadata=manager.inheritable_metadata.copy(),
|
||||
run_id=resolved_run_id,
|
||||
)
|
||||
|
||||
|
||||
class _GraphCallbackManager(BaseCallbackManager):
|
||||
"""Sync dispatcher for graph lifecycle events."""
|
||||
|
||||
run_id: UUID | None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
handlers: Sequence[GraphCallbackHandler] | None = None,
|
||||
inheritable_handlers: Sequence[GraphCallbackHandler] | None = None,
|
||||
parent_run_id: UUID | None = None,
|
||||
*,
|
||||
tags: list[str] | None = None,
|
||||
inheritable_tags: list[str] | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
inheritable_metadata: dict[str, Any] | None = None,
|
||||
run_id: UUID | None = None,
|
||||
) -> None:
|
||||
_init_base_manager(
|
||||
self,
|
||||
handlers,
|
||||
inheritable_handlers,
|
||||
parent_run_id,
|
||||
tags=tags,
|
||||
inheritable_tags=inheritable_tags,
|
||||
metadata=metadata,
|
||||
inheritable_metadata=inheritable_metadata,
|
||||
run_id=run_id,
|
||||
)
|
||||
|
||||
def copy(
|
||||
self,
|
||||
*,
|
||||
run_id: UUID | None | object = _MISSING,
|
||||
) -> _GraphCallbackManager:
|
||||
return _copy_graph_manager(self, _GraphCallbackManager, run_id)
|
||||
|
||||
@classmethod
|
||||
def configure(
|
||||
cls,
|
||||
callbacks: object | None = None,
|
||||
*,
|
||||
run_id: UUID | None = None,
|
||||
) -> _GraphCallbackManager:
|
||||
return _configure_graph_callbacks(cls, callbacks, run_id=run_id)
|
||||
|
||||
def on_interrupt(self, event: GraphInterruptEvent) -> None:
|
||||
handle_event(
|
||||
self.handlers,
|
||||
"on_interrupt",
|
||||
None,
|
||||
event,
|
||||
)
|
||||
|
||||
def on_resume(self, event: GraphResumeEvent) -> None:
|
||||
handle_event(
|
||||
self.handlers,
|
||||
"on_resume",
|
||||
None,
|
||||
event,
|
||||
)
|
||||
|
||||
|
||||
class _AsyncGraphCallbackManager(BaseCallbackManager):
|
||||
"""Async dispatcher for graph lifecycle events."""
|
||||
|
||||
run_id: UUID | None
|
||||
|
||||
@property
|
||||
def is_async(self) -> bool:
|
||||
"""Return whether the manager is async."""
|
||||
return True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
handlers: Sequence[GraphCallbackHandler] | None = None,
|
||||
inheritable_handlers: Sequence[GraphCallbackHandler] | None = None,
|
||||
parent_run_id: UUID | None = None,
|
||||
*,
|
||||
tags: list[str] | None = None,
|
||||
inheritable_tags: list[str] | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
inheritable_metadata: dict[str, Any] | None = None,
|
||||
run_id: UUID | None = None,
|
||||
) -> None:
|
||||
_init_base_manager(
|
||||
self,
|
||||
handlers,
|
||||
inheritable_handlers,
|
||||
parent_run_id,
|
||||
tags=tags,
|
||||
inheritable_tags=inheritable_tags,
|
||||
metadata=metadata,
|
||||
inheritable_metadata=inheritable_metadata,
|
||||
run_id=run_id,
|
||||
)
|
||||
|
||||
def copy(
|
||||
self,
|
||||
*,
|
||||
run_id: UUID | None | object = _MISSING,
|
||||
) -> _AsyncGraphCallbackManager:
|
||||
return _copy_graph_manager(self, _AsyncGraphCallbackManager, run_id)
|
||||
|
||||
@classmethod
|
||||
def configure(
|
||||
cls,
|
||||
callbacks: object | None = None,
|
||||
*,
|
||||
run_id: UUID | None = None,
|
||||
) -> _AsyncGraphCallbackManager:
|
||||
return _configure_graph_callbacks(cls, callbacks, run_id=run_id)
|
||||
|
||||
async def on_interrupt(self, event: GraphInterruptEvent) -> None:
|
||||
await ahandle_event(
|
||||
self.handlers,
|
||||
"on_interrupt",
|
||||
None,
|
||||
event,
|
||||
)
|
||||
|
||||
async def on_resume(self, event: GraphResumeEvent) -> None:
|
||||
await ahandle_event(
|
||||
self.handlers,
|
||||
"on_resume",
|
||||
None,
|
||||
event,
|
||||
)
|
||||
|
||||
|
||||
_GraphManagerT = TypeVar(
|
||||
"_GraphManagerT", _GraphCallbackManager, _AsyncGraphCallbackManager
|
||||
)
|
||||
|
||||
GraphCallbacks: TypeAlias = (
|
||||
_GraphCallbackManager
|
||||
| _AsyncGraphCallbackManager
|
||||
| BaseCallbackManager
|
||||
| GraphCallbackHandler
|
||||
| Sequence[BaseCallbackHandler]
|
||||
| Sequence[GraphCallbackHandler]
|
||||
| None
|
||||
)
|
||||
|
||||
|
||||
def get_sync_graph_callback_manager_for_config(
|
||||
config: RunnableConfig,
|
||||
*,
|
||||
run_id: UUID | None = None,
|
||||
) -> _GraphCallbackManager:
|
||||
"""Build a sync graph lifecycle callback manager from a runnable config.
|
||||
|
||||
This helper filters `config["callbacks"]` down to handlers that inherit
|
||||
from `GraphCallbackHandler` and binds the provided `run_id` onto the
|
||||
returned manager.
|
||||
"""
|
||||
return _GraphCallbackManager.configure(
|
||||
config.get("callbacks"),
|
||||
run_id=run_id,
|
||||
)
|
||||
|
||||
|
||||
def get_async_graph_callback_manager_for_config(
|
||||
config: RunnableConfig,
|
||||
*,
|
||||
run_id: UUID | None = None,
|
||||
) -> _AsyncGraphCallbackManager:
|
||||
"""Build an async graph lifecycle callback manager from a runnable config.
|
||||
|
||||
This helper filters `config["callbacks"]` down to handlers that inherit
|
||||
from `GraphCallbackHandler` and binds the provided `run_id` onto the
|
||||
returned manager.
|
||||
"""
|
||||
return _AsyncGraphCallbackManager.configure(
|
||||
config.get("callbacks"),
|
||||
run_id=run_id,
|
||||
)
|
||||
@@ -1,6 +1,7 @@
|
||||
from langgraph.channels.any_value import AnyValue
|
||||
from langgraph.channels.base import BaseChannel
|
||||
from langgraph.channels.binop import BinaryOperatorAggregate
|
||||
from langgraph.channels.delta import DeltaChannel
|
||||
from langgraph.channels.ephemeral_value import EphemeralValue
|
||||
from langgraph.channels.last_value import LastValue, LastValueAfterFinish
|
||||
from langgraph.channels.named_barrier_value import (
|
||||
@@ -20,6 +21,7 @@ __all__ = (
|
||||
"UntrackedValue",
|
||||
"EphemeralValue",
|
||||
"BinaryOperatorAggregate",
|
||||
"DeltaChannel",
|
||||
"NamedBarrierValue",
|
||||
"NamedBarrierValueAfterFinish",
|
||||
# topics
|
||||
|
||||
@@ -22,10 +22,9 @@ __all__ = ("BinaryOperatorAggregate",)
|
||||
def _strip_extras(t): # type: ignore[no-untyped-def]
|
||||
"""Strips Annotated, Required and NotRequired from a given type."""
|
||||
if hasattr(t, "__origin__"):
|
||||
if t.__origin__ in (Required, NotRequired):
|
||||
return _strip_extras(t.__args__[0])
|
||||
return _strip_extras(t.__origin__)
|
||||
if hasattr(t, "__origin__") and t.__origin__ in (Required, NotRequired):
|
||||
return _strip_extras(t.__args__[0])
|
||||
|
||||
return t
|
||||
|
||||
|
||||
@@ -33,11 +32,22 @@ def _get_overwrite(value: Any) -> tuple[bool, Any]:
|
||||
"""Inspects the given value and returns (is_overwrite, overwrite_value)."""
|
||||
if isinstance(value, Overwrite):
|
||||
return True, value.value
|
||||
if isinstance(value, dict) and set(value.keys()) == {OVERWRITE}:
|
||||
if isinstance(value, dict) and len(value) == 1 and OVERWRITE in value:
|
||||
return True, value[OVERWRITE]
|
||||
return False, None
|
||||
|
||||
|
||||
def _operators_equal(a: Callable, b: Callable) -> bool:
|
||||
"""Return True if two reducer operators should be considered equal.
|
||||
|
||||
Lambdas all share the name '<lambda>' so identity comparison is
|
||||
unreliable; treat any pairing that includes a lambda as equal.
|
||||
"""
|
||||
if a.__name__ == "<lambda>" or b.__name__ == "<lambda>":
|
||||
return True
|
||||
return a is b
|
||||
|
||||
|
||||
class BinaryOperatorAggregate(Generic[Value], BaseChannel[Value, Value, Value]):
|
||||
"""Stores the result of applying a binary operator to the current value and each new value.
|
||||
|
||||
@@ -68,11 +78,8 @@ class BinaryOperatorAggregate(Generic[Value], BaseChannel[Value, Value, Value]):
|
||||
self.value = MISSING
|
||||
|
||||
def __eq__(self, value: object) -> bool:
|
||||
return isinstance(value, BinaryOperatorAggregate) and (
|
||||
value.operator is self.operator
|
||||
if value.operator.__name__ != "<lambda>"
|
||||
and self.operator.__name__ != "<lambda>"
|
||||
else True
|
||||
return isinstance(value, BinaryOperatorAggregate) and _operators_equal(
|
||||
self.operator, value.operator
|
||||
)
|
||||
|
||||
@property
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import collections.abc
|
||||
import copy as _copy
|
||||
from collections.abc import Callable, Sequence
|
||||
from typing import Any, Generic
|
||||
|
||||
from langgraph.checkpoint.base import DELTA_SENTINEL, PendingWrite
|
||||
from langgraph.checkpoint.serde.types import _DeltaSnapshot
|
||||
from typing_extensions import Self
|
||||
|
||||
from langgraph._internal._typing import MISSING
|
||||
from langgraph.channels.base import BaseChannel, Value
|
||||
from langgraph.channels.binop import _get_overwrite, _operators_equal, _strip_extras
|
||||
from langgraph.errors import (
|
||||
EmptyChannelError,
|
||||
ErrorCode,
|
||||
InvalidUpdateError,
|
||||
create_error_message,
|
||||
)
|
||||
|
||||
__all__ = ("DeltaChannel",)
|
||||
|
||||
|
||||
class DeltaChannel(Generic[Value], BaseChannel[Any, Any, Any]):
|
||||
"""Reducer channel that stores only a sentinel in checkpoint blobs and
|
||||
reconstructs state by replaying ancestor writes through the reducer.
|
||||
|
||||
The reducer receives the current accumulated value and a batch of writes
|
||||
in one call: `reducer(state, [write1, write2, ...]) -> new_state`.
|
||||
|
||||
Reducers must be deterministic and batching-invariant (associative across
|
||||
folds): applying two consecutive write batches separately must produce the
|
||||
same state as applying their concatenation once:
|
||||
|
||||
reducer(reducer(state, xs), ys) == reducer(state, xs + ys)
|
||||
|
||||
This lets LangGraph replay checkpointed writes in larger batches than they
|
||||
were originally produced without changing reconstructed state.
|
||||
|
||||
`snapshot_frequency=None` (default): pure delta; stores only
|
||||
`DELTA_SENTINEL` in checkpoint blobs; reads replay all ancestor writes.
|
||||
|
||||
`snapshot_frequency=N`: `create_checkpoint` writes a full `_DeltaSnapshot`
|
||||
blob every N steps, bounding replay depth to N.
|
||||
|
||||
Parameters:
|
||||
reducer: `(state, list[writes]) -> new_state`. Must be deterministic
|
||||
and batching-invariant as described above.
|
||||
typ: The value type (e.g. `list`, `dict`). Inferred automatically
|
||||
from the outer type when used inside `Annotated[T, DeltaChannel(...)]`.
|
||||
snapshot_frequency: Every Nth pregel step writes a snapshot blob.
|
||||
`None` (default) = pure delta, never snapshot.
|
||||
"""
|
||||
|
||||
__slots__ = ("value", "reducer", "snapshot_frequency")
|
||||
value: Value | Any
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
reducer: Callable[[Any, Sequence[Any]], Any],
|
||||
typ: type[Value] | None = None,
|
||||
*,
|
||||
snapshot_frequency: int | None = None,
|
||||
) -> None:
|
||||
if typ is None:
|
||||
typ = list # type: ignore[assignment] # placeholder; overridden by _is_field_channel
|
||||
super().__init__(typ)
|
||||
self.reducer = reducer
|
||||
self.snapshot_frequency = snapshot_frequency
|
||||
typ = _strip_extras(typ)
|
||||
if typ in (collections.abc.Sequence, collections.abc.MutableSequence):
|
||||
typ = list
|
||||
if typ in (collections.abc.Set, collections.abc.MutableSet):
|
||||
typ = set
|
||||
if typ in (collections.abc.Mapping, collections.abc.MutableMapping):
|
||||
typ = dict
|
||||
self.typ = typ
|
||||
self.value: Any = MISSING
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
if not isinstance(other, DeltaChannel):
|
||||
return False
|
||||
if self.snapshot_frequency != other.snapshot_frequency:
|
||||
return False
|
||||
return _operators_equal(self.reducer, other.reducer)
|
||||
|
||||
@property
|
||||
def ValueType(self) -> Any:
|
||||
return self.typ
|
||||
|
||||
@property
|
||||
def UpdateType(self) -> Any:
|
||||
return self.typ
|
||||
|
||||
def is_snapshot_step(self, step: int) -> bool:
|
||||
"""True if pregel should write a snapshot blob at this step."""
|
||||
return (
|
||||
self.snapshot_frequency is not None
|
||||
and step > 0
|
||||
and step % self.snapshot_frequency == 0
|
||||
)
|
||||
|
||||
def copy(self) -> Self:
|
||||
new = self.__class__(
|
||||
self.reducer, self.typ, snapshot_frequency=self.snapshot_frequency
|
||||
)
|
||||
new.key = self.key
|
||||
new.value = self.value if self.value is MISSING else _copy.copy(self.value)
|
||||
return new
|
||||
|
||||
def from_checkpoint(self, checkpoint: Any) -> Self:
|
||||
"""Initialize from a stored blob or sentinel.
|
||||
|
||||
Blob types (dispatched via serde ext code, not dict key inspection):
|
||||
* `DELTA_SENTINEL` / `MISSING`: start empty; caller replays writes.
|
||||
* `_DeltaSnapshot(value)`: restore value directly from snapshot.
|
||||
* plain value (migration from old BinOp blobs): use directly.
|
||||
"""
|
||||
new = self.__class__(
|
||||
self.reducer, self.typ, snapshot_frequency=self.snapshot_frequency
|
||||
)
|
||||
new.key = self.key
|
||||
if checkpoint is MISSING or checkpoint is DELTA_SENTINEL:
|
||||
new.value = self.typ()
|
||||
elif isinstance(checkpoint, _DeltaSnapshot):
|
||||
new.value = checkpoint.value
|
||||
else:
|
||||
new.value = checkpoint
|
||||
return new
|
||||
|
||||
def replay_writes(self, writes: Sequence[PendingWrite]) -> None:
|
||||
"""Apply ancestor writes oldest-to-newest via a single reducer call.
|
||||
|
||||
If any write is an Overwrite, the last one in the sequence acts as
|
||||
the reset point: its value becomes the new base and only writes
|
||||
after it are passed to the reducer.
|
||||
"""
|
||||
values = [v for _, _, v in writes]
|
||||
if not values:
|
||||
return
|
||||
base = self.value
|
||||
start = 0
|
||||
for i, v in enumerate(values):
|
||||
is_ow, ow_value = _get_overwrite(v)
|
||||
if is_ow:
|
||||
base = _copy.copy(ow_value) if ow_value is not None else self.typ()
|
||||
start = i + 1
|
||||
remaining = values[start:]
|
||||
self.value = self.reducer(base, remaining) if remaining else base
|
||||
|
||||
def update(self, values: Sequence[Any]) -> bool:
|
||||
if not values:
|
||||
return False
|
||||
overwrite_idx: int | None = None
|
||||
for i, v in enumerate(values):
|
||||
is_ow, _ = _get_overwrite(v)
|
||||
if is_ow:
|
||||
if overwrite_idx is not None:
|
||||
msg = create_error_message(
|
||||
message="Can receive only one Overwrite value per super-step.",
|
||||
error_code=ErrorCode.INVALID_CONCURRENT_GRAPH_UPDATE,
|
||||
)
|
||||
raise InvalidUpdateError(msg)
|
||||
overwrite_idx = i
|
||||
if overwrite_idx is not None:
|
||||
_, overwrite_value = _get_overwrite(values[overwrite_idx])
|
||||
base = (
|
||||
_copy.copy(overwrite_value)
|
||||
if overwrite_value is not None
|
||||
else self.typ()
|
||||
)
|
||||
remaining = [v for i, v in enumerate(values) if i != overwrite_idx]
|
||||
self.value = self.reducer(base, remaining) if remaining else base
|
||||
return True
|
||||
base = self.typ() if self.value is MISSING else self.value
|
||||
self.value = self.reducer(base, list(values))
|
||||
return True
|
||||
|
||||
def get(self) -> Any:
|
||||
if self.value is MISSING:
|
||||
raise EmptyChannelError()
|
||||
return self.value
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return self.value is not MISSING
|
||||
|
||||
def checkpoint(self) -> Any:
|
||||
"""Return stored representation: always `DELTA_SENTINEL`.
|
||||
|
||||
Snapshot decisions are made by `create_checkpoint` in pregel (which
|
||||
has the step number) via `is_snapshot_step`. `checkpoint()` is only
|
||||
called for non-snapshot steps or when no checkpointer is available.
|
||||
"""
|
||||
if self.value is MISSING:
|
||||
return MISSING
|
||||
return DELTA_SENTINEL
|
||||
@@ -1,8 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
from warnings import warn
|
||||
|
||||
# EmptyChannelError is re-exported from langgraph.channels.base
|
||||
@@ -15,11 +16,14 @@ from langgraph.warnings import LangGraphDeprecatedSinceV10
|
||||
__all__ = (
|
||||
"EmptyChannelError",
|
||||
"ErrorCode",
|
||||
"GraphDrained",
|
||||
"GraphRecursionError",
|
||||
"InvalidUpdateError",
|
||||
"GraphBubbleUp",
|
||||
"GraphInterrupt",
|
||||
"NodeError",
|
||||
"NodeInterrupt",
|
||||
"NodeTimeoutError",
|
||||
"ParentCommand",
|
||||
"EmptyInputError",
|
||||
"TaskNotFound",
|
||||
@@ -42,6 +46,23 @@ def create_error_message(*, message: str, error_code: ErrorCode) -> str:
|
||||
)
|
||||
|
||||
|
||||
class GraphBubbleUp(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class GraphDrained(GraphBubbleUp):
|
||||
"""Raised when a graph run exits early due to a drain request.
|
||||
|
||||
This indicates the graph stopped cooperatively at a superstep boundary
|
||||
because `RunControl.request_drain()` was called (e.g., in response to
|
||||
SIGTERM). The checkpoint is saved and the run can be resumed later.
|
||||
"""
|
||||
|
||||
def __init__(self, reason: str = "shutdown") -> None:
|
||||
self.reason = reason
|
||||
super().__init__(f"Graph drained: {reason}")
|
||||
|
||||
|
||||
class GraphRecursionError(RecursionError):
|
||||
"""Raised when the graph has exhausted the maximum number of steps.
|
||||
|
||||
@@ -77,10 +98,6 @@ class InvalidUpdateError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class GraphBubbleUp(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class GraphInterrupt(GraphBubbleUp):
|
||||
"""Raised when a subgraph is interrupted, suppressed by the root graph.
|
||||
Never raised directly, or surfaced to the user."""
|
||||
@@ -125,3 +142,77 @@ class TaskNotFound(Exception):
|
||||
"""Raised when the executor is unable to find a task (for distributed mode)."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class NodeError:
|
||||
"""Failure context passed to a node-level error handler.
|
||||
|
||||
Inject by adding a parameter typed `NodeError` to a handler registered via
|
||||
`StateGraph.add_node(..., error_handler=...)`:
|
||||
|
||||
```python
|
||||
def handler(state: State, error: NodeError) -> Command:
|
||||
return Command(update={"status": f"recovered from {error.node}: {error.error}"})
|
||||
```
|
||||
"""
|
||||
|
||||
node: str
|
||||
"""Name of the node whose execution failed."""
|
||||
|
||||
error: BaseException
|
||||
"""Exception raised by the failed node."""
|
||||
|
||||
|
||||
class NodeTimeoutError(Exception):
|
||||
"""Raised when a node invocation exceeds one of its configured timeouts.
|
||||
|
||||
Does **not** inherit from the built-in `TimeoutError` (a subclass of
|
||||
`OSError`) so that the default `RetryPolicy` treats it as retryable.
|
||||
|
||||
Both `idle_timeout` and `run_timeout` reflect the configured policy at the
|
||||
time of the failure (each is `None` if not configured). `kind` and
|
||||
`timeout` identify which one fired.
|
||||
"""
|
||||
|
||||
node: str
|
||||
timeout: float
|
||||
run_timeout: float | None
|
||||
idle_timeout: float | None
|
||||
elapsed: float
|
||||
kind: Literal["idle", "run"]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
node: str,
|
||||
elapsed: float,
|
||||
*,
|
||||
kind: Literal["idle", "run"],
|
||||
idle_timeout: float | None = None,
|
||||
run_timeout: float | None = None,
|
||||
) -> None:
|
||||
if kind == "idle":
|
||||
if idle_timeout is None:
|
||||
raise ValueError("idle_timeout is required when kind='idle'")
|
||||
message = (
|
||||
f"Node '{node}' exceeded its idle timeout of "
|
||||
f"{idle_timeout:.3f}s without making progress "
|
||||
f"(elapsed: {elapsed:.3f}s)."
|
||||
)
|
||||
self.timeout = idle_timeout
|
||||
elif kind == "run":
|
||||
if run_timeout is None:
|
||||
raise ValueError("run_timeout is required when kind='run'")
|
||||
message = (
|
||||
f"Node '{node}' exceeded its run timeout of "
|
||||
f"{run_timeout:.3f}s (elapsed: {elapsed:.3f}s)."
|
||||
)
|
||||
self.timeout = run_timeout
|
||||
else:
|
||||
raise ValueError("kind must be 'idle' or 'run'")
|
||||
super().__init__(message)
|
||||
self.node = node
|
||||
self.elapsed = elapsed
|
||||
self.kind = kind
|
||||
self.idle_timeout = idle_timeout
|
||||
self.run_timeout = run_timeout
|
||||
|
||||
@@ -5,6 +5,7 @@ import inspect
|
||||
import warnings
|
||||
from collections.abc import Awaitable, Callable, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from typing import (
|
||||
Any,
|
||||
Generic,
|
||||
@@ -22,6 +23,11 @@ from typing_extensions import Unpack
|
||||
|
||||
from langgraph._internal import _serde
|
||||
from langgraph._internal._constants import CACHE_NS_WRITES, PREVIOUS
|
||||
from langgraph._internal._runnable import is_async_callable
|
||||
from langgraph._internal._timeout import (
|
||||
coerce_timeout_policy,
|
||||
sync_timeout_unsupported,
|
||||
)
|
||||
from langgraph._internal._typing import MISSING, DeprecatedKwargs
|
||||
from langgraph.channels.ephemeral_value import EphemeralValue
|
||||
from langgraph.channels.last_value import LastValue
|
||||
@@ -31,13 +37,19 @@ from langgraph.pregel._call import (
|
||||
P,
|
||||
SyncAsyncFuture,
|
||||
T,
|
||||
call,
|
||||
_call_with_options,
|
||||
get_runnable_for_entrypoint,
|
||||
identifier,
|
||||
)
|
||||
from langgraph.pregel._read import PregelNode
|
||||
from langgraph.pregel._write import ChannelWrite, ChannelWriteEntry
|
||||
from langgraph.types import _DC_KWARGS, CachePolicy, RetryPolicy, StreamMode
|
||||
from langgraph.types import (
|
||||
_DC_KWARGS,
|
||||
CachePolicy,
|
||||
RetryPolicy,
|
||||
StreamMode,
|
||||
TimeoutPolicy,
|
||||
)
|
||||
from langgraph.typing import ContextT
|
||||
from langgraph.warnings import LangGraphDeprecatedSinceV05, LangGraphDeprecatedSinceV10
|
||||
|
||||
@@ -51,6 +63,7 @@ class _TaskFunction(Generic[P, T]):
|
||||
*,
|
||||
retry_policy: Sequence[RetryPolicy],
|
||||
cache_policy: CachePolicy[Callable[P, str | bytes]] | None = None,
|
||||
timeout: TimeoutPolicy | None = None,
|
||||
name: str | None = None,
|
||||
) -> None:
|
||||
if name is not None:
|
||||
@@ -67,15 +80,17 @@ class _TaskFunction(Generic[P, T]):
|
||||
self.func = func
|
||||
self.retry_policy = retry_policy
|
||||
self.cache_policy = cache_policy
|
||||
self.timeout = timeout
|
||||
functools.update_wrapper(self, func)
|
||||
|
||||
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> SyncAsyncFuture[T]:
|
||||
return call(
|
||||
return _call_with_options(
|
||||
self.func,
|
||||
args,
|
||||
kwargs,
|
||||
retry_policy=self.retry_policy,
|
||||
cache_policy=self.cache_policy,
|
||||
*args,
|
||||
**kwargs,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
|
||||
def clear_cache(self, cache: BaseCache) -> None:
|
||||
@@ -98,6 +113,7 @@ def task(
|
||||
name: str | None = None,
|
||||
retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None,
|
||||
cache_policy: CachePolicy[Callable[P, str | bytes]] | None = None,
|
||||
timeout: float | timedelta | TimeoutPolicy | None = None,
|
||||
**kwargs: Unpack[DeprecatedKwargs],
|
||||
) -> Callable[
|
||||
[Callable[P, Awaitable[T]] | Callable[P, T]],
|
||||
@@ -119,6 +135,7 @@ def task(
|
||||
name: str | None = None,
|
||||
retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None,
|
||||
cache_policy: CachePolicy[Callable[P, str | bytes]] | None = None,
|
||||
timeout: float | timedelta | TimeoutPolicy | None = None,
|
||||
**kwargs: Unpack[DeprecatedKwargs],
|
||||
) -> (
|
||||
Callable[[Callable[P, Awaitable[T]] | Callable[P, T]], _TaskFunction[P, T]]
|
||||
@@ -142,6 +159,14 @@ def task(
|
||||
name: An optional name for the task. If not provided, the function name will be used.
|
||||
retry_policy: An optional retry policy (or list of policies) to use for the task in case of a failure.
|
||||
cache_policy: An optional cache policy to use for the task. This allows caching of the task results.
|
||||
timeout: Timeout for each task attempt. A number or `timedelta` is a hard
|
||||
wall-clock cap and is not refreshed. Use `TimeoutPolicy` to configure
|
||||
both a wall-clock `run_timeout` and an `idle_timeout` refreshed by
|
||||
progress signals. For long-running work that doesn't naturally emit
|
||||
progress, call `runtime.heartbeat()` from inside the task. When the
|
||||
timeout fires, `NodeTimeoutError` is raised and the retry policy (if
|
||||
any) decides whether to retry. Supported only for async tasks; sync
|
||||
tasks cannot be safely cancelled in-process.
|
||||
|
||||
Returns:
|
||||
A callable function when used as a decorator.
|
||||
@@ -196,6 +221,7 @@ def task(
|
||||
)
|
||||
if retry_policy is None:
|
||||
retry_policy = retry # type: ignore[assignment]
|
||||
timeout_policy = coerce_timeout_policy(timeout)
|
||||
|
||||
retry_policies: Sequence[RetryPolicy] = (
|
||||
()
|
||||
@@ -208,8 +234,15 @@ def task(
|
||||
def decorator(
|
||||
func: Callable[P, Awaitable[T]] | Callable[P, T],
|
||||
) -> Callable[P, SyncAsyncFuture[T]]:
|
||||
if timeout_policy is not None and not is_async_callable(func):
|
||||
name_ = name or getattr(func, "__name__", func.__class__.__name__)
|
||||
raise sync_timeout_unsupported(str(name_), kind="Task")
|
||||
return _TaskFunction(
|
||||
func, retry_policy=retry_policies, cache_policy=cache_policy, name=name
|
||||
func,
|
||||
retry_policy=retry_policies,
|
||||
cache_policy=cache_policy,
|
||||
timeout=timeout_policy,
|
||||
name=name,
|
||||
)
|
||||
|
||||
if __func_or_none__ is not None:
|
||||
@@ -268,6 +301,15 @@ class entrypoint(Generic[ContextT]):
|
||||
passed to the workflow.
|
||||
cache_policy: A cache policy to use for caching the results of the workflow.
|
||||
retry_policy: A retry policy (or list of policies) to use for the workflow in case of a failure.
|
||||
timeout: Timeout for each workflow attempt. A number or `timedelta` is a
|
||||
hard wall-clock cap and is not refreshed. Use `TimeoutPolicy` to
|
||||
configure both a wall-clock `run_timeout` and an `idle_timeout`
|
||||
refreshed by progress signals. For long-running work that doesn't
|
||||
naturally emit progress, call `runtime.heartbeat()` from inside the
|
||||
workflow. When the timeout fires, `NodeTimeoutError` is raised and
|
||||
the retry policy (if any) decides whether to retry. Supported only
|
||||
for async workflows; sync workflows cannot be safely cancelled
|
||||
in-process.
|
||||
|
||||
!!! warning "`config_schema` Deprecated"
|
||||
The `config_schema` parameter is deprecated in v0.6.0 and support will be removed in v2.0.0.
|
||||
@@ -400,6 +442,7 @@ class entrypoint(Generic[ContextT]):
|
||||
context_schema: type[ContextT] | None = None,
|
||||
cache_policy: CachePolicy | None = None,
|
||||
retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None,
|
||||
timeout: float | timedelta | TimeoutPolicy | None = None,
|
||||
**kwargs: Unpack[DeprecatedKwargs],
|
||||
) -> None:
|
||||
"""Initialize the entrypoint decorator."""
|
||||
@@ -426,6 +469,7 @@ class entrypoint(Generic[ContextT]):
|
||||
self.cache = cache
|
||||
self.cache_policy = cache_policy
|
||||
self.retry_policy = retry_policy
|
||||
self.timeout = coerce_timeout_policy(timeout)
|
||||
self.context_schema = context_schema
|
||||
|
||||
@dataclass(**_DC_KWARGS)
|
||||
@@ -535,6 +579,7 @@ class entrypoint(Generic[ContextT]):
|
||||
bound=bound,
|
||||
triggers=[START],
|
||||
channels=START,
|
||||
timeout=self.timeout,
|
||||
writers=[
|
||||
ChannelWrite(
|
||||
[
|
||||
|
||||
@@ -9,7 +9,7 @@ from langgraph.store.base import BaseStore
|
||||
|
||||
from langgraph._internal._typing import EMPTY_SEQ
|
||||
from langgraph.runtime import Runtime
|
||||
from langgraph.types import CachePolicy, RetryPolicy, StreamWriter
|
||||
from langgraph.types import CachePolicy, RetryPolicy, StreamWriter, TimeoutPolicy
|
||||
from langgraph.typing import ContextT, NodeInputT, NodeInputT_contra
|
||||
|
||||
|
||||
@@ -88,5 +88,8 @@ class StateNodeSpec(Generic[NodeInputT, ContextT]):
|
||||
input_schema: type[NodeInputT]
|
||||
retry_policy: RetryPolicy | Sequence[RetryPolicy] | None
|
||||
cache_policy: CachePolicy | None
|
||||
is_error_handler: bool = False
|
||||
error_handler_node: str | None = None
|
||||
ends: tuple[str, ...] | dict[str, str] | None = EMPTY_SEQ
|
||||
defer: bool = False
|
||||
timeout: TimeoutPolicy | None = None
|
||||
|
||||
@@ -244,6 +244,52 @@ def add_messages(
|
||||
return merged
|
||||
|
||||
|
||||
def _messages_delta_reducer(
|
||||
state: list[AnyMessage], writes: list[list[AnyMessage]]
|
||||
) -> list[AnyMessage]:
|
||||
"""**Experimental.** Batch reducer for use with `DeltaChannel`.
|
||||
|
||||
Processes all writes in one pass — dedup by ID, `RemoveMessage`
|
||||
tombstoning — without calling `add_messages`. Assumes writes contain
|
||||
already-typed `BaseMessage` objects (no raw-dict coercion).
|
||||
|
||||
This reducer is batching-invariant, as required by `DeltaChannel`:
|
||||
`reducer(reducer(state, xs), ys) == reducer(state, xs + ys)`.
|
||||
|
||||
Use `add_messages` as the reducer for `BinaryOperatorAggregate` or
|
||||
anywhere raw message dicts / strings need to be coerced first.
|
||||
|
||||
Example::
|
||||
|
||||
from typing import Annotated
|
||||
from langgraph.channels.delta import DeltaChannel
|
||||
from langgraph.graph.message import _messages_delta_reducer
|
||||
|
||||
class State(TypedDict):
|
||||
messages: Annotated[list, DeltaChannel(_messages_delta_reducer)]
|
||||
"""
|
||||
from itertools import chain
|
||||
|
||||
index: dict[str, int] = {m.id: i for i, m in enumerate(state) if m.id is not None}
|
||||
result: list[AnyMessage | None] = list(state)
|
||||
for msg in chain.from_iterable(
|
||||
[w] if isinstance(w, BaseMessage) else w for w in writes
|
||||
):
|
||||
mid = msg.id
|
||||
if mid is None:
|
||||
result.append(msg)
|
||||
elif isinstance(msg, RemoveMessage):
|
||||
if mid in index:
|
||||
result[index[mid]] = None
|
||||
del index[mid]
|
||||
elif mid in index:
|
||||
result[index[mid]] = msg
|
||||
else:
|
||||
index[mid] = len(result)
|
||||
result.append(msg)
|
||||
return [m for m in result if m is not None]
|
||||
|
||||
|
||||
@deprecated(
|
||||
"MessageGraph is deprecated in langgraph 1.0.0, to be removed in 2.0.0. Please use StateGraph with a `messages` key instead.",
|
||||
category=None,
|
||||
|
||||
@@ -7,6 +7,7 @@ import warnings
|
||||
from collections import defaultdict
|
||||
from collections.abc import Awaitable, Callable, Hashable, Sequence
|
||||
from dataclasses import is_dataclass
|
||||
from datetime import timedelta
|
||||
from functools import partial
|
||||
from inspect import isclass, isfunction, ismethod, signature
|
||||
from types import FunctionType
|
||||
@@ -45,9 +46,11 @@ from langgraph._internal._fields import (
|
||||
)
|
||||
from langgraph._internal._pydantic import create_model
|
||||
from langgraph._internal._runnable import coerce_to_runnable
|
||||
from langgraph._internal._timeout import coerce_timeout_policy
|
||||
from langgraph._internal._typing import EMPTY_SEQ, MISSING, DeprecatedKwargs
|
||||
from langgraph.channels.base import BaseChannel
|
||||
from langgraph.channels.binop import BinaryOperatorAggregate
|
||||
from langgraph.channels.delta import DeltaChannel
|
||||
from langgraph.channels.ephemeral_value import EphemeralValue
|
||||
from langgraph.channels.last_value import LastValue, LastValueAfterFinish
|
||||
from langgraph.channels.named_barrier_value import (
|
||||
@@ -81,6 +84,7 @@ from langgraph.types import (
|
||||
Command,
|
||||
RetryPolicy,
|
||||
Send,
|
||||
TimeoutPolicy,
|
||||
ensure_valid_checkpointer,
|
||||
)
|
||||
from langgraph.typing import ContextT, InputT, NodeInputT, OutputT, StateT
|
||||
@@ -299,7 +303,9 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
|
||||
input_schema: None = None,
|
||||
retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None,
|
||||
cache_policy: CachePolicy | None = None,
|
||||
error_handler: StateNode[Any, ContextT] | None = None,
|
||||
destinations: dict[str, str] | tuple[str, ...] | None = None,
|
||||
timeout: float | timedelta | TimeoutPolicy | None = None,
|
||||
**kwargs: Unpack[DeprecatedKwargs],
|
||||
) -> Self:
|
||||
"""Add a new node to the `StateGraph`, input schema is inferred as the state schema.
|
||||
@@ -366,7 +372,9 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
|
||||
input_schema: type[NodeInputT],
|
||||
retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None,
|
||||
cache_policy: CachePolicy | None = None,
|
||||
error_handler: StateNode[Any, ContextT] | None = None,
|
||||
destinations: dict[str, str] | tuple[str, ...] | None = None,
|
||||
timeout: float | timedelta | TimeoutPolicy | None = None,
|
||||
**kwargs: Unpack[DeprecatedKwargs],
|
||||
) -> Self:
|
||||
"""Add a new node to the `StateGraph` where input schema is specified.
|
||||
@@ -438,7 +446,9 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
|
||||
input_schema: None = None,
|
||||
retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None,
|
||||
cache_policy: CachePolicy | None = None,
|
||||
error_handler: StateNode[Any, ContextT] | None = None,
|
||||
destinations: dict[str, str] | tuple[str, ...] | None = None,
|
||||
timeout: float | timedelta | TimeoutPolicy | None = None,
|
||||
**kwargs: Unpack[DeprecatedKwargs],
|
||||
) -> Self:
|
||||
"""Add a new node to the `StateGraph`, input schema is inferred as the state schema.
|
||||
@@ -505,7 +515,9 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
|
||||
input_schema: type[NodeInputT],
|
||||
retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None,
|
||||
cache_policy: CachePolicy | None = None,
|
||||
error_handler: StateNode[Any, ContextT] | None = None,
|
||||
destinations: dict[str, str] | tuple[str, ...] | None = None,
|
||||
timeout: float | timedelta | TimeoutPolicy | None = None,
|
||||
**kwargs: Unpack[DeprecatedKwargs],
|
||||
) -> Self:
|
||||
"""Add a new node to the `StateGraph`, input schema is specified.
|
||||
@@ -579,7 +591,9 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
|
||||
input_schema: type[NodeInputT] | None = None,
|
||||
retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None,
|
||||
cache_policy: CachePolicy | None = None,
|
||||
error_handler: StateNode[Any, ContextT] | None = None,
|
||||
destinations: dict[str, str] | tuple[str, ...] | None = None,
|
||||
timeout: float | timedelta | TimeoutPolicy | None = None,
|
||||
**kwargs: Unpack[DeprecatedKwargs],
|
||||
) -> Self:
|
||||
"""Add a new node to the `StateGraph`.
|
||||
@@ -598,6 +612,7 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
|
||||
|
||||
If a sequence is provided, the first matching policy will be applied.
|
||||
cache_policy: The cache policy for the node.
|
||||
error_handler: Optional node-level error handler callable for this node.
|
||||
destinations: Destinations that indicate where a node can route to.
|
||||
|
||||
Useful for edgeless graphs with nodes that return `Command` objects.
|
||||
@@ -609,6 +624,14 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
|
||||
!!! warning
|
||||
|
||||
This is only used for graph rendering and doesn't have any effect on the graph execution.
|
||||
timeout: Timeout for each node attempt. A number or `timedelta` is
|
||||
a hard wall-clock cap and is not refreshed. Use `TimeoutPolicy`
|
||||
to configure both a wall-clock `run_timeout` and an
|
||||
`idle_timeout` refreshed by progress signals. When exceeded, a
|
||||
[`NodeTimeoutError`][langgraph.errors.NodeTimeoutError] is raised
|
||||
and the retry policy (if any) decides whether to retry. Timeouts
|
||||
are supported only for async nodes; sync nodes cannot be safely
|
||||
cancelled in-process.
|
||||
|
||||
Example:
|
||||
```python
|
||||
@@ -662,6 +685,7 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
|
||||
)
|
||||
if input_schema is None:
|
||||
input_schema = cast(type[NodeInputT] | None, input_)
|
||||
timeout = coerce_timeout_policy(timeout)
|
||||
|
||||
if not isinstance(node, str):
|
||||
action = node
|
||||
@@ -748,6 +772,25 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
|
||||
if destinations is not None:
|
||||
ends = destinations
|
||||
|
||||
resolved_input_schema: type[Any] = (
|
||||
input_schema or inferred_input_schema or self.state_schema
|
||||
)
|
||||
handler_node_name: str | None = None
|
||||
if error_handler is not None:
|
||||
handler_node_name = f"__error_handler__{node}"
|
||||
if handler_node_name in self.nodes:
|
||||
raise ValueError(
|
||||
f"Auto-generated error handler node `{handler_node_name}` already exists."
|
||||
)
|
||||
self.nodes[handler_node_name] = StateNodeSpec[Any, ContextT](
|
||||
coerce_to_runnable(error_handler, name=handler_node_name, trace=False), # type: ignore[arg-type]
|
||||
metadata=None,
|
||||
input_schema=resolved_input_schema,
|
||||
retry_policy=None,
|
||||
cache_policy=None,
|
||||
is_error_handler=True,
|
||||
)
|
||||
|
||||
if input_schema is not None:
|
||||
self.nodes[node] = StateNodeSpec[NodeInputT, ContextT](
|
||||
coerce_to_runnable(action, name=node, trace=False), # type: ignore[arg-type]
|
||||
@@ -755,8 +798,10 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
|
||||
input_schema=input_schema,
|
||||
retry_policy=retry_policy,
|
||||
cache_policy=cache_policy,
|
||||
error_handler_node=handler_node_name,
|
||||
ends=ends,
|
||||
defer=defer,
|
||||
timeout=timeout,
|
||||
)
|
||||
elif inferred_input_schema is not None:
|
||||
self.nodes[node] = StateNodeSpec(
|
||||
@@ -765,8 +810,10 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
|
||||
input_schema=inferred_input_schema,
|
||||
retry_policy=retry_policy,
|
||||
cache_policy=cache_policy,
|
||||
error_handler_node=handler_node_name,
|
||||
ends=ends,
|
||||
defer=defer,
|
||||
timeout=timeout,
|
||||
)
|
||||
else:
|
||||
self.nodes[node] = StateNodeSpec[StateT, ContextT](
|
||||
@@ -775,8 +822,10 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
|
||||
input_schema=self.state_schema,
|
||||
retry_policy=retry_policy,
|
||||
cache_policy=cache_policy,
|
||||
error_handler_node=handler_node_name,
|
||||
ends=ends,
|
||||
defer=defer,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
input_schema = input_schema or inferred_input_schema
|
||||
@@ -1031,7 +1080,6 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
|
||||
for node in interrupt:
|
||||
if node not in self.nodes:
|
||||
raise ValueError(f"Interrupt node `{node}` not found")
|
||||
|
||||
self.compiled = True
|
||||
return self
|
||||
|
||||
@@ -1045,6 +1093,7 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
|
||||
interrupt_after: All | list[str] | None = None,
|
||||
debug: bool = False,
|
||||
name: str | None = None,
|
||||
transformers: Sequence[Callable[[tuple[str, ...]], Any]] | None = None,
|
||||
) -> CompiledStateGraph[StateT, ContextT, InputT, OutputT]:
|
||||
"""Compiles the `StateGraph` into a `CompiledStateGraph` object.
|
||||
|
||||
@@ -1077,11 +1126,19 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
|
||||
interrupt_after: An optional list of node names to interrupt after.
|
||||
debug: A flag indicating whether to enable debug mode.
|
||||
name: The name to use for the compiled graph.
|
||||
transformers: Optional sequence of `StreamTransformer` classes or
|
||||
configured factories. Classes and factories are instantiated
|
||||
per run whenever `stream_v2` / `astream_v2` is called and are
|
||||
propagated to subgraph scopes. Custom factories should follow
|
||||
the standard `StreamTransformer` constructor shape by
|
||||
accepting `scope` as their first argument. Appended after the
|
||||
built-in stream transformers.
|
||||
|
||||
Returns:
|
||||
CompiledStateGraph: The compiled `StateGraph`.
|
||||
"""
|
||||
checkpointer = ensure_valid_checkpointer(checkpointer)
|
||||
|
||||
serde_allowlist: set[tuple[str, ...]] | None = None
|
||||
if _serde.STRICT_MSGPACK_ENABLED:
|
||||
schema_types: list[type[Any]] = [
|
||||
@@ -1136,6 +1193,11 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
|
||||
key for key, val in self.channels.items() if not is_managed_value(val)
|
||||
]
|
||||
)
|
||||
node_error_handler_map = {
|
||||
node_name: spec.error_handler_node
|
||||
for node_name, spec in self.nodes.items()
|
||||
if spec.error_handler_node is not None
|
||||
}
|
||||
|
||||
compiled = CompiledStateGraph[StateT, ContextT, InputT, OutputT](
|
||||
builder=self,
|
||||
@@ -1158,7 +1220,9 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
|
||||
debug=debug,
|
||||
store=store,
|
||||
cache=cache,
|
||||
node_error_handler_map=node_error_handler_map,
|
||||
name=name or "LangGraph",
|
||||
stream_transformers=transformers,
|
||||
)
|
||||
compiled._serde_allowlist = serde_allowlist
|
||||
|
||||
@@ -1331,7 +1395,10 @@ class CompiledStateGraph(
|
||||
metadata=node.metadata,
|
||||
retry_policy=node.retry_policy,
|
||||
cache_policy=node.cache_policy,
|
||||
is_error_handler=node.is_error_handler,
|
||||
error_handler_node=node.error_handler_node,
|
||||
bound=node.runnable, # type: ignore[arg-type]
|
||||
timeout=node.timeout,
|
||||
)
|
||||
else:
|
||||
raise RuntimeError
|
||||
@@ -1667,6 +1734,20 @@ def _is_field_channel(typ: type[Any]) -> BaseChannel | None:
|
||||
# Search through all annotated medata to find channel annotations
|
||||
for item in meta:
|
||||
if isinstance(item, BaseChannel):
|
||||
if isinstance(item, DeltaChannel) and hasattr(typ, "__origin__"):
|
||||
origin = typ.__origin__
|
||||
# Unwrap parameterized Required[X]/NotRequired[X] to X
|
||||
# (e.g. Annotated[NotRequired[dict[...]], ...]).
|
||||
if hasattr(origin, "__origin__") and origin.__origin__ in (
|
||||
Required,
|
||||
NotRequired,
|
||||
):
|
||||
origin = origin.__args__[0]
|
||||
item = item.__class__(
|
||||
item.reducer,
|
||||
origin,
|
||||
snapshot_frequency=item.snapshot_frequency,
|
||||
)
|
||||
return item
|
||||
elif isclass(item) and issubclass(item, BaseChannel):
|
||||
# ex, Annotated[int, EphemeralValue, SomeOtherAnnotation]
|
||||
|
||||
@@ -39,6 +39,7 @@ from langgraph._internal._constants import (
|
||||
CONFIG_KEY_CHECKPOINT_MAP,
|
||||
CONFIG_KEY_CHECKPOINT_NS,
|
||||
CONFIG_KEY_CHECKPOINTER,
|
||||
CONFIG_KEY_NODE_ERROR,
|
||||
CONFIG_KEY_READ,
|
||||
CONFIG_KEY_RESUME_MAP,
|
||||
CONFIG_KEY_RUNTIME,
|
||||
@@ -47,6 +48,7 @@ from langgraph._internal._constants import (
|
||||
CONFIG_KEY_TASK_ID,
|
||||
CONFIG_KEY_THREAD_ID,
|
||||
ERROR,
|
||||
ERROR_SOURCE_NODE,
|
||||
INTERRUPT,
|
||||
NO_WRITES,
|
||||
NS_END,
|
||||
@@ -66,6 +68,7 @@ from langgraph.channels.base import BaseChannel
|
||||
from langgraph.channels.topic import Topic
|
||||
from langgraph.channels.untracked_value import UntrackedValue
|
||||
from langgraph.constants import TAG_HIDDEN
|
||||
from langgraph.errors import NodeError
|
||||
from langgraph.managed.base import ManagedValueMapping
|
||||
from langgraph.pregel._call import get_runnable_for_task, identifier
|
||||
from langgraph.pregel._io import read_channels
|
||||
@@ -80,6 +83,7 @@ from langgraph.types import (
|
||||
PregelTask,
|
||||
RetryPolicy,
|
||||
Send,
|
||||
TimeoutPolicy,
|
||||
)
|
||||
|
||||
GetNextVersion = Callable[[V | None, None], V]
|
||||
@@ -114,13 +118,21 @@ class PregelTaskWrites(NamedTuple):
|
||||
|
||||
|
||||
class Call:
|
||||
__slots__ = ("func", "input", "retry_policy", "cache_policy", "callbacks")
|
||||
__slots__ = (
|
||||
"func",
|
||||
"input",
|
||||
"retry_policy",
|
||||
"cache_policy",
|
||||
"callbacks",
|
||||
"timeout",
|
||||
)
|
||||
|
||||
func: Callable
|
||||
input: tuple[tuple[Any, ...], dict[str, Any]]
|
||||
retry_policy: Sequence[RetryPolicy] | None
|
||||
cache_policy: CachePolicy | None
|
||||
callbacks: Callbacks
|
||||
timeout: TimeoutPolicy | None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -130,12 +142,14 @@ class Call:
|
||||
retry_policy: Sequence[RetryPolicy] | None,
|
||||
cache_policy: CachePolicy | None,
|
||||
callbacks: Callbacks,
|
||||
timeout: TimeoutPolicy | None = None,
|
||||
) -> None:
|
||||
self.func = func
|
||||
self.input = input
|
||||
self.retry_policy = retry_policy
|
||||
self.cache_policy = cache_policy
|
||||
self.callbacks = callbacks
|
||||
self.timeout = timeout
|
||||
|
||||
|
||||
def should_interrupt(
|
||||
@@ -281,7 +295,15 @@ def apply_writes(
|
||||
pending_writes_by_channel: dict[str, list[Any]] = defaultdict(list)
|
||||
for task in tasks:
|
||||
for chan, val in task.writes:
|
||||
if chan in (NO_WRITES, PUSH, RESUME, INTERRUPT, RETURN, ERROR):
|
||||
if chan in (
|
||||
NO_WRITES,
|
||||
PUSH,
|
||||
RESUME,
|
||||
INTERRUPT,
|
||||
RETURN,
|
||||
ERROR,
|
||||
ERROR_SOURCE_NODE,
|
||||
):
|
||||
pass
|
||||
elif chan in channels:
|
||||
pending_writes_by_channel[chan].append(val)
|
||||
@@ -733,11 +755,48 @@ def prepare_single_task(
|
||||
task_path[:3],
|
||||
writers=proc.flat_writers,
|
||||
subgraphs=proc.subgraphs,
|
||||
timeout=proc.timeout,
|
||||
)
|
||||
else:
|
||||
return PregelTask(task_id, name, task_path[:3])
|
||||
|
||||
|
||||
def _coerce_pending_error(value: Any) -> BaseException:
|
||||
if isinstance(value, BaseException):
|
||||
return value
|
||||
return Exception(str(value))
|
||||
|
||||
|
||||
def _read_errors_from_pending_writes(
|
||||
pending_writes: list[PendingWrite],
|
||||
) -> list[BaseException]:
|
||||
errors: list[BaseException] = []
|
||||
for _, channel, value in pending_writes:
|
||||
if channel == ERROR:
|
||||
errors.append(_coerce_pending_error(value))
|
||||
return errors
|
||||
|
||||
|
||||
def _read_error_for_task_id_from_pending_writes(
|
||||
pending_writes: list[PendingWrite], task_id: str
|
||||
) -> BaseException | None:
|
||||
for pending_task_id, channel, value in reversed(pending_writes):
|
||||
if pending_task_id == task_id and channel == ERROR:
|
||||
return _coerce_pending_error(value)
|
||||
return None
|
||||
|
||||
|
||||
def _read_error_source_node_from_pending_writes(
|
||||
pending_writes: list[PendingWrite], task_id: str
|
||||
) -> str | None:
|
||||
for pending_task_id, channel, value in reversed(pending_writes):
|
||||
if pending_task_id == task_id and channel == ERROR_SOURCE_NODE:
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return str(value)
|
||||
return None
|
||||
|
||||
|
||||
def prepare_push_task_functional(
|
||||
task_path: tuple[str, tuple, int, str, Call],
|
||||
# (PUSH, parent task path, idx of PUSH write, id of parent task, Call)
|
||||
@@ -870,6 +929,7 @@ def prepare_push_task_functional(
|
||||
cache_key,
|
||||
task_id,
|
||||
in_progress_task_path,
|
||||
timeout=call.timeout,
|
||||
)
|
||||
else:
|
||||
return PregelTask(task_id, name, in_progress_task_path)
|
||||
@@ -1041,11 +1101,153 @@ def prepare_push_task_send(
|
||||
translated_task_path,
|
||||
writers=proc.flat_writers,
|
||||
subgraphs=proc.subgraphs,
|
||||
timeout=packet.timeout if packet.timeout is not None else proc.timeout,
|
||||
)
|
||||
else:
|
||||
return PregelTask(task_id, packet.node, translated_task_path)
|
||||
|
||||
|
||||
def prepare_node_error_handler_task(
|
||||
failed_task: PregelExecutableTask,
|
||||
*,
|
||||
handler_node_name: str,
|
||||
failed_error: BaseException,
|
||||
checkpoint: Checkpoint,
|
||||
pending_writes: list[PendingWrite],
|
||||
processes: Mapping[str, PregelNode],
|
||||
channels: Mapping[str, BaseChannel],
|
||||
managed: ManagedValueMapping,
|
||||
config: RunnableConfig,
|
||||
step: int,
|
||||
stop: int,
|
||||
store: BaseStore | None = None,
|
||||
checkpointer: BaseCheckpointSaver | None = None,
|
||||
manager: None | ParentRunManager | AsyncParentRunManager = None,
|
||||
cache_policy: CachePolicy | None = None,
|
||||
retry_policy: Sequence[RetryPolicy] = (),
|
||||
) -> PregelExecutableTask | None:
|
||||
"""Prepare an immediate node-level error handler task for a failed task."""
|
||||
if handler_node_name not in processes:
|
||||
return None
|
||||
proc = processes[handler_node_name]
|
||||
proc_node = proc.node
|
||||
if proc_node is None:
|
||||
return None
|
||||
|
||||
checkpoint_id_bytes = binascii.unhexlify(checkpoint["id"].replace("-", ""))
|
||||
task_id_func = _xxhash_str if checkpoint["v"] > 1 else _uuid5_str
|
||||
configurable = config.get(CONF, {})
|
||||
parent_ns = configurable.get(CONFIG_KEY_CHECKPOINT_NS, "")
|
||||
checkpoint_ns = (
|
||||
f"{parent_ns}{NS_SEP}{handler_node_name}" if parent_ns else handler_node_name
|
||||
)
|
||||
task_id = task_id_func(
|
||||
checkpoint_id_bytes,
|
||||
checkpoint_ns,
|
||||
str(step),
|
||||
handler_node_name,
|
||||
PUSH,
|
||||
"node_error_handler",
|
||||
failed_task.id,
|
||||
)
|
||||
task_checkpoint_ns = f"{checkpoint_ns}:{task_id}"
|
||||
translated_task_path = (*failed_task.path[:3], "node_error_handler", False)
|
||||
metadata = {
|
||||
"langgraph_step": step,
|
||||
"langgraph_node": handler_node_name,
|
||||
"langgraph_triggers": PUSH_TRIGGER,
|
||||
"langgraph_path": translated_task_path,
|
||||
"langgraph_checkpoint_ns": task_checkpoint_ns,
|
||||
}
|
||||
if proc.metadata:
|
||||
metadata.update(proc.metadata)
|
||||
writes: deque[tuple[str, Any]] = deque()
|
||||
|
||||
effective_retry_policy = proc.retry_policy or retry_policy
|
||||
effective_cache_policy = proc.cache_policy or cache_policy
|
||||
if effective_cache_policy:
|
||||
args_key = effective_cache_policy.key_func(failed_task.input)
|
||||
cache_key = CacheKey(
|
||||
(
|
||||
CACHE_NS_WRITES,
|
||||
(identifier(proc) or "__dynamic__"),
|
||||
handler_node_name,
|
||||
),
|
||||
xxh3_128_hexdigest(
|
||||
args_key.encode() if isinstance(args_key, str) else args_key
|
||||
),
|
||||
effective_cache_policy.ttl,
|
||||
)
|
||||
else:
|
||||
cache_key = None
|
||||
|
||||
scratchpad = _scratchpad(
|
||||
config[CONF].get(CONFIG_KEY_SCRATCHPAD),
|
||||
pending_writes,
|
||||
task_id,
|
||||
xxh3_128_hexdigest(task_checkpoint_ns.encode()),
|
||||
config[CONF].get(CONFIG_KEY_RESUME_MAP),
|
||||
step,
|
||||
stop,
|
||||
)
|
||||
runtime = cast(Runtime, configurable.get(CONFIG_KEY_RUNTIME, DEFAULT_RUNTIME))
|
||||
runtime = runtime.override(
|
||||
store=store, previous=checkpoint["channel_values"].get(PREVIOUS, None)
|
||||
)
|
||||
additional_config: RunnableConfig = {
|
||||
"metadata": metadata,
|
||||
"tags": proc.tags,
|
||||
}
|
||||
return PregelExecutableTask(
|
||||
handler_node_name,
|
||||
failed_task.input,
|
||||
proc_node,
|
||||
writes,
|
||||
patch_config(
|
||||
merge_configs(config, additional_config),
|
||||
run_name=handler_node_name,
|
||||
callbacks=manager.get_child(f"graph:step:{step}") if manager else None,
|
||||
configurable={
|
||||
CONFIG_KEY_TASK_ID: task_id,
|
||||
CONFIG_KEY_SEND: writes.extend,
|
||||
CONFIG_KEY_READ: partial(
|
||||
local_read,
|
||||
scratchpad,
|
||||
channels,
|
||||
managed,
|
||||
PregelTaskWrites(
|
||||
translated_task_path,
|
||||
handler_node_name,
|
||||
writes,
|
||||
PUSH_TRIGGER,
|
||||
),
|
||||
),
|
||||
CONFIG_KEY_CHECKPOINTER: (
|
||||
checkpointer or configurable.get(CONFIG_KEY_CHECKPOINTER)
|
||||
),
|
||||
CONFIG_KEY_CHECKPOINT_MAP: {
|
||||
**configurable.get(CONFIG_KEY_CHECKPOINT_MAP, {}),
|
||||
parent_ns: checkpoint["id"],
|
||||
},
|
||||
CONFIG_KEY_CHECKPOINT_ID: None,
|
||||
CONFIG_KEY_CHECKPOINT_NS: task_checkpoint_ns,
|
||||
CONFIG_KEY_SCRATCHPAD: scratchpad,
|
||||
CONFIG_KEY_RUNTIME: runtime,
|
||||
CONFIG_KEY_NODE_ERROR: NodeError(
|
||||
node=failed_task.name, error=failed_error
|
||||
),
|
||||
},
|
||||
),
|
||||
PUSH_TRIGGER,
|
||||
effective_retry_policy,
|
||||
cache_key,
|
||||
task_id,
|
||||
translated_task_path,
|
||||
writers=proc.flat_writers,
|
||||
subgraphs=proc.subgraphs,
|
||||
)
|
||||
|
||||
|
||||
def checkpoint_null_version(
|
||||
checkpoint: Checkpoint,
|
||||
) -> V | None:
|
||||
@@ -1255,4 +1457,4 @@ def sanitize_untracked_values_in_send(
|
||||
for k, v in packet.arg.items()
|
||||
if not isinstance(channels.get(k), UntrackedValue)
|
||||
}
|
||||
return Send(node=packet.node, arg=sanitized_arg)
|
||||
return Send(node=packet.node, arg=sanitized_arg, timeout=packet.timeout)
|
||||
|
||||
@@ -8,6 +8,7 @@ import inspect
|
||||
import sys
|
||||
import types
|
||||
from collections.abc import Awaitable, Callable, Generator, Sequence
|
||||
from datetime import timedelta
|
||||
from typing import Any, Generic, TypeVar, cast
|
||||
|
||||
from langchain_core.runnables import Runnable
|
||||
@@ -20,9 +21,13 @@ from langgraph._internal._runnable import (
|
||||
is_async_callable,
|
||||
run_in_executor,
|
||||
)
|
||||
from langgraph._internal._timeout import (
|
||||
coerce_timeout_policy,
|
||||
sync_timeout_unsupported,
|
||||
)
|
||||
from langgraph.config import get_config
|
||||
from langgraph.pregel._write import ChannelWrite, ChannelWriteEntry
|
||||
from langgraph.types import CachePolicy, RetryPolicy
|
||||
from langgraph.types import CachePolicy, RetryPolicy, TimeoutPolicy
|
||||
|
||||
##
|
||||
# Utilities borrowed from cloudpickle.
|
||||
@@ -255,8 +260,31 @@ def call(
|
||||
*args: Any,
|
||||
retry_policy: Sequence[RetryPolicy] | None = None,
|
||||
cache_policy: CachePolicy | None = None,
|
||||
timeout: float | timedelta | TimeoutPolicy | None = None,
|
||||
**kwargs: Any,
|
||||
) -> SyncAsyncFuture[T]:
|
||||
return _call_with_options(
|
||||
func,
|
||||
args,
|
||||
kwargs,
|
||||
retry_policy=retry_policy,
|
||||
cache_policy=cache_policy,
|
||||
timeout=coerce_timeout_policy(timeout),
|
||||
)
|
||||
|
||||
|
||||
def _call_with_options(
|
||||
func: Callable[P, Awaitable[T]] | Callable[P, T],
|
||||
args: tuple[Any, ...],
|
||||
kwargs: dict[str, Any],
|
||||
*,
|
||||
retry_policy: Sequence[RetryPolicy] | None = None,
|
||||
cache_policy: CachePolicy | None = None,
|
||||
timeout: TimeoutPolicy | None = None,
|
||||
) -> SyncAsyncFuture[T]:
|
||||
if timeout is not None and not is_async_callable(func):
|
||||
name = getattr(func, "__name__", func.__class__.__name__)
|
||||
raise sync_timeout_unsupported(name, kind="Task")
|
||||
config = get_config()
|
||||
impl = config[CONF][CONFIG_KEY_CALL]
|
||||
fut = impl(
|
||||
@@ -265,5 +293,6 @@ def call(
|
||||
retry_policy=retry_policy,
|
||||
cache_policy=cache_policy,
|
||||
callbacks=config["callbacks"],
|
||||
timeout=timeout,
|
||||
)
|
||||
return fut
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from collections.abc import Callable, Mapping
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, cast
|
||||
|
||||
from langgraph.checkpoint.base import Checkpoint
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from langgraph.checkpoint.base import DELTA_SENTINEL, BaseCheckpointSaver, Checkpoint
|
||||
from langgraph.checkpoint.base.id import uuid6
|
||||
from langgraph.checkpoint.serde.types import _DeltaSnapshot
|
||||
|
||||
from langgraph._internal._typing import MISSING
|
||||
from langgraph.channels.base import BaseChannel
|
||||
from langgraph.channels.delta import DeltaChannel
|
||||
from langgraph.managed.base import ManagedValueMapping, ManagedValueSpec
|
||||
|
||||
LATEST_VERSION = 4
|
||||
|
||||
GetNextVersion = Callable[[Any, None], Any]
|
||||
|
||||
|
||||
def empty_checkpoint() -> Checkpoint:
|
||||
return Checkpoint(
|
||||
@@ -31,35 +37,87 @@ def create_checkpoint(
|
||||
*,
|
||||
id: str | None = None,
|
||||
updated_channels: set[str] | None = None,
|
||||
get_next_version: GetNextVersion | None = None,
|
||||
force_delta_snapshot: bool = False,
|
||||
) -> Checkpoint:
|
||||
"""Create a checkpoint for the given channels."""
|
||||
"""Create a checkpoint for the given channels.
|
||||
|
||||
For `DeltaChannel` with `snapshot_frequency=N`, snapshot steps write a
|
||||
`_DeltaSnapshot` blob rather than `DELTA_SENTINEL`, bounding the ancestor
|
||||
walk to at most N steps. Snapshots are eager: even if the channel had no
|
||||
write this step, a version bump is forced (via `get_next_version`) so the
|
||||
blob is stored by `put()`. Without `get_next_version` (e.g. static
|
||||
contexts), snapshot steps gracefully fall back to sentinel.
|
||||
|
||||
`force_delta_snapshot` writes available `DeltaChannel` values as snapshots
|
||||
regardless of `snapshot_frequency`. This is used by `durability="exit"`,
|
||||
where intermediate writes are not stored as ancestor `checkpoint_writes`.
|
||||
"""
|
||||
ts = datetime.now(timezone.utc).isoformat()
|
||||
if channels is None:
|
||||
values = checkpoint["channel_values"]
|
||||
channel_versions = checkpoint["channel_versions"]
|
||||
else:
|
||||
values = {}
|
||||
channel_versions = dict(checkpoint["channel_versions"])
|
||||
for k in channels:
|
||||
if k not in checkpoint["channel_versions"]:
|
||||
if k not in channel_versions:
|
||||
continue
|
||||
v = channels[k].checkpoint()
|
||||
if v is not MISSING:
|
||||
values[k] = v
|
||||
ch = channels[k]
|
||||
if (
|
||||
isinstance(ch, DeltaChannel)
|
||||
and (force_delta_snapshot or ch.is_snapshot_step(step))
|
||||
and ch.is_available()
|
||||
):
|
||||
# Eager snapshot: bump version if not already written this step
|
||||
# so put() includes this channel in new_versions and stores blob.
|
||||
if get_next_version is not None and (
|
||||
updated_channels is None or k not in updated_channels
|
||||
):
|
||||
channel_versions[k] = get_next_version(channel_versions[k], None)
|
||||
values[k] = _DeltaSnapshot(ch.get())
|
||||
else:
|
||||
v = ch.checkpoint()
|
||||
if v is not MISSING:
|
||||
values[k] = v
|
||||
return Checkpoint(
|
||||
v=LATEST_VERSION,
|
||||
ts=ts,
|
||||
id=id or str(uuid6(clock_seq=step)),
|
||||
channel_values=values,
|
||||
channel_versions=checkpoint["channel_versions"],
|
||||
channel_versions=channel_versions,
|
||||
versions_seen=checkpoint["versions_seen"],
|
||||
updated_channels=None if updated_channels is None else sorted(updated_channels),
|
||||
)
|
||||
|
||||
|
||||
def _needs_replay(spec: BaseChannel, stored: object) -> bool:
|
||||
"""True if `spec` is a `DeltaChannel` and the stored blob is a sentinel,
|
||||
requiring an ancestor walk to reconstruct.
|
||||
|
||||
`_DeltaSnapshot` blobs and plain values (migration) resolve directly via
|
||||
`from_checkpoint` — only `DELTA_SENTINEL` / `MISSING` trigger replay.
|
||||
"""
|
||||
if not isinstance(spec, DeltaChannel):
|
||||
return False
|
||||
return stored is MISSING or stored is DELTA_SENTINEL
|
||||
|
||||
|
||||
def channels_from_checkpoint(
|
||||
specs: Mapping[str, BaseChannel | ManagedValueSpec],
|
||||
checkpoint: Checkpoint,
|
||||
*,
|
||||
saver: BaseCheckpointSaver | None = None,
|
||||
config: RunnableConfig | None = None,
|
||||
) -> tuple[Mapping[str, BaseChannel], ManagedValueMapping]:
|
||||
"""Get channels from a checkpoint."""
|
||||
"""Hydrate channels from a checkpoint.
|
||||
|
||||
For most channels, `spec.from_checkpoint(checkpoint["channel_values"][k])`
|
||||
is sufficient. `DeltaChannel` is the exception: sentinel blobs require an
|
||||
ancestor walk via `saver._get_channel_writes_history`. The walk terminates
|
||||
at the nearest `_DeltaSnapshot` blob (step-based) or a pre-migration plain
|
||||
value, so read depth is bounded by `snapshot_frequency`.
|
||||
"""
|
||||
channel_specs: dict[str, BaseChannel] = {}
|
||||
managed_specs: dict[str, ManagedValueSpec] = {}
|
||||
for k, v in specs.items():
|
||||
@@ -67,13 +125,53 @@ def channels_from_checkpoint(
|
||||
channel_specs[k] = v
|
||||
else:
|
||||
managed_specs[k] = v
|
||||
return (
|
||||
{
|
||||
k: v.from_checkpoint(checkpoint["channel_values"].get(k, MISSING))
|
||||
for k, v in channel_specs.items()
|
||||
},
|
||||
managed_specs,
|
||||
)
|
||||
|
||||
channels: dict[str, BaseChannel] = {}
|
||||
for k, spec in channel_specs.items():
|
||||
ch: BaseChannel
|
||||
stored = checkpoint["channel_values"].get(k, MISSING)
|
||||
if _needs_replay(spec, stored) and saver is not None and config is not None:
|
||||
delta_spec = cast(DeltaChannel, spec)
|
||||
history = saver._get_channel_writes_history(config, k)
|
||||
replay_ch = delta_spec.from_checkpoint(history.seed)
|
||||
replay_ch.replay_writes(history.writes)
|
||||
ch = replay_ch
|
||||
else:
|
||||
ch = spec.from_checkpoint(stored)
|
||||
channels[k] = ch
|
||||
return channels, managed_specs
|
||||
|
||||
|
||||
async def achannels_from_checkpoint(
|
||||
specs: Mapping[str, BaseChannel | ManagedValueSpec],
|
||||
checkpoint: Checkpoint,
|
||||
*,
|
||||
saver: BaseCheckpointSaver | None = None,
|
||||
config: RunnableConfig | None = None,
|
||||
) -> tuple[Mapping[str, BaseChannel], ManagedValueMapping]:
|
||||
"""Async version of `channels_from_checkpoint`. See docstring there."""
|
||||
channel_specs: dict[str, BaseChannel] = {}
|
||||
managed_specs: dict[str, ManagedValueSpec] = {}
|
||||
for k, v in specs.items():
|
||||
if isinstance(v, BaseChannel):
|
||||
channel_specs[k] = v
|
||||
else:
|
||||
managed_specs[k] = v
|
||||
|
||||
channels: dict[str, BaseChannel] = {}
|
||||
for k, spec in channel_specs.items():
|
||||
ch: BaseChannel
|
||||
stored = checkpoint["channel_values"].get(k, MISSING)
|
||||
if _needs_replay(spec, stored) and saver is not None and config is not None:
|
||||
delta_spec = cast(DeltaChannel, spec)
|
||||
history = await saver._aget_channel_writes_history(config, k)
|
||||
replay_ch = delta_spec.from_checkpoint(history.seed)
|
||||
replay_ch.replay_writes(history.writes)
|
||||
ch = replay_ch
|
||||
else:
|
||||
ch = spec.from_checkpoint(stored)
|
||||
channels[k] = ch
|
||||
return channels, managed_specs
|
||||
|
||||
|
||||
def copy_checkpoint(checkpoint: Checkpoint) -> Checkpoint:
|
||||
|
||||
@@ -45,11 +45,13 @@ from langgraph._internal._constants import (
|
||||
CONFIG_KEY_REPLAY_STATE,
|
||||
CONFIG_KEY_RESUME_MAP,
|
||||
CONFIG_KEY_RESUMING,
|
||||
CONFIG_KEY_RUNTIME,
|
||||
CONFIG_KEY_SCRATCHPAD,
|
||||
CONFIG_KEY_STREAM,
|
||||
CONFIG_KEY_TASK_ID,
|
||||
CONFIG_KEY_THREAD_ID,
|
||||
ERROR,
|
||||
ERROR_SOURCE_NODE,
|
||||
INPUT,
|
||||
INTERRUPT,
|
||||
NS_END,
|
||||
@@ -62,7 +64,13 @@ from langgraph._internal._constants import (
|
||||
from langgraph._internal._replay import ReplayState
|
||||
from langgraph._internal._scratchpad import PregelScratchpad
|
||||
from langgraph._internal._typing import EMPTY_SEQ, MISSING
|
||||
from langgraph.callbacks import (
|
||||
GraphInterruptEvent,
|
||||
GraphLifecycleEvent,
|
||||
GraphResumeEvent,
|
||||
)
|
||||
from langgraph.channels.base import BaseChannel
|
||||
from langgraph.channels.delta import DeltaChannel
|
||||
from langgraph.channels.untracked_value import UntrackedValue
|
||||
from langgraph.constants import TAG_HIDDEN
|
||||
from langgraph.errors import (
|
||||
@@ -81,12 +89,14 @@ from langgraph.pregel._algo import (
|
||||
checkpoint_null_version,
|
||||
increment,
|
||||
prepare_next_tasks,
|
||||
prepare_node_error_handler_task,
|
||||
prepare_single_task,
|
||||
sanitize_untracked_values_in_send,
|
||||
should_interrupt,
|
||||
task_path_str,
|
||||
)
|
||||
from langgraph.pregel._checkpoint import (
|
||||
achannels_from_checkpoint,
|
||||
channels_from_checkpoint,
|
||||
copy_checkpoint,
|
||||
create_checkpoint,
|
||||
@@ -112,11 +122,13 @@ from langgraph.pregel.debug import (
|
||||
map_debug_tasks,
|
||||
)
|
||||
from langgraph.pregel.protocol import StreamChunk, StreamProtocol
|
||||
from langgraph.runtime import RunControl, Runtime
|
||||
from langgraph.types import (
|
||||
All,
|
||||
CachePolicy,
|
||||
Command,
|
||||
Durability,
|
||||
Interrupt,
|
||||
PregelExecutableTask,
|
||||
RetryPolicy,
|
||||
Send,
|
||||
@@ -182,6 +194,8 @@ class PregelLoop:
|
||||
_migrate_checkpoint: Callable[[Checkpoint], None] | None
|
||||
submit: Submit
|
||||
channels: Mapping[str, BaseChannel]
|
||||
# Only set on AsyncPregelLoop; sync loops keep this as None.
|
||||
_delta_write_futs: list[Any] | None = None
|
||||
managed: ManagedValueMapping
|
||||
checkpoint: Checkpoint
|
||||
checkpoint_id_saved: str
|
||||
@@ -196,13 +210,17 @@ class PregelLoop:
|
||||
"input",
|
||||
"pending",
|
||||
"done",
|
||||
"draining",
|
||||
"interrupt_before",
|
||||
"interrupt_after",
|
||||
"out_of_steps",
|
||||
]
|
||||
control: RunControl | None
|
||||
tasks: dict[str, PregelExecutableTask]
|
||||
output: None | dict[str, Any] | Any = None
|
||||
updated_channels: set[str] | None = None
|
||||
_graph_lifecycle_events: deque[GraphLifecycleEvent]
|
||||
_has_graph_lifecycle_callbacks: bool
|
||||
|
||||
# public
|
||||
|
||||
@@ -228,6 +246,7 @@ class PregelLoop:
|
||||
migrate_checkpoint: Callable[[Checkpoint], None] | None = None,
|
||||
retry_policy: Sequence[RetryPolicy] = (),
|
||||
cache_policy: CachePolicy | None = None,
|
||||
has_graph_lifecycle_callbacks: bool = False,
|
||||
) -> None:
|
||||
self.stream = stream
|
||||
self.config = config
|
||||
@@ -252,6 +271,8 @@ class PregelLoop:
|
||||
self.retry_policy = retry_policy
|
||||
self.cache_policy = cache_policy
|
||||
self.durability = durability
|
||||
self._has_graph_lifecycle_callbacks = has_graph_lifecycle_callbacks
|
||||
self._graph_lifecycle_events = deque()
|
||||
if self.stream is not None and CONFIG_KEY_STREAM in config[CONF]:
|
||||
self.stream = DuplexStream(self.stream, config[CONF][CONFIG_KEY_STREAM])
|
||||
scratchpad: PregelScratchpad | None = config[CONF].get(CONFIG_KEY_SCRATCHPAD)
|
||||
@@ -302,6 +323,47 @@ class PregelLoop:
|
||||
else ()
|
||||
)
|
||||
self.prev_checkpoint_config = None
|
||||
runtime = self.config[CONF].get(CONFIG_KEY_RUNTIME)
|
||||
self.control = runtime.control if isinstance(runtime, Runtime) else None
|
||||
|
||||
def _push_graph_lifecycle_event(
|
||||
self,
|
||||
kind: Literal["resume", "interrupt"],
|
||||
*,
|
||||
interrupts: tuple[Interrupt, ...] = (),
|
||||
) -> None:
|
||||
# drain status never reaches lifecycle events: tick() returns False
|
||||
# before pushing, and interrupts are raised through GraphInterrupt
|
||||
if self.status == "draining":
|
||||
raise RuntimeError("Draining status cannot emit lifecycle events")
|
||||
status = self.status
|
||||
if kind == "resume":
|
||||
self._graph_lifecycle_events.append(
|
||||
GraphResumeEvent(
|
||||
run_id=None,
|
||||
status=status,
|
||||
checkpoint_id=self.checkpoint["id"],
|
||||
checkpoint_ns=self.checkpoint_ns,
|
||||
)
|
||||
)
|
||||
elif kind == "interrupt":
|
||||
self._graph_lifecycle_events.append(
|
||||
GraphInterruptEvent(
|
||||
run_id=None,
|
||||
status=status,
|
||||
checkpoint_id=self.checkpoint["id"],
|
||||
checkpoint_ns=self.checkpoint_ns,
|
||||
interrupts=interrupts,
|
||||
)
|
||||
)
|
||||
else:
|
||||
msg = f"Unknown graph lifecycle event type: {kind}"
|
||||
raise AssertionError(msg)
|
||||
|
||||
def _pop_lifecycle_event(self) -> GraphLifecycleEvent | None:
|
||||
if not self._graph_lifecycle_events:
|
||||
return None
|
||||
return self._graph_lifecycle_events.popleft()
|
||||
|
||||
def put_writes(self, task_id: str, writes: WritesT) -> None:
|
||||
"""Put writes for a task, to be read by the next tick."""
|
||||
@@ -361,7 +423,7 @@ class PregelLoop:
|
||||
task = self.tasks.get(task_id)
|
||||
else:
|
||||
task = None
|
||||
self.submit(
|
||||
fut = self.submit(
|
||||
self.checkpointer_put_writes,
|
||||
config,
|
||||
writes_to_save,
|
||||
@@ -369,12 +431,16 @@ class PregelLoop:
|
||||
task_path_str(task.path) if task else "",
|
||||
)
|
||||
else:
|
||||
self.submit(
|
||||
fut = self.submit(
|
||||
self.checkpointer_put_writes,
|
||||
config,
|
||||
writes_to_save,
|
||||
task_id,
|
||||
)
|
||||
if self._delta_write_futs is not None and any(
|
||||
isinstance(self.specs.get(c), DeltaChannel) for c, _ in writes_to_save
|
||||
):
|
||||
self._delta_write_futs.append(fut)
|
||||
# output writes
|
||||
if hasattr(self, "tasks"):
|
||||
self.output_writes(task_id, writes)
|
||||
@@ -458,6 +524,16 @@ class PregelLoop:
|
||||
# return the new task, to be started if not run before
|
||||
return pushed
|
||||
|
||||
def schedule_error_handler(
|
||||
self, failed_task: PregelExecutableTask, error: BaseException
|
||||
) -> PregelExecutableTask | None:
|
||||
raise NotImplementedError
|
||||
|
||||
async def aschedule_error_handler(
|
||||
self, failed_task: PregelExecutableTask, error: BaseException
|
||||
) -> PregelExecutableTask | None:
|
||||
raise NotImplementedError
|
||||
|
||||
def tick(self) -> bool:
|
||||
"""Execute a single iteration of the Pregel loop.
|
||||
|
||||
@@ -516,6 +592,10 @@ class PregelLoop:
|
||||
self.status = "done"
|
||||
return False
|
||||
|
||||
if self.control is not None and self.control.drain_requested:
|
||||
self.status = "draining"
|
||||
return False
|
||||
|
||||
# if there are pending writes from a previous loop, apply them
|
||||
if not self.is_replaying and self.checkpoint_pending_writes:
|
||||
self._match_writes(self.tasks)
|
||||
@@ -582,7 +662,7 @@ class PregelLoop:
|
||||
|
||||
def _match_writes(self, tasks: Mapping[str, PregelExecutableTask]) -> None:
|
||||
for tid, k, v in self.checkpoint_pending_writes:
|
||||
if k in (ERROR, INTERRUPT, RESUME):
|
||||
if k in (ERROR, ERROR_SOURCE_NODE, INTERRUPT, RESUME):
|
||||
continue
|
||||
if task := tasks.get(tid):
|
||||
task.writes.append((k, v))
|
||||
@@ -647,7 +727,7 @@ class PregelLoop:
|
||||
# writes so that interrupt() calls re-fire instead of returning
|
||||
# stale values. But if we're actively resuming, keep them —
|
||||
# multi-interrupt scenarios need previously resolved values preserved.
|
||||
if self.is_replaying and (
|
||||
is_time_traveling = self.is_replaying and (
|
||||
# Time-travel to a subgraph checkpoint: the parent sets
|
||||
# RESUMING=True (it can't distinguish time-travel from resume),
|
||||
# so we check if this subgraph's own ns is in checkpoint_map.
|
||||
@@ -665,7 +745,8 @@ class PregelLoop:
|
||||
# (subgraph input is a Send arg, not a Command)
|
||||
or configurable.get(CONFIG_KEY_RESUMING, False)
|
||||
)
|
||||
):
|
||||
)
|
||||
if is_time_traveling:
|
||||
self.checkpoint_pending_writes = [
|
||||
w for w in self.checkpoint_pending_writes if w[1] != RESUME
|
||||
]
|
||||
@@ -720,6 +801,26 @@ class PregelLoop:
|
||||
if k in self.checkpoint["channel_versions"]:
|
||||
version = self.checkpoint["channel_versions"][k]
|
||||
self.checkpoint["versions_seen"][INTERRUPT][k] = version
|
||||
# When time-traveling (replaying from a specific checkpoint),
|
||||
# save a fork checkpoint so the replayed execution creates a
|
||||
# new branch. Without this, if the execution hits an interrupt
|
||||
# before after_tick() runs, no new checkpoint is created —
|
||||
# the parent's latest checkpoint remains the old one and
|
||||
# subsequent resumes load the wrong state.
|
||||
# Skip for update_state forks (source=update/fork) since they
|
||||
# already have their own fork checkpoint.
|
||||
if is_time_traveling and self.checkpoint_metadata.get("source") not in (
|
||||
"update",
|
||||
"fork",
|
||||
):
|
||||
# Clear old INTERRUPT writes from the loaded checkpoint.
|
||||
# The fork will have a new checkpoint_id which changes
|
||||
# task IDs — stale interrupt writes would accumulate and
|
||||
# confuse the multiple-interrupt check in future resumes.
|
||||
self.checkpoint_pending_writes = [
|
||||
w for w in self.checkpoint_pending_writes if w[1] != INTERRUPT
|
||||
]
|
||||
self._put_checkpoint({"source": "fork"})
|
||||
# produce values output
|
||||
self._emit(
|
||||
"values", map_output_values, self.output_keys, True, self.channels
|
||||
@@ -762,14 +863,28 @@ class PregelLoop:
|
||||
if not self.is_nested:
|
||||
# Pass the resolved before-bound checkpoint ID so subgraphs can
|
||||
# find their corresponding checkpoint without re-fetching the
|
||||
# parent. For forks (source=update), use the fork's parent
|
||||
# parent. For forks (source=update/fork), use the fork's parent
|
||||
# checkpoint ID since the fork was created after the subgraph's
|
||||
# checkpoints from the original execution.
|
||||
#
|
||||
# Only gate on is_time_traveling (not is_replaying). When the
|
||||
# client resumes with an explicit checkpoint_id that happens to
|
||||
# point at the current head (e.g. LangGraph Studio sending
|
||||
# `checkpoint: {checkpoint_id}` alongside Command(resume=...)),
|
||||
# is_replaying is True but is_time_traveling is False. In that
|
||||
# case subgraphs should load their latest checkpoint normally,
|
||||
# not go through ReplayState's before-bound lookup which would
|
||||
# miss subgraph checkpoints created during processing of the
|
||||
# current parent step.
|
||||
replay_state: ReplayState | None = None
|
||||
if self.is_replaying:
|
||||
if is_time_traveling:
|
||||
replay_checkpoint_id = self.checkpoint["id"]
|
||||
if (
|
||||
self.checkpoint_metadata.get("source") == "update"
|
||||
self.checkpoint_metadata.get("source")
|
||||
in (
|
||||
"update",
|
||||
"fork",
|
||||
)
|
||||
and self.prev_checkpoint_config
|
||||
):
|
||||
replay_checkpoint_id = self.prev_checkpoint_config[CONF].get(
|
||||
@@ -785,6 +900,8 @@ class PregelLoop:
|
||||
)
|
||||
# set flag
|
||||
self.status = "pending"
|
||||
if is_resuming:
|
||||
self._push_graph_lifecycle_event("resume")
|
||||
return updated_channels
|
||||
|
||||
def _put_checkpoint(self, metadata: CheckpointMetadata) -> None:
|
||||
@@ -808,6 +925,10 @@ class PregelLoop:
|
||||
self.step,
|
||||
id=self.checkpoint["id"] if exiting else None,
|
||||
updated_channels=self.updated_channels,
|
||||
get_next_version=self.checkpointer_get_next_version
|
||||
if do_checkpoint
|
||||
else None,
|
||||
force_delta_snapshot=exiting and self.durability == "exit",
|
||||
)
|
||||
# sanitize TASK channel in the checkpoint before saving (durability=="exit")
|
||||
if TASKS in self.checkpoint["channel_values"] and any(
|
||||
@@ -885,8 +1006,10 @@ class PregelLoop:
|
||||
self._put_checkpoint(self.checkpoint_metadata)
|
||||
self._put_pending_writes()
|
||||
# suppress interrupt
|
||||
suppress = isinstance(exc_value, GraphInterrupt) and not self.is_nested
|
||||
if suppress:
|
||||
if isinstance(exc_value, GraphInterrupt) and not self.is_nested:
|
||||
interrupt = exc_value
|
||||
interrupts = tuple(interrupt.args[0]) if interrupt.args else ()
|
||||
self._push_graph_lifecycle_event("interrupt", interrupts=interrupts)
|
||||
# emit one last "values" event, with pending writes applied
|
||||
if (
|
||||
hasattr(self, "tasks")
|
||||
@@ -913,12 +1036,11 @@ class PregelLoop:
|
||||
self.channels,
|
||||
)
|
||||
# emit INTERRUPT if exception is empty (otherwise emitted by put_writes)
|
||||
if exc_value is not None and (not exc_value.args or not exc_value.args[0]):
|
||||
if not interrupt.args or not interrupt.args[0]:
|
||||
interrupt_payload = interrupt.args[0] if interrupt.args else ()
|
||||
self._emit(
|
||||
"updates",
|
||||
lambda: iter(
|
||||
[{INTERRUPT: cast(GraphInterrupt, exc_value).args[0]}]
|
||||
),
|
||||
lambda: iter([{INTERRUPT: interrupt_payload}]),
|
||||
)
|
||||
# save final output
|
||||
self.output = read_channels(self.channels, self.output_keys)
|
||||
@@ -1040,6 +1162,7 @@ class SyncPregelLoop(PregelLoop, AbstractContextManager):
|
||||
migrate_checkpoint: Callable[[Checkpoint], None] | None = None,
|
||||
retry_policy: Sequence[RetryPolicy] = (),
|
||||
cache_policy: CachePolicy | None = None,
|
||||
has_graph_lifecycle_callbacks: bool = False,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
input,
|
||||
@@ -1061,6 +1184,7 @@ class SyncPregelLoop(PregelLoop, AbstractContextManager):
|
||||
retry_policy=retry_policy,
|
||||
cache_policy=cache_policy,
|
||||
durability=durability,
|
||||
has_graph_lifecycle_callbacks=has_graph_lifecycle_callbacks,
|
||||
)
|
||||
self.stack = ExitStack()
|
||||
if checkpointer:
|
||||
@@ -1115,6 +1239,45 @@ class SyncPregelLoop(PregelLoop, AbstractContextManager):
|
||||
self.output_writes(task.id, task.writes, cached=True)
|
||||
return pushed
|
||||
|
||||
def schedule_error_handler(
|
||||
self, failed_task: PregelExecutableTask, error: BaseException
|
||||
) -> PregelExecutableTask | None:
|
||||
handler_node = self.nodes[failed_task.name].error_handler_node
|
||||
if not handler_node:
|
||||
return None
|
||||
writes = list(failed_task.writes)
|
||||
writes.append((ERROR_SOURCE_NODE, failed_task.name))
|
||||
self.put_writes(
|
||||
failed_task.id,
|
||||
writes,
|
||||
)
|
||||
handler_task = prepare_node_error_handler_task(
|
||||
failed_task,
|
||||
handler_node_name=handler_node,
|
||||
failed_error=error,
|
||||
checkpoint=self.checkpoint,
|
||||
pending_writes=self.checkpoint_pending_writes,
|
||||
processes=self.nodes,
|
||||
channels=self.channels,
|
||||
managed=self.managed,
|
||||
config=failed_task.config,
|
||||
step=self.step,
|
||||
stop=self.stop,
|
||||
store=self.store,
|
||||
checkpointer=self.checkpointer,
|
||||
manager=self.manager,
|
||||
retry_policy=self.retry_policy,
|
||||
cache_policy=self.cache_policy,
|
||||
)
|
||||
if handler_task is None:
|
||||
return None
|
||||
self.tasks[handler_task.id] = handler_task
|
||||
if not self.is_replaying:
|
||||
self._match_writes({handler_task.id: handler_task})
|
||||
for task in self.match_cached_writes():
|
||||
self.output_writes(task.id, task.writes, cached=True)
|
||||
return handler_task
|
||||
|
||||
def put_writes(self, task_id: str, writes: WritesT) -> None:
|
||||
"""Put writes for a task, to be read by the next tick."""
|
||||
super().put_writes(task_id, writes)
|
||||
@@ -1136,6 +1299,7 @@ class SyncPregelLoop(PregelLoop, AbstractContextManager):
|
||||
# context manager
|
||||
|
||||
def __enter__(self) -> Self:
|
||||
self._graph_lifecycle_events = deque()
|
||||
if not self.checkpointer:
|
||||
saved = None
|
||||
elif self.checkpoint_config[CONF].get(CONFIG_KEY_CHECKPOINT_ID):
|
||||
@@ -1187,7 +1351,10 @@ class SyncPregelLoop(PregelLoop, AbstractContextManager):
|
||||
)
|
||||
self.submit = self.stack.enter_context(BackgroundExecutor(self.config))
|
||||
self.channels, self.managed = channels_from_checkpoint(
|
||||
self.specs, self.checkpoint
|
||||
self.specs,
|
||||
self.checkpoint,
|
||||
saver=self.checkpointer,
|
||||
config=self.checkpoint_config,
|
||||
)
|
||||
self.stack.push(self._suppress_interrupt)
|
||||
self.status = "input"
|
||||
@@ -1236,6 +1403,7 @@ class AsyncPregelLoop(PregelLoop, AbstractAsyncContextManager):
|
||||
migrate_checkpoint: Callable[[Checkpoint], None] | None = None,
|
||||
retry_policy: Sequence[RetryPolicy] = (),
|
||||
cache_policy: CachePolicy | None = None,
|
||||
has_graph_lifecycle_callbacks: bool = False,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
input,
|
||||
@@ -1257,6 +1425,7 @@ class AsyncPregelLoop(PregelLoop, AbstractAsyncContextManager):
|
||||
retry_policy=retry_policy,
|
||||
cache_policy=cache_policy,
|
||||
durability=durability,
|
||||
has_graph_lifecycle_callbacks=has_graph_lifecycle_callbacks,
|
||||
)
|
||||
self.stack = AsyncExitStack()
|
||||
if checkpointer:
|
||||
@@ -1280,6 +1449,11 @@ class AsyncPregelLoop(PregelLoop, AbstractAsyncContextManager):
|
||||
metadata: CheckpointMetadata,
|
||||
new_versions: ChannelVersions,
|
||||
) -> RunnableConfig:
|
||||
# Drain DeltaChannel write futures before committing the checkpoint so
|
||||
# DELTA_SENTINEL blobs are never saved ahead of their backing writes.
|
||||
if self._delta_write_futs:
|
||||
futs, self._delta_write_futs = self._delta_write_futs, []
|
||||
await asyncio.gather(*futs)
|
||||
try:
|
||||
if prev is not None:
|
||||
await prev
|
||||
@@ -1311,6 +1485,45 @@ class AsyncPregelLoop(PregelLoop, AbstractAsyncContextManager):
|
||||
self.output_writes(task.id, task.writes, cached=True)
|
||||
return pushed
|
||||
|
||||
async def aschedule_error_handler(
|
||||
self, failed_task: PregelExecutableTask, error: BaseException
|
||||
) -> PregelExecutableTask | None:
|
||||
handler_node = self.nodes[failed_task.name].error_handler_node
|
||||
if not handler_node:
|
||||
return None
|
||||
writes = list(failed_task.writes)
|
||||
writes.append((ERROR_SOURCE_NODE, failed_task.name))
|
||||
self.put_writes(
|
||||
failed_task.id,
|
||||
writes,
|
||||
)
|
||||
handler_task = prepare_node_error_handler_task(
|
||||
failed_task,
|
||||
handler_node_name=handler_node,
|
||||
failed_error=error,
|
||||
checkpoint=self.checkpoint,
|
||||
pending_writes=self.checkpoint_pending_writes,
|
||||
processes=self.nodes,
|
||||
channels=self.channels,
|
||||
managed=self.managed,
|
||||
config=failed_task.config,
|
||||
step=self.step,
|
||||
stop=self.stop,
|
||||
store=self.store,
|
||||
checkpointer=self.checkpointer,
|
||||
manager=self.manager,
|
||||
retry_policy=self.retry_policy,
|
||||
cache_policy=self.cache_policy,
|
||||
)
|
||||
if handler_task is None:
|
||||
return None
|
||||
self.tasks[handler_task.id] = handler_task
|
||||
if not self.is_replaying:
|
||||
self._match_writes({handler_task.id: handler_task})
|
||||
for task in await self.amatch_cached_writes():
|
||||
self.output_writes(task.id, task.writes, cached=True)
|
||||
return handler_task
|
||||
|
||||
def put_writes(self, task_id: str, writes: WritesT) -> None:
|
||||
"""Put writes for a task, to be read by the next tick."""
|
||||
super().put_writes(task_id, writes)
|
||||
@@ -1335,6 +1548,7 @@ class AsyncPregelLoop(PregelLoop, AbstractAsyncContextManager):
|
||||
# context manager
|
||||
|
||||
async def __aenter__(self) -> Self:
|
||||
self._graph_lifecycle_events = deque()
|
||||
if not self.checkpointer:
|
||||
saved = None
|
||||
elif self.checkpoint_config[CONF].get(CONFIG_KEY_CHECKPOINT_ID):
|
||||
@@ -1384,11 +1598,15 @@ class AsyncPregelLoop(PregelLoop, AbstractAsyncContextManager):
|
||||
if saved.pending_writes is not None
|
||||
else []
|
||||
)
|
||||
self._delta_write_futs = []
|
||||
self.submit = await self.stack.enter_async_context(
|
||||
AsyncBackgroundExecutor(self.config)
|
||||
)
|
||||
self.channels, self.managed = channels_from_checkpoint(
|
||||
self.specs, self.checkpoint
|
||||
self.channels, self.managed = await achannels_from_checkpoint(
|
||||
self.specs,
|
||||
self.checkpoint,
|
||||
saver=self.checkpointer,
|
||||
config=self.checkpoint_config,
|
||||
)
|
||||
self.stack.push(self._suppress_interrupt)
|
||||
self.status = "input"
|
||||
|
||||
@@ -24,6 +24,11 @@ try:
|
||||
except ImportError:
|
||||
_StreamingCallbackHandler = object # type: ignore
|
||||
|
||||
try:
|
||||
from langchain_core.tracers._streaming import _V2StreamingCallbackHandler
|
||||
except ImportError:
|
||||
_V2StreamingCallbackHandler = object # type: ignore
|
||||
|
||||
T = TypeVar("T")
|
||||
Meta = tuple[tuple[str, ...], dict[str, Any]]
|
||||
|
||||
@@ -248,3 +253,126 @@ class StreamMessagesHandler(BaseCallbackHandler, _StreamingCallbackHandler):
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
self.metadata.pop(run_id, None)
|
||||
|
||||
|
||||
class StreamMessagesHandlerV2(StreamMessagesHandler, _V2StreamingCallbackHandler):
|
||||
"""v2 variant of `StreamMessagesHandler`.
|
||||
|
||||
Declaring `_V2StreamingCallbackHandler` as a base flips
|
||||
`BaseChatModel.invoke` to route through `_stream_chat_model_events`
|
||||
(firing `on_stream_event`) instead of `_stream` (firing
|
||||
`on_llm_new_token`). Inherits `on_stream_event` from the parent,
|
||||
which forwards protocol events onto the messages stream channel.
|
||||
|
||||
Pregel attaches this class instead of the v1 handler only when
|
||||
`StreamingHandler` opts in via the internal
|
||||
`CONFIG_KEY_STREAM_MESSAGES_V2` config key; direct
|
||||
`graph.stream(stream_mode="messages")` callers keep the v1
|
||||
AIMessageChunk shape.
|
||||
"""
|
||||
|
||||
def on_llm_new_token(
|
||||
self,
|
||||
token: str,
|
||||
*,
|
||||
chunk: ChatGenerationChunk | None = None,
|
||||
run_id: UUID,
|
||||
parent_run_id: UUID | None = None,
|
||||
tags: list[str] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
"""Intentional no-op — v1 chunks are not used on v2-flagged runs.
|
||||
|
||||
The v2 marker already steers `invoke` to the event generator, so
|
||||
`on_llm_new_token` should not fire under normal routing. This
|
||||
override stays a pass-through (no call to `super()`) to make
|
||||
the intent explicit and to guard against any caller (e.g. a
|
||||
node that calls `model.stream()` directly, which still fires
|
||||
the v1 callback) leaking AIMessageChunks onto a v2-flagged
|
||||
messages stream.
|
||||
"""
|
||||
# Intentionally empty: v2 handler does not forward v1 chunks.
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
stream: Callable[[StreamChunk], None],
|
||||
subgraphs: bool,
|
||||
*,
|
||||
parent_ns: tuple[str, ...] | None = None,
|
||||
) -> None:
|
||||
super().__init__(stream, subgraphs, parent_ns=parent_ns)
|
||||
self._streamed_run_ids: set[UUID] = set()
|
||||
|
||||
def on_llm_end(
|
||||
self,
|
||||
response: LLMResult,
|
||||
*,
|
||||
run_id: UUID,
|
||||
parent_run_id: UUID | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
if meta := self.metadata.get(run_id):
|
||||
if response.generations and response.generations[0]:
|
||||
gen = response.generations[0][0]
|
||||
if isinstance(gen, ChatGeneration):
|
||||
if run_id in self._streamed_run_ids:
|
||||
if gen.message.id is None:
|
||||
gen.message.id = str(uuid4())
|
||||
self.seen.add(gen.message.id)
|
||||
else:
|
||||
self._emit(meta, gen.message, dedupe=True)
|
||||
self._streamed_run_ids.discard(run_id)
|
||||
self.metadata.pop(run_id, None)
|
||||
|
||||
def on_llm_error(
|
||||
self,
|
||||
error: BaseException,
|
||||
*,
|
||||
run_id: UUID,
|
||||
parent_run_id: UUID | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
self._streamed_run_ids.discard(run_id)
|
||||
super().on_llm_error(
|
||||
error,
|
||||
run_id=run_id,
|
||||
parent_run_id=parent_run_id,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def on_stream_event(
|
||||
self,
|
||||
event: dict[str, Any],
|
||||
*,
|
||||
run_id: UUID,
|
||||
parent_run_id: UUID | None = None,
|
||||
tags: list[str] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
"""Forward a protocol event from `stream_v2` as a messages stream part.
|
||||
|
||||
Fires once per `MessagesData` event (`message-start`, per-block
|
||||
`content-block-*`, `message-finish`). The transformer layer
|
||||
correlates events back to a single `ChatModelStream` via
|
||||
`metadata["run_id"]` — attached here so the v1
|
||||
`stream_mode="messages"` output (which emits
|
||||
`(AIMessageChunk, metadata)` via `on_llm_new_token`) keeps its
|
||||
original metadata shape.
|
||||
|
||||
Lives on the v2 handler rather than the v1 base: content-block
|
||||
events are a v2-only concept, and forwarding them only when the
|
||||
v2 handler is attached keeps the message channel's shape
|
||||
predictable for v1 callers.
|
||||
"""
|
||||
if meta := self.metadata.get(run_id):
|
||||
# Record message_id on message-start so on_chain_end's
|
||||
# dedupe skips the finalized AIMessage the node returns
|
||||
# (otherwise the messages projection double-counts: once
|
||||
# from streaming, once from the chain output).
|
||||
if event.get("event") == "message-start":
|
||||
self._streamed_run_ids.add(run_id)
|
||||
msg_id = event.get("message_id")
|
||||
if msg_id:
|
||||
self.seen.add(msg_id)
|
||||
v2_meta = {**meta[1], "run_id": str(run_id)}
|
||||
self.stream((meta[0], "messages", (event, v2_meta)))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator, Callable, Iterator, Mapping, Sequence
|
||||
from datetime import timedelta
|
||||
from functools import cached_property
|
||||
from typing import (
|
||||
Any,
|
||||
@@ -11,10 +12,11 @@ from langchain_core.runnables import Runnable, RunnableConfig
|
||||
from langgraph._internal._config import merge_configs
|
||||
from langgraph._internal._constants import CONF, CONFIG_KEY_READ
|
||||
from langgraph._internal._runnable import RunnableCallable, RunnableSeq
|
||||
from langgraph._internal._timeout import coerce_timeout_policy
|
||||
from langgraph.pregel._utils import find_subgraph_pregel
|
||||
from langgraph.pregel._write import ChannelWrite
|
||||
from langgraph.pregel.protocol import PregelProtocol
|
||||
from langgraph.types import CachePolicy, RetryPolicy
|
||||
from langgraph.types import CachePolicy, RetryPolicy, TimeoutPolicy
|
||||
|
||||
READ_TYPE = Callable[[str | Sequence[str], bool], Any | dict[str, Any]]
|
||||
INPUT_CACHE_KEY_TYPE = tuple[Callable[..., Any], tuple[str, ...]]
|
||||
@@ -123,12 +125,25 @@ class PregelNode:
|
||||
cache_policy: CachePolicy | None
|
||||
"""The cache policy to use when invoking the node."""
|
||||
|
||||
timeout: TimeoutPolicy | None
|
||||
"""Timeout policy for a single invocation.
|
||||
|
||||
If exceeded, `NodeTimeoutError` is raised and the retry policy (if any)
|
||||
decides whether to retry. Supported only for async nodes.
|
||||
"""
|
||||
|
||||
tags: Sequence[str] | None
|
||||
"""Tags to attach to the node for tracing."""
|
||||
|
||||
metadata: Mapping[str, Any] | None
|
||||
"""Metadata to attach to the node for tracing."""
|
||||
|
||||
is_error_handler: bool
|
||||
"""Whether this node is registered as an error handler node."""
|
||||
|
||||
error_handler_node: str | None
|
||||
"""Optional handler node name for failures from this node."""
|
||||
|
||||
subgraphs: Sequence[PregelProtocol]
|
||||
"""Subgraphs used by the node."""
|
||||
|
||||
@@ -144,7 +159,10 @@ class PregelNode:
|
||||
bound: Runnable[Any, Any] | None = None,
|
||||
retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None,
|
||||
cache_policy: CachePolicy | None = None,
|
||||
is_error_handler: bool = False,
|
||||
error_handler_node: str | None = None,
|
||||
subgraphs: Sequence[PregelProtocol] | None = None,
|
||||
timeout: float | timedelta | TimeoutPolicy | None = None,
|
||||
) -> None:
|
||||
self.channels = channels
|
||||
self.triggers = list(triggers)
|
||||
@@ -156,8 +174,11 @@ class PregelNode:
|
||||
self.retry_policy = (retry_policy,)
|
||||
else:
|
||||
self.retry_policy = retry_policy
|
||||
self.timeout = coerce_timeout_policy(timeout)
|
||||
self.tags = tags
|
||||
self.metadata = metadata
|
||||
self.is_error_handler = is_error_handler
|
||||
self.error_handler_node = error_handler_node
|
||||
if subgraphs is not None:
|
||||
self.subgraphs = subgraphs
|
||||
elif self.bound is not DEFAULT_BOUND:
|
||||
|
||||
@@ -4,32 +4,487 @@ import asyncio
|
||||
import logging
|
||||
import random
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import weakref
|
||||
from collections.abc import Awaitable, Callable, Sequence
|
||||
from dataclasses import replace
|
||||
from typing import Any
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Literal, NamedTuple
|
||||
|
||||
from langchain_core.callbacks import BaseCallbackHandler
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
|
||||
from langgraph._internal._config import patch_configurable, recast_checkpoint_ns
|
||||
from langgraph._internal._config import (
|
||||
merge_configs,
|
||||
patch_configurable,
|
||||
recast_checkpoint_ns,
|
||||
)
|
||||
from langgraph._internal._constants import (
|
||||
CONF,
|
||||
CONFIG_KEY_CALL,
|
||||
CONFIG_KEY_CHECKPOINT_ID,
|
||||
CONFIG_KEY_CHECKPOINT_NS,
|
||||
CONFIG_KEY_RESUMING,
|
||||
CONFIG_KEY_RUNTIME,
|
||||
CONFIG_KEY_SEND,
|
||||
CONFIG_KEY_STREAM,
|
||||
CONFIG_KEY_TASK_ID,
|
||||
CONFIG_KEY_THREAD_ID,
|
||||
CONFIG_KEY_TIMED_ATTEMPT_OBSERVER,
|
||||
NS_SEP,
|
||||
)
|
||||
from langgraph.errors import GraphBubbleUp, ParentCommand
|
||||
from langgraph._internal._runnable import create_task_in_config_context
|
||||
from langgraph._internal._timeout import sync_timeout_unsupported
|
||||
from langgraph.errors import GraphBubbleUp, NodeTimeoutError, ParentCommand
|
||||
from langgraph.pregel.protocol import StreamProtocol
|
||||
from langgraph.runtime import ExecutionInfo, Runtime
|
||||
from langgraph.types import Command, PregelExecutableTask, RetryPolicy
|
||||
from langgraph.types import Command, PregelExecutableTask, RetryPolicy, TimeoutPolicy
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
SUPPORTS_EXC_NOTES = sys.version_info >= (3, 11)
|
||||
|
||||
|
||||
def _timeout_secs(value: float | timedelta) -> float:
|
||||
return value.total_seconds() if isinstance(value, timedelta) else value
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _ResolvedTimeout:
|
||||
run_timeout_secs: float | None
|
||||
idle_timeout_secs: float | None
|
||||
refresh_on: Literal["auto", "heartbeat"] | None
|
||||
|
||||
|
||||
def _resolve_timeout(timeout: TimeoutPolicy) -> _ResolvedTimeout:
|
||||
idle_timeout_secs = (
|
||||
_timeout_secs(timeout.idle_timeout)
|
||||
if timeout.idle_timeout is not None
|
||||
else None
|
||||
)
|
||||
return _ResolvedTimeout(
|
||||
run_timeout_secs=(
|
||||
_timeout_secs(timeout.run_timeout)
|
||||
if timeout.run_timeout is not None
|
||||
else None
|
||||
),
|
||||
idle_timeout_secs=idle_timeout_secs,
|
||||
refresh_on=timeout.refresh_on if idle_timeout_secs is not None else None,
|
||||
)
|
||||
|
||||
|
||||
class _AttemptContext(NamedTuple):
|
||||
"""Immutable per-attempt metadata shared across start/progress/finish events.
|
||||
|
||||
Built once at attempt start and referenced (not copied) by every emitted
|
||||
`_AttemptEvent`, so per-event allocation is just the small event wrapper.
|
||||
|
||||
Intentionally underscore-prefixed: this and `_AttemptEvent` are part of an
|
||||
internal observer contract consumed by langgraph-server. Do not move to
|
||||
`langgraph.types` — server imports them by this path.
|
||||
"""
|
||||
|
||||
task_id: str
|
||||
task_name: str
|
||||
attempt: int
|
||||
run_id: str | None
|
||||
thread_id: str | None
|
||||
checkpoint_ns: str | None
|
||||
started_at: datetime
|
||||
run_timeout_secs: float | None
|
||||
idle_timeout_secs: float | None
|
||||
refresh_on: Literal["auto", "heartbeat"] | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _AttemptEvent:
|
||||
"""One lifecycle event for a timed attempt.
|
||||
|
||||
Holds a reference to the shared `_AttemptContext` and the event-specific
|
||||
fields. The observer must treat this and `context` as read-only — they
|
||||
are reused across all events for the same attempt.
|
||||
"""
|
||||
|
||||
context: _AttemptContext
|
||||
event: Literal["start", "progress", "finish"]
|
||||
progress_at: datetime | None = None
|
||||
finished_at: datetime | None = None
|
||||
status: Literal["success", "error"] | None = None
|
||||
error_type: str | None = None
|
||||
error_message: str | None = None
|
||||
|
||||
|
||||
class _TimedAttemptScope:
|
||||
"""Guarded-config window for timed attempts.
|
||||
|
||||
The wrapped config marks writes, stream events, runtime stream writer calls,
|
||||
child task scheduling, and any LangChain callback event emitted under the
|
||||
node's run as observable progress when `refresh_on="auto"`.
|
||||
`runtime.heartbeat()` exposes a manual progress signal for work that doesn't
|
||||
otherwise emit any of these, and is the only progress signal when
|
||||
`refresh_on="heartbeat"`.
|
||||
Guarded writes are serialized with `close()` so cancelled background tasks
|
||||
cannot persist writes past the timeout boundary. Stream/custom output is
|
||||
best-effort: it is dropped after close is observed, but callbacks run outside
|
||||
the lock because they may contain arbitrary user/runtime code.
|
||||
"""
|
||||
|
||||
__slots__ = (
|
||||
"__weakref__",
|
||||
"_active",
|
||||
"_last_progress",
|
||||
"_last_progress_emit",
|
||||
"_lock",
|
||||
"_on_progress",
|
||||
"_progress_min_interval",
|
||||
"_refresh_on",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
on_progress: Callable[[], None] | None = None,
|
||||
progress_min_interval: float = 0.0,
|
||||
refresh_on: Literal["auto", "heartbeat"] | None = None,
|
||||
) -> None:
|
||||
self._active = True
|
||||
self._last_progress = time.monotonic()
|
||||
self._lock = threading.Lock()
|
||||
self._on_progress = on_progress
|
||||
self._progress_min_interval = progress_min_interval
|
||||
self._refresh_on = refresh_on
|
||||
# `-inf` so the first touch always passes the rate-limit gate.
|
||||
self._last_progress_emit: float = float("-inf")
|
||||
|
||||
def wrap_config(self, config: RunnableConfig) -> RunnableConfig:
|
||||
configurable = config.get(CONF, {})
|
||||
patch: dict[str, Any] = {}
|
||||
if (send := configurable.get(CONFIG_KEY_SEND)) is not None:
|
||||
patch[CONFIG_KEY_SEND] = self._guard_send(send)
|
||||
if (stream := configurable.get(CONFIG_KEY_STREAM)) is not None:
|
||||
patch[CONFIG_KEY_STREAM] = self._guard_stream(stream)
|
||||
if (call := configurable.get(CONFIG_KEY_CALL)) is not None:
|
||||
patch[CONFIG_KEY_CALL] = self._guard_call(call)
|
||||
if isinstance(runtime := configurable.get(CONFIG_KEY_RUNTIME), Runtime):
|
||||
if self._refresh_on is not None:
|
||||
patch[CONFIG_KEY_RUNTIME] = runtime.override(
|
||||
stream_writer=self._guard_stream_writer(runtime.stream_writer),
|
||||
heartbeat=self.touch,
|
||||
)
|
||||
else:
|
||||
patch[CONFIG_KEY_RUNTIME] = runtime.override(
|
||||
stream_writer=self._guard_stream_writer(runtime.stream_writer)
|
||||
)
|
||||
new_config = patch_configurable(config, patch) if patch else config
|
||||
if self._refresh_on == "auto":
|
||||
return merge_configs(
|
||||
new_config, {"callbacks": [_IdleProgressCallbackHandler(self)]}
|
||||
)
|
||||
return new_config
|
||||
|
||||
def touch(self) -> None:
|
||||
# Avoid locking this hot progress path. We accept a small race window in
|
||||
# timestamp ordering because idle_timeout is expected to be coarse compared
|
||||
# with scheduler/thread timing.
|
||||
now = time.monotonic()
|
||||
self._last_progress = now
|
||||
if self._on_progress is None:
|
||||
return
|
||||
# Best-effort rate limit: a benign race may emit a duplicate progress
|
||||
# event under heavy concurrency, which observers must already tolerate
|
||||
# (callbacks fire from arbitrary threads).
|
||||
if now - self._last_progress_emit < self._progress_min_interval:
|
||||
return
|
||||
self._last_progress_emit = now
|
||||
self._on_progress()
|
||||
|
||||
def close(self) -> None:
|
||||
with self._lock:
|
||||
self._active = False
|
||||
|
||||
async def wait_for_idle_timeout(self, idle_timeout_s: float) -> None:
|
||||
while True:
|
||||
with self._lock:
|
||||
if not self._active:
|
||||
return
|
||||
remaining = self._last_progress + idle_timeout_s - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise asyncio.TimeoutError
|
||||
await asyncio.sleep(remaining)
|
||||
|
||||
def _guard_send(
|
||||
self, send: Callable[[Sequence[tuple[str, Any]]], None]
|
||||
) -> Callable[[Sequence[tuple[str, Any]]], None]:
|
||||
def guarded_send(writes: Sequence[tuple[str, Any]]) -> None:
|
||||
with self._lock:
|
||||
if self._active:
|
||||
if writes and self._refresh_on == "auto":
|
||||
self._last_progress = time.monotonic()
|
||||
send(writes)
|
||||
|
||||
return guarded_send
|
||||
|
||||
def _guard_stream(self, stream: StreamProtocol) -> StreamProtocol:
|
||||
# No lock: stream callbacks fire from the event loop only, so the
|
||||
# active-check + write happen atomically between awaits.
|
||||
def guarded_stream(chunk: tuple[tuple[str, ...], str, Any]) -> None:
|
||||
if not self._active:
|
||||
return
|
||||
if self._refresh_on == "auto":
|
||||
self._last_progress = time.monotonic()
|
||||
stream(chunk)
|
||||
|
||||
return StreamProtocol(guarded_stream, stream.modes)
|
||||
|
||||
def _guard_call(self, call: Callable[..., Any]) -> Callable[..., Any]:
|
||||
# No lock: child-task scheduling happens from the event loop only.
|
||||
def guarded_call(*args: Any, **kwargs: Any) -> Any:
|
||||
if not self._active:
|
||||
raise asyncio.CancelledError
|
||||
if self._refresh_on == "auto":
|
||||
self._last_progress = time.monotonic()
|
||||
return call(*args, **kwargs)
|
||||
|
||||
return guarded_call
|
||||
|
||||
def _guard_stream_writer(
|
||||
self, stream_writer: Callable[[Any], None]
|
||||
) -> Callable[[Any], None]:
|
||||
def guarded_stream_writer(chunk: Any) -> None:
|
||||
with self._lock:
|
||||
if not self._active:
|
||||
return
|
||||
if self._refresh_on == "auto":
|
||||
self._last_progress = time.monotonic()
|
||||
stream_writer(chunk)
|
||||
|
||||
return guarded_stream_writer
|
||||
|
||||
|
||||
class _IdleProgressCallbackHandler(BaseCallbackHandler):
|
||||
"""Resets the idle timeout clock on any LangChain callback event.
|
||||
|
||||
Inherits via `config["callbacks"]`, so it sees only events emitted by
|
||||
runs descended from the node's attempt — sibling nodes do not bleed
|
||||
through. Holds the scope by weakref so a child manager that outlives
|
||||
the attempt cannot keep the scope alive.
|
||||
"""
|
||||
|
||||
# Run inline so progress is recorded in callback emission order;
|
||||
# thread-pool dispatch would introduce extra reordering.
|
||||
run_inline = True
|
||||
|
||||
def __init__(self, scope: _TimedAttemptScope) -> None:
|
||||
self._scope_ref = weakref.ref(scope)
|
||||
|
||||
def _touch(self, *args: Any, **kwargs: Any) -> None:
|
||||
if (scope := self._scope_ref()) is not None:
|
||||
scope.touch()
|
||||
|
||||
on_llm_start = _touch
|
||||
on_chat_model_start = _touch
|
||||
on_llm_new_token = _touch
|
||||
on_llm_end = _touch
|
||||
on_llm_error = _touch
|
||||
on_chain_start = _touch
|
||||
on_chain_end = _touch
|
||||
on_chain_error = _touch
|
||||
on_tool_start = _touch
|
||||
on_tool_end = _touch
|
||||
on_tool_error = _touch
|
||||
on_retriever_start = _touch
|
||||
on_retriever_end = _touch
|
||||
on_retriever_error = _touch
|
||||
on_agent_action = _touch
|
||||
on_agent_finish = _touch
|
||||
on_text = _touch
|
||||
on_retry = _touch
|
||||
on_custom_event = _touch
|
||||
|
||||
|
||||
def _drain_cancelled(task: asyncio.Task[Any]) -> None:
|
||||
# Mark the abandoned task's exception as retrieved so asyncio doesn't log it.
|
||||
with suppress(asyncio.CancelledError):
|
||||
task.exception()
|
||||
|
||||
|
||||
def _start_timed_attempt(
|
||||
task: PregelExecutableTask, config: RunnableConfig, timeout: _ResolvedTimeout
|
||||
) -> _AttemptContext | None:
|
||||
configurable = config.get(CONF, {})
|
||||
callback = configurable.get(CONFIG_KEY_TIMED_ATTEMPT_OBSERVER)
|
||||
if callback is None:
|
||||
return None
|
||||
runtime = configurable.get(CONFIG_KEY_RUNTIME)
|
||||
execution_info = runtime.execution_info if isinstance(runtime, Runtime) else None
|
||||
context = _AttemptContext(
|
||||
task_id=task.id,
|
||||
task_name=task.name,
|
||||
attempt=execution_info.node_attempt if execution_info is not None else 1,
|
||||
run_id=execution_info.run_id if execution_info is not None else None,
|
||||
thread_id=execution_info.thread_id if execution_info is not None else None,
|
||||
checkpoint_ns=(
|
||||
execution_info.checkpoint_ns if execution_info is not None else None
|
||||
),
|
||||
started_at=datetime.now(timezone.utc),
|
||||
run_timeout_secs=timeout.run_timeout_secs,
|
||||
idle_timeout_secs=timeout.idle_timeout_secs,
|
||||
refresh_on=timeout.refresh_on,
|
||||
)
|
||||
_dispatch_observer(callback, _AttemptEvent(context=context, event="start"))
|
||||
return context
|
||||
|
||||
|
||||
def _finish_timed_attempt(
|
||||
config: RunnableConfig,
|
||||
context: _AttemptContext | None,
|
||||
error: BaseException | None = None,
|
||||
) -> None:
|
||||
if context is None:
|
||||
return
|
||||
callback = config.get(CONF, {}).get(CONFIG_KEY_TIMED_ATTEMPT_OBSERVER)
|
||||
if callback is None:
|
||||
return
|
||||
_dispatch_observer(
|
||||
callback,
|
||||
_AttemptEvent(
|
||||
context=context,
|
||||
event="finish",
|
||||
finished_at=datetime.now(timezone.utc),
|
||||
status="error" if error is not None else "success",
|
||||
error_type=type(error).__name__ if error is not None else None,
|
||||
error_message=str(error) if error is not None else None,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _emit_progress(
|
||||
callback: Callable[[_AttemptEvent], None],
|
||||
context: _AttemptContext,
|
||||
) -> None:
|
||||
_dispatch_observer(
|
||||
callback,
|
||||
_AttemptEvent(
|
||||
context=context,
|
||||
event="progress",
|
||||
progress_at=datetime.now(timezone.utc),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _dispatch_observer(
|
||||
callback: Callable[[_AttemptEvent], None],
|
||||
event: _AttemptEvent,
|
||||
) -> None:
|
||||
try:
|
||||
callback(event)
|
||||
except Exception:
|
||||
logger.warning("Timed attempt observer failed", exc_info=True)
|
||||
|
||||
|
||||
async def _run_timeout_watchdog(run_timeout_s: float) -> None:
|
||||
await asyncio.sleep(run_timeout_s)
|
||||
raise asyncio.TimeoutError
|
||||
|
||||
|
||||
async def _arun_with_timeout(
|
||||
task: PregelExecutableTask,
|
||||
config: RunnableConfig,
|
||||
timeout: _ResolvedTimeout,
|
||||
attempt_ctx: _AttemptContext | None,
|
||||
*,
|
||||
stream: bool,
|
||||
) -> Any:
|
||||
run_timeout_s = timeout.run_timeout_secs
|
||||
idle_timeout_s = timeout.idle_timeout_secs
|
||||
on_progress: Callable[[], None] | None = None
|
||||
if attempt_ctx is not None:
|
||||
callback = config.get(CONF, {}).get(CONFIG_KEY_TIMED_ATTEMPT_OBSERVER)
|
||||
if callback is not None and idle_timeout_s is not None:
|
||||
on_progress = lambda: _emit_progress(callback, attempt_ctx) # noqa: E731
|
||||
scope = _TimedAttemptScope(
|
||||
on_progress=on_progress,
|
||||
# Cap progress emission at ~4 events per idle window so token-rate
|
||||
# callbacks don't flood the observer.
|
||||
progress_min_interval=idle_timeout_s / 4 if idle_timeout_s is not None else 0.0,
|
||||
refresh_on=timeout.refresh_on,
|
||||
)
|
||||
scoped_config = scope.wrap_config(config)
|
||||
start = time.monotonic()
|
||||
if stream:
|
||||
# Yielded chunks count as progress only under `refresh_on="auto"`.
|
||||
# `refresh_on="heartbeat"` is the strict mode where only explicit
|
||||
# `runtime.heartbeat()` calls reset the idle clock.
|
||||
async def run() -> Any:
|
||||
async for _ in task.proc.astream(task.input, scoped_config):
|
||||
if timeout.refresh_on == "auto":
|
||||
scope.touch()
|
||||
|
||||
else:
|
||||
|
||||
async def run() -> Any:
|
||||
return await task.proc.ainvoke(task.input, scoped_config)
|
||||
|
||||
bg = create_task_in_config_context(run, scoped_config)
|
||||
watchdogs: dict[asyncio.Task[None], Literal["idle", "run"]] = {}
|
||||
if idle_timeout_s is not None:
|
||||
watchdogs[asyncio.create_task(scope.wait_for_idle_timeout(idle_timeout_s))] = (
|
||||
"idle"
|
||||
)
|
||||
if run_timeout_s is not None:
|
||||
watchdogs[asyncio.create_task(_run_timeout_watchdog(run_timeout_s))] = "run"
|
||||
try:
|
||||
done, _ = await asyncio.wait(
|
||||
{bg, *watchdogs}, return_when=asyncio.FIRST_COMPLETED
|
||||
)
|
||||
if bg in done:
|
||||
# Task completed in time.
|
||||
for watchdog in watchdogs:
|
||||
watchdog.cancel()
|
||||
# FIRST_COMPLETED can return both; a watchdog may have
|
||||
# already raised TimeoutError before we cancelled it.
|
||||
for watchdog in watchdogs:
|
||||
with suppress(asyncio.CancelledError, asyncio.TimeoutError):
|
||||
await watchdog
|
||||
return await bg
|
||||
# bg was not in `done`, so every member of `done` is one of our
|
||||
# watchdogs. Only a watchdog's TimeoutError converts to
|
||||
# NodeTimeoutError; any TimeoutError raised by the proc itself
|
||||
# propagates unchanged.
|
||||
for watchdog in done:
|
||||
kind = watchdogs[watchdog]
|
||||
try:
|
||||
await watchdog
|
||||
except asyncio.TimeoutError as exc:
|
||||
elapsed = time.monotonic() - start
|
||||
scope.close()
|
||||
task.writes.clear()
|
||||
bg.cancel()
|
||||
bg.add_done_callback(_drain_cancelled)
|
||||
raise NodeTimeoutError(
|
||||
task.name,
|
||||
elapsed,
|
||||
kind=kind,
|
||||
idle_timeout=idle_timeout_s,
|
||||
run_timeout=run_timeout_s,
|
||||
) from exc
|
||||
raise RuntimeError(
|
||||
f"{kind} timeout watchdog completed without raising TimeoutError"
|
||||
)
|
||||
raise RuntimeError("timeout wait completed without task or watchdog")
|
||||
except asyncio.CancelledError:
|
||||
scope.close()
|
||||
bg.cancel()
|
||||
for watchdog in watchdogs:
|
||||
watchdog.cancel()
|
||||
bg.add_done_callback(_drain_cancelled)
|
||||
raise
|
||||
finally:
|
||||
scope.close()
|
||||
for watchdog in watchdogs:
|
||||
watchdog.cancel()
|
||||
|
||||
|
||||
def _ensure_execution_info(
|
||||
runtime: Runtime, config: RunnableConfig, task: PregelExecutableTask
|
||||
) -> Runtime:
|
||||
@@ -90,6 +545,10 @@ def run_with_retry(
|
||||
) -> None:
|
||||
"""Run a task with retries."""
|
||||
retry_policy = task.retry_policy or retry_policy
|
||||
if task.timeout is not None:
|
||||
# `validate_timeout_supported` catches sync nodes at compile time;
|
||||
# this is a runtime safety net for paths that may bypass that validation.
|
||||
raise sync_timeout_unsupported(task.name)
|
||||
attempts = 0
|
||||
node_first_attempt_time = time.time()
|
||||
config = task.config
|
||||
@@ -195,6 +654,9 @@ async def arun_with_retry(
|
||||
) -> None:
|
||||
"""Run a task asynchronously with retries."""
|
||||
retry_policy = task.retry_policy or retry_policy
|
||||
resolved_timeout = (
|
||||
_resolve_timeout(task.timeout) if task.timeout is not None else None
|
||||
)
|
||||
attempts = 0
|
||||
node_first_attempt_time = time.time()
|
||||
config = task.config
|
||||
@@ -229,35 +691,53 @@ async def arun_with_retry(
|
||||
)
|
||||
},
|
||||
)
|
||||
attempt_ctx = (
|
||||
_start_timed_attempt(task, config, resolved_timeout)
|
||||
if resolved_timeout is not None
|
||||
else None
|
||||
)
|
||||
try:
|
||||
# clear any writes from previous attempts
|
||||
task.writes.clear()
|
||||
# run the task
|
||||
if resolved_timeout is None:
|
||||
if stream:
|
||||
async for _ in task.proc.astream(task.input, config):
|
||||
pass
|
||||
break
|
||||
return await task.proc.ainvoke(task.input, config)
|
||||
result = await _arun_with_timeout(
|
||||
task, config, resolved_timeout, attempt_ctx, stream=stream
|
||||
)
|
||||
_finish_timed_attempt(config, attempt_ctx)
|
||||
if stream:
|
||||
async for _ in task.proc.astream(task.input, config):
|
||||
pass
|
||||
# if successful, end
|
||||
break
|
||||
else:
|
||||
return await task.proc.ainvoke(task.input, config)
|
||||
return result
|
||||
except ParentCommand as exc:
|
||||
ns: str = config[CONF][CONFIG_KEY_CHECKPOINT_NS]
|
||||
cmd = exc.args[0]
|
||||
# strip task_ids from namespace for comparison (ns format: "node1|node2:task_id")
|
||||
if cmd.graph in (ns, recast_checkpoint_ns(ns), task.name):
|
||||
# this command is for the current graph, handle it
|
||||
for w in task.writers:
|
||||
w.invoke(cmd, config)
|
||||
try:
|
||||
# this command is for the current graph, handle it
|
||||
for w in task.writers:
|
||||
w.invoke(cmd, config)
|
||||
except Exception as writer_exc:
|
||||
_finish_timed_attempt(config, attempt_ctx, writer_exc)
|
||||
raise
|
||||
_finish_timed_attempt(config, attempt_ctx)
|
||||
break
|
||||
elif cmd.graph == Command.PARENT:
|
||||
# this command is for the parent graph, assign it to the parent.
|
||||
exc.args = (replace(cmd, graph=_checkpoint_ns_for_parent_command(ns)),)
|
||||
# bubble up
|
||||
_finish_timed_attempt(config, attempt_ctx)
|
||||
# bubble up the exception to the parent graph
|
||||
raise
|
||||
except GraphBubbleUp:
|
||||
# if interrupted, end
|
||||
_finish_timed_attempt(config, attempt_ctx)
|
||||
raise
|
||||
except Exception as exc:
|
||||
_finish_timed_attempt(config, attempt_ctx, exc)
|
||||
if SUPPORTS_EXC_NOTES:
|
||||
exc.add_note(f"During task with name '{task.name}' and id '{task.id}'")
|
||||
if not retry_policy:
|
||||
|
||||
@@ -10,8 +10,10 @@ from collections.abc import (
|
||||
AsyncIterator,
|
||||
Awaitable,
|
||||
Callable,
|
||||
Collection,
|
||||
Iterable,
|
||||
Iterator,
|
||||
Mapping,
|
||||
Sequence,
|
||||
)
|
||||
from functools import partial
|
||||
@@ -46,6 +48,7 @@ from langgraph.types import (
|
||||
CachePolicy,
|
||||
PregelExecutableTask,
|
||||
RetryPolicy,
|
||||
TimeoutPolicy,
|
||||
)
|
||||
|
||||
F = TypeVar("F", concurrent.futures.Future, asyncio.Future)
|
||||
@@ -71,6 +74,10 @@ SKIP_RERAISE_SET: weakref.WeakSet[concurrent.futures.Future | asyncio.Future] =
|
||||
class FuturesDict(Generic[F, E], dict[F, PregelExecutableTask | None]):
|
||||
event: E
|
||||
callback: weakref.ref[Callable[[PregelExecutableTask, BaseException | None], None]]
|
||||
# Stop condition is injected by PregelRunner instead of hard-coded here.
|
||||
# This lets the runner treat graph-error-handled exceptions as non-fatal
|
||||
# so `on_done` does not trigger an early stop for those futures.
|
||||
should_stop: Callable[[set[F]], bool]
|
||||
counter: int
|
||||
done: set[F]
|
||||
lock: threading.Lock
|
||||
@@ -81,6 +88,7 @@ class FuturesDict(Generic[F, E], dict[F, PregelExecutableTask | None]):
|
||||
callback: weakref.ref[
|
||||
Callable[[PregelExecutableTask, BaseException | None], None]
|
||||
],
|
||||
should_stop: Callable[[set[F]], bool],
|
||||
future_type: type[F],
|
||||
# used for generic typing, newer py supports FutureDict[...](...)
|
||||
) -> None:
|
||||
@@ -88,6 +96,7 @@ class FuturesDict(Generic[F, E], dict[F, PregelExecutableTask | None]):
|
||||
self.lock = threading.Lock()
|
||||
self.event = event
|
||||
self.callback = callback
|
||||
self.should_stop = should_stop
|
||||
self.counter = 0
|
||||
self.done: set[F] = set()
|
||||
|
||||
@@ -108,6 +117,7 @@ class FuturesDict(Generic[F, E], dict[F, PregelExecutableTask | None]):
|
||||
task: PregelExecutableTask,
|
||||
fut: F,
|
||||
) -> None:
|
||||
# Called automatically by future.add_done_callback registered in __setitem__.
|
||||
try:
|
||||
if cb := self.callback():
|
||||
cb(task, _exception(fut))
|
||||
@@ -115,7 +125,9 @@ class FuturesDict(Generic[F, E], dict[F, PregelExecutableTask | None]):
|
||||
with self.lock:
|
||||
self.done.add(fut)
|
||||
self.counter -= 1
|
||||
if self.counter == 0 or _should_stop_others(self.done):
|
||||
# Wake waiter when all tracked futures are done, or when runner-level
|
||||
# stop condition is met (for example, a non-handled fatal exception).
|
||||
if self.counter == 0 or self.should_stop(self.done):
|
||||
self.event.set()
|
||||
|
||||
|
||||
@@ -131,11 +143,34 @@ class PregelRunner:
|
||||
put_writes: weakref.ref[Callable[[str, Sequence[tuple[str, Any]]], None]],
|
||||
use_astream: bool = False,
|
||||
node_finished: Callable[[str], None] | None = None,
|
||||
node_error_handler_map: Mapping[str, str] | None = None,
|
||||
schedule_error_handler: Callable[
|
||||
[PregelExecutableTask, BaseException], PregelExecutableTask | None
|
||||
]
|
||||
| None = None,
|
||||
aschedule_error_handler: Callable[
|
||||
[PregelExecutableTask, BaseException],
|
||||
Awaitable[PregelExecutableTask | None],
|
||||
]
|
||||
| None = None,
|
||||
) -> None:
|
||||
self.submit = submit
|
||||
self.put_writes = put_writes
|
||||
self.use_astream = use_astream
|
||||
self.node_finished = node_finished
|
||||
self.node_error_handler_map = dict(node_error_handler_map or {})
|
||||
self.error_handler_nodes = set(self.node_error_handler_map.values())
|
||||
self.schedule_error_handler = schedule_error_handler
|
||||
self.aschedule_error_handler = aschedule_error_handler
|
||||
# Exception object ids that are already routed to graph-level error handler.
|
||||
# These ids are consulted by stop/panic checks to avoid re-raising handled
|
||||
# exceptions via the normal fatal path in the same run.
|
||||
self._handled_exception_ids: set[int] = set()
|
||||
|
||||
def _should_route_to_error_handler(self, task: PregelExecutableTask) -> bool:
|
||||
if task.name in self.error_handler_nodes:
|
||||
return False
|
||||
return task.name in self.node_error_handler_map
|
||||
|
||||
def tick(
|
||||
self,
|
||||
@@ -154,6 +189,9 @@ class PregelRunner:
|
||||
futures = FuturesDict(
|
||||
callback=weakref.WeakMethod(self.commit),
|
||||
event=threading.Event(),
|
||||
should_stop=partial(
|
||||
_should_stop_others, handled_exception_ids=self._handled_exception_ids
|
||||
),
|
||||
future_type=concurrent.futures.Future,
|
||||
)
|
||||
# give control back to the caller
|
||||
@@ -163,6 +201,7 @@ class PregelRunner:
|
||||
return
|
||||
elif len(tasks) == 1 and timeout is None and get_waiter is None:
|
||||
t = tasks[0]
|
||||
scheduled_error_handler = False
|
||||
try:
|
||||
run_with_retry(
|
||||
t,
|
||||
@@ -181,12 +220,23 @@ class PregelRunner:
|
||||
self.commit(t, None)
|
||||
except Exception as exc:
|
||||
self.commit(t, exc)
|
||||
if (
|
||||
not isinstance(exc, GraphBubbleUp)
|
||||
and self._should_route_to_error_handler(t)
|
||||
and self.schedule_error_handler is not None
|
||||
):
|
||||
self._handled_exception_ids.add(id(exc))
|
||||
if handler_task := self.schedule_error_handler(t, exc):
|
||||
tasks = (handler_task,)
|
||||
scheduled_error_handler = True
|
||||
# Continue to the regular scheduling path for handler execution.
|
||||
if reraise and futures:
|
||||
# will be re-raised after futures are done
|
||||
fut: concurrent.futures.Future = concurrent.futures.Future()
|
||||
fut.set_exception(exc)
|
||||
futures.done.add(fut)
|
||||
elif reraise:
|
||||
if id(exc) not in self._handled_exception_ids:
|
||||
# will be re-raised after futures are done
|
||||
fut: concurrent.futures.Future = concurrent.futures.Future()
|
||||
fut.set_exception(exc)
|
||||
futures.done.add(fut)
|
||||
elif reraise and id(exc) not in self._handled_exception_ids:
|
||||
if tb := exc.__traceback__:
|
||||
while tb.tb_next is not None and any(
|
||||
tb.tb_frame.f_code.co_filename.endswith(name)
|
||||
@@ -195,10 +245,12 @@ class PregelRunner:
|
||||
tb = tb.tb_next
|
||||
exc.__traceback__ = tb
|
||||
raise
|
||||
if not futures: # maybe `t` scheduled another task
|
||||
if not futures and not scheduled_error_handler:
|
||||
# maybe `t` scheduled another task
|
||||
return
|
||||
else:
|
||||
tasks = () # don't reschedule this task
|
||||
if not scheduled_error_handler:
|
||||
tasks = () # don't reschedule this task
|
||||
# add waiter task if requested
|
||||
if get_waiter is not None:
|
||||
futures[get_waiter()] = None
|
||||
@@ -225,6 +277,7 @@ class PregelRunner:
|
||||
# each task is independent from all other concurrent tasks
|
||||
# yield updates/debug output as each task finishes
|
||||
end_time = timeout + time.monotonic() if timeout else None
|
||||
handled_futures: set[concurrent.futures.Future[Any]] = set()
|
||||
while len(futures) > (1 if get_waiter is not None else 0):
|
||||
done, inflight = concurrent.futures.wait(
|
||||
futures,
|
||||
@@ -233,17 +286,49 @@ class PregelRunner:
|
||||
)
|
||||
if not done:
|
||||
break # timed out
|
||||
done_for_stop: set[concurrent.futures.Future[Any]] = set()
|
||||
for fut in done:
|
||||
task = futures.pop(fut)
|
||||
if task is None:
|
||||
# waiter task finished, schedule another
|
||||
if inflight and get_waiter is not None:
|
||||
futures[get_waiter()] = None
|
||||
elif (
|
||||
(task_exc := _exception(fut))
|
||||
and self._should_route_to_error_handler(task)
|
||||
and not isinstance(task_exc, GraphBubbleUp)
|
||||
):
|
||||
self._handled_exception_ids.add(id(task_exc))
|
||||
SKIP_RERAISE_SET.add(fut)
|
||||
handled_futures.add(fut)
|
||||
if self.schedule_error_handler is not None:
|
||||
if handler_task := self.schedule_error_handler(task, task_exc):
|
||||
handler_fut = self.submit()( # type: ignore[misc]
|
||||
run_with_retry,
|
||||
handler_task,
|
||||
retry_policy,
|
||||
configurable={
|
||||
CONFIG_KEY_CALL: partial(
|
||||
_call,
|
||||
weakref.ref(handler_task),
|
||||
retry_policy=retry_policy,
|
||||
futures=weakref.ref(futures),
|
||||
schedule_task=schedule_task,
|
||||
submit=self.submit,
|
||||
),
|
||||
},
|
||||
__reraise_on_exit__=reraise,
|
||||
)
|
||||
futures[handler_fut] = handler_task
|
||||
else:
|
||||
done_for_stop.add(fut)
|
||||
else:
|
||||
# remove references to loop vars
|
||||
del fut, task
|
||||
# maybe stop other tasks
|
||||
if _should_stop_others(done):
|
||||
if _should_stop_others(
|
||||
done_for_stop, handled_exception_ids=self._handled_exception_ids
|
||||
):
|
||||
break
|
||||
# give control back to the caller
|
||||
yield
|
||||
@@ -258,6 +343,8 @@ class PregelRunner:
|
||||
_panic_or_proceed(
|
||||
futures.done.union(f for f, t in futures.items() if t is not None),
|
||||
panic=reraise,
|
||||
handled_exception_ids=self._handled_exception_ids,
|
||||
handled_futures=handled_futures,
|
||||
)
|
||||
except Exception as exc:
|
||||
if tb := exc.__traceback__:
|
||||
@@ -291,6 +378,9 @@ class PregelRunner:
|
||||
futures = FuturesDict(
|
||||
callback=weakref.WeakMethod(self.commit),
|
||||
event=asyncio.Event(),
|
||||
should_stop=partial(
|
||||
_should_stop_others, handled_exception_ids=self._handled_exception_ids
|
||||
),
|
||||
future_type=asyncio.Future,
|
||||
)
|
||||
# give control back to the caller
|
||||
@@ -300,6 +390,7 @@ class PregelRunner:
|
||||
return
|
||||
elif len(tasks) == 1 and get_waiter is None and timeout is None:
|
||||
t = tasks[0]
|
||||
scheduled_error_handler = False
|
||||
try:
|
||||
await arun_with_retry(
|
||||
t,
|
||||
@@ -321,12 +412,22 @@ class PregelRunner:
|
||||
self.commit(t, None)
|
||||
except Exception as exc:
|
||||
self.commit(t, exc)
|
||||
if (
|
||||
not isinstance(exc, GraphBubbleUp)
|
||||
and self._should_route_to_error_handler(t)
|
||||
and self.aschedule_error_handler is not None
|
||||
):
|
||||
self._handled_exception_ids.add(id(exc))
|
||||
if handler_task := await self.aschedule_error_handler(t, exc):
|
||||
tasks = (handler_task,)
|
||||
scheduled_error_handler = True
|
||||
if reraise and futures:
|
||||
# will be re-raised after futures are done
|
||||
fut: asyncio.Future = loop.create_future()
|
||||
fut.set_exception(exc)
|
||||
futures.done.add(fut)
|
||||
elif reraise:
|
||||
if id(exc) not in self._handled_exception_ids:
|
||||
# will be re-raised after futures are done
|
||||
fut: asyncio.Future = loop.create_future()
|
||||
fut.set_exception(exc)
|
||||
futures.done.add(fut)
|
||||
elif reraise and id(exc) not in self._handled_exception_ids:
|
||||
if tb := exc.__traceback__:
|
||||
while tb.tb_next is not None and any(
|
||||
tb.tb_frame.f_code.co_filename.endswith(name)
|
||||
@@ -335,10 +436,12 @@ class PregelRunner:
|
||||
tb = tb.tb_next
|
||||
exc.__traceback__ = tb
|
||||
raise
|
||||
if not futures: # maybe `t` scheduled another task
|
||||
if not futures and not scheduled_error_handler:
|
||||
# maybe `t` scheduled another task
|
||||
return
|
||||
else:
|
||||
tasks = () # don't reschedule this task
|
||||
if not scheduled_error_handler:
|
||||
tasks = () # don't reschedule this task
|
||||
# add waiter task if requested
|
||||
if get_waiter is not None:
|
||||
futures[get_waiter()] = None
|
||||
@@ -373,6 +476,7 @@ class PregelRunner:
|
||||
# each task is independent from all other concurrent tasks
|
||||
# yield updates/debug output as each task finishes
|
||||
end_time = timeout + loop.time() if timeout else None
|
||||
handled_futures: set[asyncio.Future[Any]] = set()
|
||||
while len(futures) > (1 if get_waiter is not None else 0):
|
||||
done, inflight = await asyncio.wait(
|
||||
futures,
|
||||
@@ -381,17 +485,59 @@ class PregelRunner:
|
||||
)
|
||||
if not done:
|
||||
break # timed out
|
||||
done_for_stop: set[asyncio.Future[Any]] = set()
|
||||
for fut in done:
|
||||
task = futures.pop(fut)
|
||||
if task is None:
|
||||
# waiter task finished, schedule another
|
||||
if inflight and get_waiter is not None:
|
||||
futures[get_waiter()] = None
|
||||
elif (
|
||||
(task_exc := _exception(fut))
|
||||
and self._should_route_to_error_handler(task)
|
||||
and not isinstance(task_exc, GraphBubbleUp)
|
||||
):
|
||||
self._handled_exception_ids.add(id(task_exc))
|
||||
SKIP_RERAISE_SET.add(fut)
|
||||
handled_futures.add(fut)
|
||||
if self.aschedule_error_handler is not None:
|
||||
if handler_task := await self.aschedule_error_handler(
|
||||
task, task_exc
|
||||
):
|
||||
handler_fut = cast(
|
||||
asyncio.Future,
|
||||
self.submit()( # type: ignore[misc]
|
||||
arun_with_retry,
|
||||
handler_task,
|
||||
retry_policy,
|
||||
stream=self.use_astream,
|
||||
configurable={
|
||||
CONFIG_KEY_CALL: partial(
|
||||
_acall,
|
||||
weakref.ref(handler_task),
|
||||
retry_policy=retry_policy,
|
||||
stream=self.use_astream,
|
||||
futures=weakref.ref(futures),
|
||||
schedule_task=schedule_task,
|
||||
submit=self.submit,
|
||||
loop=loop,
|
||||
),
|
||||
},
|
||||
__name__=handler_task.name,
|
||||
__cancel_on_exit__=True,
|
||||
__reraise_on_exit__=reraise,
|
||||
),
|
||||
)
|
||||
futures[handler_fut] = handler_task
|
||||
else:
|
||||
done_for_stop.add(fut)
|
||||
else:
|
||||
# remove references to loop vars
|
||||
del fut, task
|
||||
# maybe stop other tasks
|
||||
if _should_stop_others(done):
|
||||
if _should_stop_others(
|
||||
done_for_stop, handled_exception_ids=self._handled_exception_ids
|
||||
):
|
||||
break
|
||||
# give control back to the caller
|
||||
yield
|
||||
@@ -411,6 +557,8 @@ class PregelRunner:
|
||||
futures.done.union(f for f, t in futures.items() if t is not None),
|
||||
timeout_exc_cls=asyncio.TimeoutError,
|
||||
panic=reraise,
|
||||
handled_exception_ids=self._handled_exception_ids,
|
||||
handled_futures=handled_futures,
|
||||
)
|
||||
except Exception as exc:
|
||||
if tb := exc.__traceback__:
|
||||
@@ -446,6 +594,11 @@ class PregelRunner:
|
||||
else:
|
||||
# save error to checkpointer
|
||||
task.writes.append((ERROR, exception))
|
||||
if self._should_route_to_error_handler(task) and not isinstance(
|
||||
exception, GraphBubbleUp
|
||||
):
|
||||
# Mark early in commit path; loop-side routing may happen later.
|
||||
self._handled_exception_ids.add(id(exception))
|
||||
self.put_writes()(task.id, task.writes) # type: ignore[misc]
|
||||
else:
|
||||
if self.node_finished and (
|
||||
@@ -461,6 +614,8 @@ class PregelRunner:
|
||||
|
||||
def _should_stop_others(
|
||||
done: set[F],
|
||||
*,
|
||||
handled_exception_ids: set[int] | None = None,
|
||||
) -> bool:
|
||||
"""Check if any task failed, if so, cancel all other tasks.
|
||||
GraphInterrupts are not considered failures."""
|
||||
@@ -468,7 +623,11 @@ def _should_stop_others(
|
||||
if fut.cancelled():
|
||||
continue
|
||||
elif exc := fut.exception():
|
||||
if not isinstance(exc, GraphBubbleUp) and fut not in SKIP_RERAISE_SET:
|
||||
if (
|
||||
id(exc) not in (handled_exception_ids or set())
|
||||
and not isinstance(exc, GraphBubbleUp)
|
||||
and fut not in SKIP_RERAISE_SET
|
||||
):
|
||||
return True
|
||||
|
||||
return False
|
||||
@@ -492,6 +651,9 @@ def _panic_or_proceed(
|
||||
*,
|
||||
timeout_exc_cls: type[Exception] = TimeoutError,
|
||||
panic: bool = True,
|
||||
handled_exception_ids: set[int] | None = None,
|
||||
handled_futures: Collection[concurrent.futures.Future[Any] | asyncio.Future[Any]]
|
||||
| None = None,
|
||||
) -> None:
|
||||
"""Cancel remaining tasks if any failed, re-raise exception if panic is True."""
|
||||
done: set[concurrent.futures.Future[Any] | asyncio.Future[Any]] = set()
|
||||
@@ -508,6 +670,10 @@ def _panic_or_proceed(
|
||||
# if any task failed
|
||||
fut = done.pop()
|
||||
if exc := _exception(fut):
|
||||
if fut in (handled_futures or set()):
|
||||
continue
|
||||
if id(exc) in (handled_exception_ids or set()):
|
||||
continue
|
||||
# cancel all pending tasks
|
||||
while inflight:
|
||||
inflight.pop().cancel()
|
||||
@@ -537,6 +703,7 @@ def _call(
|
||||
*,
|
||||
retry_policy: Sequence[RetryPolicy] | None = None,
|
||||
cache_policy: CachePolicy | None = None,
|
||||
timeout: TimeoutPolicy | None = None,
|
||||
callbacks: Callbacks = None,
|
||||
futures: weakref.ref[FuturesDict],
|
||||
schedule_task: Callable[
|
||||
@@ -560,6 +727,7 @@ def _call(
|
||||
retry_policy=retry_policy,
|
||||
cache_policy=cache_policy,
|
||||
callbacks=callbacks,
|
||||
timeout=timeout,
|
||||
),
|
||||
):
|
||||
if fut := next(
|
||||
@@ -624,6 +792,7 @@ def _acall(
|
||||
*,
|
||||
retry_policy: Sequence[RetryPolicy] | None = None,
|
||||
cache_policy: CachePolicy | None = None,
|
||||
timeout: TimeoutPolicy | None = None,
|
||||
callbacks: Callbacks = None,
|
||||
# injected dependencies
|
||||
futures: weakref.ref[FuturesDict],
|
||||
@@ -657,6 +826,7 @@ def _acall(
|
||||
input,
|
||||
retry_policy=retry_policy,
|
||||
cache_policy=cache_policy,
|
||||
timeout=timeout,
|
||||
callbacks=callbacks,
|
||||
futures=futures,
|
||||
schedule_task=schedule_task,
|
||||
@@ -678,6 +848,7 @@ async def _acall_impl(
|
||||
*,
|
||||
retry_policy: Sequence[RetryPolicy] | None = None,
|
||||
cache_policy: CachePolicy | None = None,
|
||||
timeout: TimeoutPolicy | None = None,
|
||||
callbacks: Callbacks = None,
|
||||
# injected dependencies
|
||||
futures: weakref.ref[FuturesDict[asyncio.Future, asyncio.Event]],
|
||||
@@ -703,6 +874,7 @@ async def _acall_impl(
|
||||
retry_policy=retry_policy,
|
||||
cache_policy=cache_policy,
|
||||
callbacks=callbacks,
|
||||
timeout=timeout,
|
||||
),
|
||||
):
|
||||
if fut := next(
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator, Callable, Iterator
|
||||
from contextvars import ContextVar, Token
|
||||
from typing import Any, TypeVar, cast
|
||||
from uuid import UUID
|
||||
|
||||
from langchain_core.callbacks import BaseCallbackHandler
|
||||
|
||||
from langgraph._internal._constants import NS_SEP
|
||||
from langgraph.constants import TAG_NOSTREAM
|
||||
from langgraph.pregel.protocol import StreamChunk
|
||||
|
||||
try:
|
||||
from langchain_core.tracers._streaming import _StreamingCallbackHandler
|
||||
except ImportError:
|
||||
_StreamingCallbackHandler = object # type: ignore[assignment,misc]
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
ToolCallWriter = Callable[[Any], None]
|
||||
"""A closure bound to a single tool call that emits `tool-output-delta` events."""
|
||||
|
||||
_tool_call_writer: ContextVar[ToolCallWriter | None] = ContextVar(
|
||||
"langgraph_tool_call_writer", default=None
|
||||
)
|
||||
"""ContextVar holding the writer for the currently-executing tool call.
|
||||
|
||||
Set by `StreamToolCallHandler.on_tool_start` and reset on end/error.
|
||||
Read by `ToolRuntime.emit_output_delta` (in `langgraph.prebuilt`).
|
||||
"""
|
||||
|
||||
|
||||
class StreamToolCallHandler(BaseCallbackHandler, _StreamingCallbackHandler):
|
||||
"""Callback handler that emits tool-call lifecycle events on the stream.
|
||||
|
||||
Fires on LangChain's `on_tool_*` callbacks and pushes to the `tools`
|
||||
stream mode. Emits `tool-started` / `tool-output-delta` /
|
||||
`tool-finished` / `tool-error` payloads keyed by `tool_call_id`.
|
||||
|
||||
While a tool is executing, this handler sets `_tool_call_writer` to a
|
||||
closure bound to that call's namespace and `tool_call_id`.
|
||||
`ToolRuntime.emit_output_delta` reads that ContextVar so tool bodies
|
||||
can stream partial output without threading the writer through their
|
||||
own signature.
|
||||
|
||||
Attached by `Pregel.stream` / `astream` when `"tools"` is in
|
||||
`stream_modes`. `run_inline = True` keeps event ordering
|
||||
deterministic.
|
||||
"""
|
||||
|
||||
run_inline = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
stream: Callable[[StreamChunk], None],
|
||||
subgraphs: bool,
|
||||
*,
|
||||
parent_ns: tuple[str, ...] | None = None,
|
||||
) -> None:
|
||||
"""Configure the handler to stream tool-call events.
|
||||
|
||||
Args:
|
||||
stream: Callable that accepts a `StreamChunk` tuple
|
||||
`(namespace, mode, payload)` and enqueues it.
|
||||
subgraphs: Whether to emit events from tools called inside
|
||||
nested subgraphs. When False, only tools at the
|
||||
handler's own scope (`parent_ns`) emit.
|
||||
parent_ns: Namespace where the handler was attached.
|
||||
Mirrors the `StreamMessagesHandler` escape hatch:
|
||||
tools whose containing namespace equals `parent_ns`
|
||||
still emit even with `subgraphs=False`, so a node that
|
||||
explicitly streams a subgraph with `stream_mode="tools"`
|
||||
sees its own tools.
|
||||
"""
|
||||
self.stream = stream
|
||||
self.subgraphs = subgraphs
|
||||
self.parent_ns = parent_ns
|
||||
# run_id → (namespace, tool_call_id, ContextVar token)
|
||||
# `on_tool_end` does not receive `tool_call_id` in kwargs, so
|
||||
# we correlate by `run_id` which is present on every callback.
|
||||
self._run_to_call: dict[
|
||||
UUID, tuple[tuple[str, ...], str, Token[ToolCallWriter | None]]
|
||||
] = {}
|
||||
|
||||
def _ns_for_emit(
|
||||
self,
|
||||
metadata: dict[str, Any] | None,
|
||||
tags: list[str] | None,
|
||||
) -> tuple[str, ...] | None:
|
||||
"""Resolve the namespace this tool call should emit at, or `None` to skip.
|
||||
|
||||
Mirrors `StreamMessagesHandler.on_chat_model_start`'s namespace
|
||||
derivation: parses `langgraph_checkpoint_ns` (which ends with
|
||||
the `node_name:task_id` of the calling node), drops that
|
||||
trailing segment, and returns the containing subgraph's own
|
||||
namespace. Returns `None` when the call should be silently
|
||||
suppressed:
|
||||
|
||||
- `metadata` is missing — handler is attached to a context
|
||||
without Pregel routing info.
|
||||
- `TAG_NOSTREAM` is in `tags` — caller explicitly opted out.
|
||||
- Tool runs in a subgraph (`len(ns) > 0`) and the handler was
|
||||
attached with `subgraphs=False` and a different `parent_ns`
|
||||
than the call's containing subgraph.
|
||||
"""
|
||||
if not metadata:
|
||||
return None
|
||||
if tags and TAG_NOSTREAM in tags:
|
||||
return None
|
||||
nskey = metadata.get("langgraph_checkpoint_ns")
|
||||
if not nskey:
|
||||
ns: tuple[str, ...] = ()
|
||||
else:
|
||||
ns = tuple(cast(str, nskey).split(NS_SEP))[:-1]
|
||||
if not self.subgraphs and len(ns) > 0 and ns != self.parent_ns:
|
||||
return None
|
||||
return ns
|
||||
|
||||
def _start(
|
||||
self,
|
||||
serialized: dict[str, Any] | None,
|
||||
input_str: str,
|
||||
*,
|
||||
run_id: UUID,
|
||||
metadata: dict[str, Any] | None,
|
||||
tags: list[str] | None,
|
||||
inputs: dict[str, Any] | None,
|
||||
kwargs: dict[str, Any],
|
||||
) -> None:
|
||||
ns = self._ns_for_emit(metadata, tags)
|
||||
if ns is None:
|
||||
return
|
||||
tool_call_id = cast("str | None", kwargs.get("tool_call_id")) or str(run_id)
|
||||
tool_name = (
|
||||
(serialized or {}).get("name")
|
||||
or cast("str | None", kwargs.get("name"))
|
||||
or ""
|
||||
)
|
||||
|
||||
def writer(delta: Any) -> None:
|
||||
self.stream(
|
||||
(
|
||||
ns,
|
||||
"tools",
|
||||
{
|
||||
"event": "tool-output-delta",
|
||||
"tool_call_id": tool_call_id,
|
||||
"delta": delta,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
token = _tool_call_writer.set(writer)
|
||||
self._run_to_call[run_id] = (ns, tool_call_id, token)
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"event": "tool-started",
|
||||
"tool_call_id": tool_call_id,
|
||||
"tool_name": tool_name,
|
||||
}
|
||||
if inputs is not None:
|
||||
payload["input"] = inputs
|
||||
self.stream((ns, "tools", payload))
|
||||
|
||||
def _end(self, output: Any, *, run_id: UUID) -> None:
|
||||
info = self._run_to_call.pop(run_id, None)
|
||||
if info is None:
|
||||
return
|
||||
ns, tool_call_id, token = info
|
||||
self._reset_writer(token)
|
||||
self.stream(
|
||||
(
|
||||
ns,
|
||||
"tools",
|
||||
{
|
||||
"event": "tool-finished",
|
||||
"tool_call_id": tool_call_id,
|
||||
"output": output,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
def _error(self, error: BaseException, *, run_id: UUID) -> None:
|
||||
info = self._run_to_call.pop(run_id, None)
|
||||
if info is None:
|
||||
return
|
||||
ns, tool_call_id, token = info
|
||||
self._reset_writer(token)
|
||||
self.stream(
|
||||
(
|
||||
ns,
|
||||
"tools",
|
||||
{
|
||||
"event": "tool-error",
|
||||
"tool_call_id": tool_call_id,
|
||||
"message": str(error),
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
def tap_output_aiter(
|
||||
self, run_id: UUID, output: AsyncIterator[T]
|
||||
) -> AsyncIterator[T]:
|
||||
"""Pass-through — required by the `_StreamingCallbackHandler` protocol."""
|
||||
return output
|
||||
|
||||
def tap_output_iter(self, run_id: UUID, output: Iterator[T]) -> Iterator[T]:
|
||||
"""Pass-through — sync counterpart to `tap_output_aiter`."""
|
||||
return output
|
||||
|
||||
@staticmethod
|
||||
def _reset_writer(token: Token[ToolCallWriter | None]) -> None:
|
||||
# Token is invalid if `on_tool_end` runs in a different context
|
||||
# than `on_tool_start` (e.g. langchain may hand off to a thread
|
||||
# worker without copying the context). Swallow that case; the
|
||||
# ContextVar lifetime is bounded by the enclosing task anyway.
|
||||
try:
|
||||
_tool_call_writer.reset(token)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Sync callbacks
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def on_tool_start(
|
||||
self,
|
||||
serialized: dict[str, Any],
|
||||
input_str: str,
|
||||
*,
|
||||
run_id: UUID,
|
||||
parent_run_id: UUID | None = None,
|
||||
tags: list[str] | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
inputs: dict[str, Any] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
self._start(
|
||||
serialized,
|
||||
input_str,
|
||||
run_id=run_id,
|
||||
metadata=metadata,
|
||||
tags=tags,
|
||||
inputs=inputs,
|
||||
kwargs=kwargs,
|
||||
)
|
||||
|
||||
def on_tool_end(
|
||||
self,
|
||||
output: Any,
|
||||
*,
|
||||
run_id: UUID,
|
||||
parent_run_id: UUID | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
self._end(output, run_id=run_id)
|
||||
|
||||
def on_tool_error(
|
||||
self,
|
||||
error: BaseException,
|
||||
*,
|
||||
run_id: UUID,
|
||||
parent_run_id: UUID | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
self._error(error, run_id=run_id)
|
||||
@@ -4,16 +4,27 @@ import ast
|
||||
import inspect
|
||||
import re
|
||||
import textwrap
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Sequence
|
||||
from functools import partial
|
||||
from typing import Any
|
||||
|
||||
from langchain_core.runnables import Runnable, RunnableLambda, RunnableSequence
|
||||
from langchain_core.runnables import (
|
||||
Runnable,
|
||||
RunnableLambda,
|
||||
RunnableParallel,
|
||||
RunnableSequence,
|
||||
)
|
||||
from langchain_core.runnables.base import RunnableBindingBase
|
||||
from langchain_core.runnables.config import run_in_executor
|
||||
from langgraph.checkpoint.base import ChannelVersions
|
||||
from typing_extensions import override
|
||||
|
||||
from langgraph._internal._runnable import RunnableCallable, RunnableSeq
|
||||
from langgraph._internal._timeout import sync_timeout_unsupported
|
||||
from langgraph.pregel.protocol import PregelProtocol
|
||||
|
||||
_SEQUENCE_TYPES = (RunnableSeq, RunnableSequence)
|
||||
|
||||
|
||||
def get_new_channel_versions(
|
||||
previous_versions: ChannelVersions, current_versions: ChannelVersions
|
||||
@@ -64,6 +75,68 @@ def find_subgraph_pregel(candidate: Runnable) -> PregelProtocol | None:
|
||||
return None
|
||||
|
||||
|
||||
def _sequence_steps(runnable: Runnable) -> Sequence[Runnable] | None:
|
||||
if isinstance(runnable, _SEQUENCE_TYPES):
|
||||
return runnable.steps
|
||||
return None
|
||||
|
||||
|
||||
def _parallel_steps(runnable: Runnable) -> Sequence[Runnable] | None:
|
||||
if isinstance(runnable, RunnableParallel):
|
||||
return tuple(runnable.steps__.values())
|
||||
return None
|
||||
|
||||
|
||||
def _has_method_override(runnable: Runnable, method_name: str) -> bool:
|
||||
method = getattr(type(runnable), method_name, None)
|
||||
return method is not None and method is not getattr(Runnable, method_name)
|
||||
|
||||
|
||||
def _is_executor_backed_afunc(afunc: Callable[..., Any] | None) -> bool:
|
||||
return isinstance(afunc, partial) and afunc.func is run_in_executor
|
||||
|
||||
|
||||
def _has_native_async(runnable: Runnable) -> bool:
|
||||
if isinstance(runnable, RunnableCallable):
|
||||
return runnable.afunc is not None and not _is_executor_backed_afunc(
|
||||
runnable.afunc
|
||||
)
|
||||
if isinstance(runnable, RunnableLambda):
|
||||
return bool(getattr(runnable, "afunc", False))
|
||||
return _has_method_override(runnable, "ainvoke")
|
||||
|
||||
|
||||
def _runnable_has_native_async(runnable: Runnable) -> bool:
|
||||
"""Return whether a runnable can be idle-timed without known sync code.
|
||||
|
||||
For custom runnable subclasses, an `ainvoke` override is treated as the
|
||||
async contract. We do not introspect whether that implementation delegates
|
||||
to blocking work internally — e.g. a subclass whose `ainvoke` calls
|
||||
`asyncio.to_thread(self.invoke, ...)` will pass this check but the wrapped
|
||||
sync work is still uncancellable. Idle-timeout enforcement on such a
|
||||
runnable will fire `NodeTimeoutError` correctly, but the background thread
|
||||
will keep running until its sync work returns.
|
||||
"""
|
||||
|
||||
while isinstance(runnable, RunnableBindingBase):
|
||||
runnable = runnable.bound
|
||||
steps = _sequence_steps(runnable)
|
||||
if steps is None:
|
||||
steps = _parallel_steps(runnable)
|
||||
if steps is not None:
|
||||
return all(_runnable_has_native_async(step) for step in steps)
|
||||
# Raw callables and the common composition wrappers created by graph
|
||||
# builders fall through here. We do not exhaustively unwrap every Runnable
|
||||
# wrapper — wrappers that provide `ainvoke` are treated as owning the async
|
||||
# contract.
|
||||
return _has_native_async(runnable)
|
||||
|
||||
|
||||
def validate_timeout_supported(runnable: Runnable, *, name: str) -> None:
|
||||
if not _runnable_has_native_async(runnable):
|
||||
raise sync_timeout_unsupported(name)
|
||||
|
||||
|
||||
def get_function_nonlocals(func: Callable) -> list[Any]:
|
||||
"""Get the nonlocal variables accessed by a function.
|
||||
|
||||
|
||||
@@ -16,7 +16,8 @@ from collections.abc import (
|
||||
Mapping,
|
||||
Sequence,
|
||||
)
|
||||
from dataclasses import is_dataclass
|
||||
from dataclasses import is_dataclass, replace
|
||||
from datetime import timedelta
|
||||
from functools import partial
|
||||
from inspect import isclass
|
||||
from typing import (
|
||||
@@ -73,6 +74,7 @@ from langgraph._internal._constants import (
|
||||
CONFIG_KEY_RUNTIME,
|
||||
CONFIG_KEY_SEND,
|
||||
CONFIG_KEY_STREAM,
|
||||
CONFIG_KEY_STREAM_MESSAGES_V2,
|
||||
CONFIG_KEY_TASK_ID,
|
||||
CONFIG_KEY_THREAD_ID,
|
||||
ERROR,
|
||||
@@ -95,13 +97,21 @@ from langgraph._internal._runnable import (
|
||||
RunnableSeq,
|
||||
coerce_to_runnable,
|
||||
)
|
||||
from langgraph._internal._timeout import coerce_timeout_policy
|
||||
from langgraph._internal._typing import MISSING, DeprecatedKwargs
|
||||
from langgraph.callbacks import (
|
||||
GraphInterruptEvent,
|
||||
GraphResumeEvent,
|
||||
get_async_graph_callback_manager_for_config,
|
||||
get_sync_graph_callback_manager_for_config,
|
||||
)
|
||||
from langgraph.channels.base import BaseChannel
|
||||
from langgraph.channels.topic import Topic
|
||||
from langgraph.config import get_config
|
||||
from langgraph.constants import END
|
||||
from langgraph.errors import (
|
||||
ErrorCode,
|
||||
GraphDrained,
|
||||
GraphRecursionError,
|
||||
InvalidUpdateError,
|
||||
create_error_message,
|
||||
@@ -116,6 +126,7 @@ from langgraph.pregel._algo import (
|
||||
)
|
||||
from langgraph.pregel._call import identifier
|
||||
from langgraph.pregel._checkpoint import (
|
||||
achannels_from_checkpoint,
|
||||
channels_from_checkpoint,
|
||||
copy_checkpoint,
|
||||
create_checkpoint,
|
||||
@@ -127,11 +138,18 @@ from langgraph.pregel._loop import (
|
||||
AsyncPregelLoop,
|
||||
SyncPregelLoop,
|
||||
)
|
||||
from langgraph.pregel._messages import StreamMessagesHandler
|
||||
from langgraph.pregel._messages import (
|
||||
StreamMessagesHandler,
|
||||
StreamMessagesHandlerV2,
|
||||
)
|
||||
from langgraph.pregel._read import DEFAULT_BOUND, PregelNode
|
||||
from langgraph.pregel._retry import RetryPolicy
|
||||
from langgraph.pregel._runner import PregelRunner
|
||||
from langgraph.pregel._utils import get_new_channel_versions
|
||||
from langgraph.pregel._tools import StreamToolCallHandler
|
||||
from langgraph.pregel._utils import (
|
||||
get_new_channel_versions,
|
||||
validate_timeout_supported,
|
||||
)
|
||||
from langgraph.pregel._validate import validate_graph, validate_keys
|
||||
from langgraph.pregel._write import ChannelWrite, ChannelWriteEntry
|
||||
from langgraph.pregel.debug import get_bolded_text, get_colored_text, tasks_w_writes
|
||||
@@ -139,9 +157,19 @@ from langgraph.pregel.protocol import PregelProtocol, StreamChunk, StreamProtoco
|
||||
from langgraph.runtime import (
|
||||
DEFAULT_RUNTIME,
|
||||
BaseUser,
|
||||
RunControl,
|
||||
Runtime,
|
||||
ServerInfo,
|
||||
)
|
||||
from langgraph.stream._mux import StreamMux
|
||||
from langgraph.stream._types import StreamTransformer
|
||||
from langgraph.stream.run_stream import AsyncGraphRunStream, GraphRunStream
|
||||
from langgraph.stream.transformers import (
|
||||
LifecycleTransformer,
|
||||
MessagesTransformer,
|
||||
SubgraphTransformer,
|
||||
ValuesTransformer,
|
||||
)
|
||||
from langgraph.types import (
|
||||
All,
|
||||
CachePolicy,
|
||||
@@ -155,6 +183,7 @@ from langgraph.types import (
|
||||
StateUpdate,
|
||||
StreamMode,
|
||||
StreamPart,
|
||||
TimeoutPolicy,
|
||||
ensure_valid_checkpointer,
|
||||
)
|
||||
from langgraph.typing import ContextT, InputT, OutputT, StateT
|
||||
@@ -180,6 +209,7 @@ class NodeBuilder:
|
||||
"_bound",
|
||||
"_retry_policy",
|
||||
"_cache_policy",
|
||||
"_timeout",
|
||||
)
|
||||
|
||||
_channels: str | list[str]
|
||||
@@ -190,6 +220,7 @@ class NodeBuilder:
|
||||
_bound: Runnable
|
||||
_retry_policy: list[RetryPolicy]
|
||||
_cache_policy: CachePolicy | None
|
||||
_timeout: TimeoutPolicy | None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -202,6 +233,7 @@ class NodeBuilder:
|
||||
self._bound = DEFAULT_BOUND
|
||||
self._retry_policy = []
|
||||
self._cache_policy = None
|
||||
self._timeout = None
|
||||
|
||||
def subscribe_only(
|
||||
self,
|
||||
@@ -320,6 +352,11 @@ class NodeBuilder:
|
||||
self._cache_policy = policy
|
||||
return self
|
||||
|
||||
def set_timeout(self, timeout: float | timedelta | TimeoutPolicy | None) -> Self:
|
||||
"""Set the per-attempt timeout policy for this node."""
|
||||
self._timeout = coerce_timeout_policy(timeout)
|
||||
return self
|
||||
|
||||
def build(self) -> PregelNode:
|
||||
"""Builds the node."""
|
||||
return PregelNode(
|
||||
@@ -331,9 +368,62 @@ class NodeBuilder:
|
||||
bound=self._bound,
|
||||
retry_policy=self._retry_policy,
|
||||
cache_policy=self._cache_policy,
|
||||
timeout=self._timeout,
|
||||
)
|
||||
|
||||
|
||||
def _collect_stream_modes(mux: Any) -> list[StreamMode]:
|
||||
"""Return the union of `required_stream_modes` across registered transformers.
|
||||
|
||||
Transformers declare the stream modes they need to function, and
|
||||
`stream_v2` asks the graph for exactly that union — no hardcoded
|
||||
default set. If zero transformers declare a given mode, the graph
|
||||
does not stream events for it.
|
||||
"""
|
||||
modes: set[StreamMode] = set()
|
||||
for transformer in mux._transformers:
|
||||
modes.update(
|
||||
cast(
|
||||
"tuple[StreamMode, ...]",
|
||||
getattr(transformer, "required_stream_modes", ()),
|
||||
)
|
||||
)
|
||||
return list(modes)
|
||||
|
||||
|
||||
def _normalize_stream_transformer_factories(
|
||||
specs: Sequence[Callable[[tuple[str, ...]], Any]] | None,
|
||||
) -> list[Callable[[tuple[str, ...]], Any]]:
|
||||
"""Normalize stream transformer specs to scoped factories.
|
||||
|
||||
A stream transformer spec is a callable that accepts
|
||||
`scope: tuple[str, ...]` and returns a fresh `StreamTransformer`.
|
||||
Transformer classes work when their constructor follows the same
|
||||
shape. Pre-built instances are rejected because they cannot be
|
||||
cloned into subgraph scopes.
|
||||
"""
|
||||
factories: list[Callable[[tuple[str, ...]], Any]] = []
|
||||
for spec in specs or ():
|
||||
if isinstance(spec, StreamTransformer):
|
||||
raise TypeError(
|
||||
"stream_v2 transformers must be scope-aware callables, "
|
||||
f"got pre-built instance {type(spec).__name__}. Pass the "
|
||||
"transformer class or a factory like "
|
||||
"`lambda scope: MyTransformer(scope, ...)`."
|
||||
)
|
||||
if not callable(spec):
|
||||
raise TypeError(
|
||||
"stream_v2 transformers must be scope-aware callables, "
|
||||
f"got {type(spec).__name__}."
|
||||
)
|
||||
|
||||
def factory(scope: tuple[str, ...], _spec: Callable[..., Any] = spec) -> Any:
|
||||
return _spec(scope)
|
||||
|
||||
factories.append(factory)
|
||||
return factories
|
||||
|
||||
|
||||
class Pregel(
|
||||
PregelProtocol[StateT, ContextT, InputT, OutputT],
|
||||
Generic[StateT, ContextT, InputT, OutputT],
|
||||
@@ -640,6 +730,7 @@ class Pregel(
|
||||
name: str = "LangGraph"
|
||||
|
||||
trigger_to_nodes: Mapping[str, Sequence[str]]
|
||||
node_error_handler_map: Mapping[str, str]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -664,7 +755,9 @@ class Pregel(
|
||||
context_schema: type[ContextT] | None = None,
|
||||
config: RunnableConfig | None = None,
|
||||
trigger_to_nodes: Mapping[str, Sequence[str]] | None = None,
|
||||
node_error_handler_map: Mapping[str, str] | None = None,
|
||||
name: str = "LangGraph",
|
||||
stream_transformers: Sequence[Callable[[tuple[str, ...]], Any]] | None = None,
|
||||
**deprecated_kwargs: Unpack[DeprecatedKwargs],
|
||||
) -> None:
|
||||
if (
|
||||
@@ -710,7 +803,11 @@ class Pregel(
|
||||
self.context_schema = context_schema
|
||||
self.config = config
|
||||
self.trigger_to_nodes = trigger_to_nodes or {}
|
||||
self.node_error_handler_map = node_error_handler_map or {}
|
||||
self.name = name
|
||||
self.stream_transformers: tuple[Callable[[tuple[str, ...]], Any], ...] = tuple(
|
||||
stream_transformers or ()
|
||||
)
|
||||
self._serde_allowlist: set[tuple[str, ...]] | None = None
|
||||
if auto_validate:
|
||||
self.validate()
|
||||
@@ -811,6 +908,9 @@ class Pregel(
|
||||
)
|
||||
|
||||
def validate(self) -> Self:
|
||||
for name, node in self.nodes.items():
|
||||
if node.timeout is not None:
|
||||
validate_timeout_supported(node.node or node.bound, name=name)
|
||||
validate_graph(
|
||||
self.nodes,
|
||||
{k: v for k, v in self.channels.items() if isinstance(v, BaseChannel)},
|
||||
@@ -1046,6 +1146,10 @@ class Pregel(
|
||||
channels, managed = channels_from_checkpoint(
|
||||
self.channels,
|
||||
saved.checkpoint,
|
||||
saver=self.checkpointer
|
||||
if isinstance(self.checkpointer, BaseCheckpointSaver)
|
||||
else None,
|
||||
config=saved.config,
|
||||
)
|
||||
# tasks for this checkpoint
|
||||
next_tasks = prepare_next_tasks(
|
||||
@@ -1162,9 +1266,13 @@ class Pregel(
|
||||
|
||||
step = saved.metadata.get("step", -1) + 1
|
||||
stop = step + 2
|
||||
channels, managed = channels_from_checkpoint(
|
||||
channels, managed = await achannels_from_checkpoint(
|
||||
self.channels,
|
||||
saved.checkpoint,
|
||||
saver=self.checkpointer
|
||||
if isinstance(self.checkpointer, BaseCheckpointSaver)
|
||||
else None,
|
||||
config=saved.config,
|
||||
)
|
||||
# tasks for this checkpoint
|
||||
next_tasks = prepare_next_tasks(
|
||||
@@ -1535,6 +1643,11 @@ class Pregel(
|
||||
channels, managed = channels_from_checkpoint(
|
||||
self.channels,
|
||||
checkpoint,
|
||||
saver=self.checkpointer
|
||||
if saved is not None
|
||||
and isinstance(self.checkpointer, BaseCheckpointSaver)
|
||||
else None,
|
||||
config=saved.config if saved is not None else None,
|
||||
)
|
||||
values, as_node = updates[0][:2]
|
||||
|
||||
@@ -1978,9 +2091,14 @@ class Pregel(
|
||||
)
|
||||
if saved:
|
||||
checkpoint_config = patch_configurable(config, saved.config[CONF])
|
||||
channels, managed = channels_from_checkpoint(
|
||||
channels, managed = await achannels_from_checkpoint(
|
||||
self.channels,
|
||||
checkpoint,
|
||||
saver=self.checkpointer
|
||||
if saved is not None
|
||||
and isinstance(self.checkpointer, BaseCheckpointSaver)
|
||||
else None,
|
||||
config=saved.config if saved is not None else None,
|
||||
)
|
||||
values, as_node = updates[0][:2]
|
||||
# no values, just clear all tasks
|
||||
@@ -2457,6 +2575,7 @@ class Pregel(
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
durability: Durability | None = None,
|
||||
control: RunControl | None = None,
|
||||
subgraphs: bool = False,
|
||||
debug: bool | None = None,
|
||||
version: Literal["v2"],
|
||||
@@ -2476,6 +2595,7 @@ class Pregel(
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
durability: Durability | None = None,
|
||||
control: RunControl | None = None,
|
||||
subgraphs: bool = False,
|
||||
debug: bool | None = None,
|
||||
version: Literal["v1"] = ...,
|
||||
@@ -2494,6 +2614,7 @@ class Pregel(
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
durability: Durability | None = None,
|
||||
control: RunControl | None = None,
|
||||
subgraphs: bool = False,
|
||||
debug: bool | None = None,
|
||||
version: Literal["v1", "v2"] = "v1",
|
||||
@@ -2538,6 +2659,7 @@ class Pregel(
|
||||
- `"sync"`: Changes are persisted synchronously before the next step starts.
|
||||
- `"async"`: Changes are persisted asynchronously while the next step executes.
|
||||
- `"exit"`: Changes are persisted only when the graph exits.
|
||||
control: Optional run control used to request cooperative drain.
|
||||
subgraphs: Whether to stream events from inside subgraphs, defaults to `False`.
|
||||
|
||||
If `True`, the events will be emitted as tuples `(namespace, data)`,
|
||||
@@ -2576,15 +2698,7 @@ class Pregel(
|
||||
stream = SyncQueue()
|
||||
|
||||
config = ensure_config(self.config, config)
|
||||
callback_manager = get_callback_manager_for_config(config)
|
||||
if "ls_integration" not in callback_manager.metadata:
|
||||
callback_manager.add_metadata({"ls_integration": "langgraph"})
|
||||
run_manager = callback_manager.on_chain_start(
|
||||
None,
|
||||
input,
|
||||
name=config.get("run_name", self.get_name()),
|
||||
run_id=config.get("run_id"),
|
||||
)
|
||||
run_manager = None
|
||||
try:
|
||||
# assign defaults
|
||||
(
|
||||
@@ -2605,6 +2719,36 @@ class Pregel(
|
||||
interrupt_after=interrupt_after,
|
||||
durability=durability,
|
||||
)
|
||||
callback_manager = get_callback_manager_for_config(config)
|
||||
if "messages" in stream_modes and version != "v2":
|
||||
# Strip any inherited v2 messages handler so a v1 stream
|
||||
# does not get routed through the content-block event
|
||||
# protocol. Leave v1 handlers in place — an outer
|
||||
# stream(stream_mode="messages", subgraphs=True) relies
|
||||
# on its inheritable handler to observe events emitted
|
||||
# by inner stream(stream_mode="messages") calls.
|
||||
callback_manager.handlers = [
|
||||
h
|
||||
for h in callback_manager.handlers
|
||||
if not isinstance(h, StreamMessagesHandlerV2)
|
||||
]
|
||||
callback_manager.inheritable_handlers = [
|
||||
h
|
||||
for h in callback_manager.inheritable_handlers
|
||||
if not isinstance(h, StreamMessagesHandlerV2)
|
||||
]
|
||||
if "ls_integration" not in callback_manager.metadata:
|
||||
callback_manager.add_metadata({"ls_integration": "langgraph"})
|
||||
run_manager = callback_manager.on_chain_start(
|
||||
None,
|
||||
input,
|
||||
name=config.get("run_name", self.get_name()),
|
||||
run_id=config.get("run_id"),
|
||||
)
|
||||
graph_callback_manager = get_sync_graph_callback_manager_for_config(
|
||||
config,
|
||||
run_id=run_manager.run_id,
|
||||
)
|
||||
if checkpointer is None and durability is not None:
|
||||
warnings.warn(
|
||||
"`durability` has no effect when no checkpointer is present.",
|
||||
@@ -2616,14 +2760,33 @@ class Pregel(
|
||||
# set up messages stream mode
|
||||
if "messages" in stream_modes:
|
||||
ns_ = cast(str | None, config[CONF].get(CONFIG_KEY_CHECKPOINT_NS))
|
||||
use_stream_messages_v2 = bool(
|
||||
version == "v2" and config[CONF].get(CONFIG_KEY_STREAM_MESSAGES_V2)
|
||||
)
|
||||
messages_handler_cls = (
|
||||
StreamMessagesHandlerV2
|
||||
if use_stream_messages_v2
|
||||
else StreamMessagesHandler
|
||||
)
|
||||
run_manager.inheritable_handlers.append(
|
||||
StreamMessagesHandler(
|
||||
messages_handler_cls(
|
||||
stream.put,
|
||||
subgraphs,
|
||||
parent_ns=tuple(ns_.split(NS_SEP)) if ns_ else None,
|
||||
)
|
||||
)
|
||||
|
||||
# set up tools stream mode
|
||||
if "tools" in stream_modes:
|
||||
ns_tools = cast(str | None, config[CONF].get(CONFIG_KEY_CHECKPOINT_NS))
|
||||
run_manager.inheritable_handlers.append(
|
||||
StreamToolCallHandler(
|
||||
stream.put,
|
||||
subgraphs,
|
||||
parent_ns=tuple(ns_tools.split(NS_SEP)) if ns_tools else None,
|
||||
)
|
||||
)
|
||||
|
||||
# set up custom stream mode
|
||||
if "custom" in stream_modes:
|
||||
|
||||
@@ -2661,6 +2824,7 @@ class Pregel(
|
||||
previous=None,
|
||||
execution_info=None,
|
||||
server_info=server_info,
|
||||
control=control or parent_runtime.control or RunControl(),
|
||||
)
|
||||
runtime = parent_runtime.merge(runtime)
|
||||
config[CONF][CONFIG_KEY_RUNTIME] = runtime
|
||||
@@ -2669,6 +2833,17 @@ class Pregel(
|
||||
_output_mapper = self._output_mapper if version == "v2" else None
|
||||
_state_mapper = self._state_mapper if version == "v2" else None
|
||||
|
||||
def emit_graph_lifecycle_events(loop: SyncPregelLoop) -> None:
|
||||
while (event := loop._pop_lifecycle_event()) is not None:
|
||||
if isinstance(event, GraphResumeEvent):
|
||||
graph_callback_manager.on_resume(
|
||||
replace(event, run_id=graph_callback_manager.run_id)
|
||||
)
|
||||
else:
|
||||
graph_callback_manager.on_interrupt(
|
||||
replace(event, run_id=graph_callback_manager.run_id)
|
||||
)
|
||||
|
||||
with SyncPregelLoop(
|
||||
input,
|
||||
stream=StreamProtocol(stream.put, stream_modes),
|
||||
@@ -2689,7 +2864,9 @@ class Pregel(
|
||||
migrate_checkpoint=self._migrate_checkpoint,
|
||||
retry_policy=self.retry_policy,
|
||||
cache_policy=self.cache_policy,
|
||||
has_graph_lifecycle_callbacks=bool(graph_callback_manager.handlers),
|
||||
) as loop:
|
||||
emit_graph_lifecycle_events(loop)
|
||||
# create runner
|
||||
runner = PregelRunner(
|
||||
submit=config[CONF].get(
|
||||
@@ -2697,6 +2874,8 @@ class Pregel(
|
||||
),
|
||||
put_writes=weakref.WeakMethod(loop.put_writes),
|
||||
node_finished=config[CONF].get(CONFIG_KEY_NODE_FINISHED),
|
||||
node_error_handler_map=self.node_error_handler_map,
|
||||
schedule_error_handler=loop.schedule_error_handler,
|
||||
)
|
||||
# enable subgraph streaming
|
||||
if subgraphs:
|
||||
@@ -2751,9 +2930,11 @@ class Pregel(
|
||||
_state_mapper,
|
||||
)
|
||||
loop.after_tick()
|
||||
emit_graph_lifecycle_events(loop)
|
||||
# wait for checkpoint
|
||||
if durability_ == "sync":
|
||||
loop._put_checkpoint_fut.result()
|
||||
emit_graph_lifecycle_events(loop)
|
||||
# emit output
|
||||
yield from _output(
|
||||
stream_mode,
|
||||
@@ -2776,10 +2957,15 @@ class Pregel(
|
||||
error_code=ErrorCode.GRAPH_RECURSION_LIMIT,
|
||||
)
|
||||
raise GraphRecursionError(msg)
|
||||
elif loop.status == "draining":
|
||||
if loop.control is None:
|
||||
raise RuntimeError("Draining status requires run control")
|
||||
raise GraphDrained(loop.control.drain_reason or "shutdown")
|
||||
# set final channel values as run output
|
||||
run_manager.on_chain_end(loop.output)
|
||||
except BaseException as e:
|
||||
run_manager.on_chain_error(e)
|
||||
if run_manager is not None:
|
||||
run_manager.on_chain_error(e)
|
||||
raise
|
||||
|
||||
@overload
|
||||
@@ -2795,6 +2981,7 @@ class Pregel(
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
durability: Durability | None = None,
|
||||
control: RunControl | None = None,
|
||||
subgraphs: bool = False,
|
||||
debug: bool | None = None,
|
||||
version: Literal["v2"],
|
||||
@@ -2814,6 +3001,7 @@ class Pregel(
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
durability: Durability | None = None,
|
||||
control: RunControl | None = None,
|
||||
subgraphs: bool = False,
|
||||
debug: bool | None = None,
|
||||
version: Literal["v1"] = ...,
|
||||
@@ -2832,6 +3020,7 @@ class Pregel(
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
durability: Durability | None = None,
|
||||
control: RunControl | None = None,
|
||||
subgraphs: bool = False,
|
||||
debug: bool | None = None,
|
||||
version: Literal["v1", "v2"] = "v1",
|
||||
@@ -2876,6 +3065,7 @@ class Pregel(
|
||||
- `"sync"`: Changes are persisted synchronously before the next step starts.
|
||||
- `"async"`: Changes are persisted asynchronously while the next step executes.
|
||||
- `"exit"`: Changes are persisted only when the graph exits.
|
||||
control: Optional run control used to request cooperative drain.
|
||||
subgraphs: Whether to stream events from inside subgraphs, defaults to `False`.
|
||||
|
||||
If `True`, the events will be emitted as tuples `(namespace, data)`,
|
||||
@@ -2919,29 +3109,7 @@ class Pregel(
|
||||
)
|
||||
|
||||
config = ensure_config(self.config, config)
|
||||
callback_manager = get_async_callback_manager_for_config(config)
|
||||
if "ls_integration" not in callback_manager.metadata:
|
||||
callback_manager.add_metadata({"ls_integration": "langgraph"})
|
||||
run_manager = await callback_manager.on_chain_start(
|
||||
None,
|
||||
input,
|
||||
name=config.get("run_name", self.get_name()),
|
||||
run_id=config.get("run_id"),
|
||||
)
|
||||
# if running from astream_log() run each proc with streaming
|
||||
do_stream = (
|
||||
next(
|
||||
(
|
||||
True
|
||||
for h in run_manager.handlers
|
||||
if isinstance(h, _StreamingCallbackHandler)
|
||||
and not isinstance(h, StreamMessagesHandler)
|
||||
),
|
||||
False,
|
||||
)
|
||||
if _StreamingCallbackHandler is not None
|
||||
else False
|
||||
)
|
||||
run_manager = None
|
||||
try:
|
||||
# assign defaults
|
||||
(
|
||||
@@ -2962,6 +3130,50 @@ class Pregel(
|
||||
interrupt_after=interrupt_after,
|
||||
durability=durability,
|
||||
)
|
||||
callback_manager = get_async_callback_manager_for_config(config)
|
||||
if "messages" in stream_modes and version != "v2":
|
||||
# Strip any inherited v2 messages handler so a v1 stream
|
||||
# does not get routed through the content-block event
|
||||
# protocol. Leave v1 handlers in place — an outer
|
||||
# astream(stream_mode="messages", subgraphs=True) relies
|
||||
# on its inheritable handler to observe events emitted
|
||||
# by inner astream(stream_mode="messages") calls.
|
||||
callback_manager.handlers = [
|
||||
h
|
||||
for h in callback_manager.handlers
|
||||
if not isinstance(h, StreamMessagesHandlerV2)
|
||||
]
|
||||
callback_manager.inheritable_handlers = [
|
||||
h
|
||||
for h in callback_manager.inheritable_handlers
|
||||
if not isinstance(h, StreamMessagesHandlerV2)
|
||||
]
|
||||
if "ls_integration" not in callback_manager.metadata:
|
||||
callback_manager.add_metadata({"ls_integration": "langgraph"})
|
||||
run_manager = await callback_manager.on_chain_start(
|
||||
None,
|
||||
input,
|
||||
name=config.get("run_name", self.get_name()),
|
||||
run_id=config.get("run_id"),
|
||||
)
|
||||
graph_callback_manager = get_async_graph_callback_manager_for_config(
|
||||
config,
|
||||
run_id=run_manager.run_id,
|
||||
)
|
||||
# if running from astream_log() run each proc with streaming
|
||||
do_stream = (
|
||||
next(
|
||||
(
|
||||
True
|
||||
for h in run_manager.handlers
|
||||
if isinstance(h, _StreamingCallbackHandler)
|
||||
and not isinstance(h, StreamMessagesHandler)
|
||||
),
|
||||
False,
|
||||
)
|
||||
if _StreamingCallbackHandler is not None
|
||||
else False
|
||||
)
|
||||
if checkpointer is None and durability is not None:
|
||||
warnings.warn(
|
||||
"`durability` has no effect when no checkpointer is present.",
|
||||
@@ -2974,14 +3186,33 @@ class Pregel(
|
||||
if "messages" in stream_modes:
|
||||
# namespace can be None in a root level graph?
|
||||
ns_ = cast(str | None, config[CONF].get(CONFIG_KEY_CHECKPOINT_NS))
|
||||
use_stream_messages_v2 = bool(
|
||||
version == "v2" and config[CONF].get(CONFIG_KEY_STREAM_MESSAGES_V2)
|
||||
)
|
||||
messages_handler_cls = (
|
||||
StreamMessagesHandlerV2
|
||||
if use_stream_messages_v2
|
||||
else StreamMessagesHandler
|
||||
)
|
||||
run_manager.inheritable_handlers.append(
|
||||
StreamMessagesHandler(
|
||||
messages_handler_cls(
|
||||
stream_put,
|
||||
subgraphs,
|
||||
parent_ns=tuple(ns_.split(NS_SEP)) if ns_ else None,
|
||||
)
|
||||
)
|
||||
|
||||
# set up tools stream mode
|
||||
if "tools" in stream_modes:
|
||||
ns_tools = cast(str | None, config[CONF].get(CONFIG_KEY_CHECKPOINT_NS))
|
||||
run_manager.inheritable_handlers.append(
|
||||
StreamToolCallHandler(
|
||||
stream_put,
|
||||
subgraphs,
|
||||
parent_ns=tuple(ns_tools.split(NS_SEP)) if ns_tools else None,
|
||||
)
|
||||
)
|
||||
|
||||
# set up custom stream mode
|
||||
def stream_writer(c: Any) -> None:
|
||||
aioloop.call_soon_threadsafe(
|
||||
@@ -3034,6 +3265,7 @@ class Pregel(
|
||||
previous=None,
|
||||
execution_info=None,
|
||||
server_info=server_info,
|
||||
control=control or parent_runtime.control or RunControl(),
|
||||
)
|
||||
runtime = parent_runtime.merge(runtime)
|
||||
config[CONF][CONFIG_KEY_RUNTIME] = runtime
|
||||
@@ -3042,6 +3274,28 @@ class Pregel(
|
||||
_output_mapper = self._output_mapper if version == "v2" else None
|
||||
_state_mapper = self._state_mapper if version == "v2" else None
|
||||
|
||||
async def aemit_graph_lifecycle_events(loop: AsyncPregelLoop) -> None:
|
||||
while (event := loop._pop_lifecycle_event()) is not None:
|
||||
if isinstance(event, GraphResumeEvent):
|
||||
await graph_callback_manager.on_resume(
|
||||
GraphResumeEvent(
|
||||
run_id=graph_callback_manager.run_id,
|
||||
status=event.status,
|
||||
checkpoint_id=event.checkpoint_id,
|
||||
checkpoint_ns=event.checkpoint_ns,
|
||||
)
|
||||
)
|
||||
else:
|
||||
await graph_callback_manager.on_interrupt(
|
||||
GraphInterruptEvent(
|
||||
run_id=graph_callback_manager.run_id,
|
||||
status=event.status,
|
||||
checkpoint_id=event.checkpoint_id,
|
||||
checkpoint_ns=event.checkpoint_ns,
|
||||
interrupts=event.interrupts,
|
||||
)
|
||||
)
|
||||
|
||||
async with AsyncPregelLoop(
|
||||
input,
|
||||
stream=StreamProtocol(stream.put_nowait, stream_modes),
|
||||
@@ -3062,7 +3316,9 @@ class Pregel(
|
||||
migrate_checkpoint=self._migrate_checkpoint,
|
||||
retry_policy=self.retry_policy,
|
||||
cache_policy=self.cache_policy,
|
||||
has_graph_lifecycle_callbacks=bool(graph_callback_manager.handlers),
|
||||
) as loop:
|
||||
await aemit_graph_lifecycle_events(loop)
|
||||
# create runner
|
||||
runner = PregelRunner(
|
||||
submit=config[CONF].get(
|
||||
@@ -3071,6 +3327,8 @@ class Pregel(
|
||||
put_writes=weakref.WeakMethod(loop.put_writes),
|
||||
use_astream=do_stream,
|
||||
node_finished=config[CONF].get(CONFIG_KEY_NODE_FINISHED),
|
||||
node_error_handler_map=self.node_error_handler_map,
|
||||
aschedule_error_handler=loop.aschedule_error_handler,
|
||||
)
|
||||
# enable subgraph streaming
|
||||
if subgraphs:
|
||||
@@ -3144,6 +3402,7 @@ class Pregel(
|
||||
):
|
||||
yield o
|
||||
loop.after_tick()
|
||||
await aemit_graph_lifecycle_events(loop)
|
||||
# wait for checkpoint
|
||||
if durability_ == "sync":
|
||||
await cast(asyncio.Future, loop._put_checkpoint_fut)
|
||||
@@ -3152,6 +3411,8 @@ class Pregel(
|
||||
if _cleanup_waiter is not None:
|
||||
await _cleanup_waiter()
|
||||
|
||||
await aemit_graph_lifecycle_events(loop)
|
||||
|
||||
# emit output
|
||||
for o in _output(
|
||||
stream_mode,
|
||||
@@ -3175,12 +3436,159 @@ class Pregel(
|
||||
error_code=ErrorCode.GRAPH_RECURSION_LIMIT,
|
||||
)
|
||||
raise GraphRecursionError(msg)
|
||||
elif loop.status == "draining":
|
||||
if loop.control is None:
|
||||
raise RuntimeError("Draining status requires run control")
|
||||
raise GraphDrained(loop.control.drain_reason or "shutdown")
|
||||
# set final channel values as run output
|
||||
await run_manager.on_chain_end(loop.output)
|
||||
except BaseException as e:
|
||||
await asyncio.shield(run_manager.on_chain_error(e))
|
||||
if run_manager is not None:
|
||||
await asyncio.shield(run_manager.on_chain_error(e))
|
||||
raise
|
||||
|
||||
def stream_v2(
|
||||
self,
|
||||
input: InputT | Command | None,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
control: RunControl | None = None,
|
||||
transformers: Sequence[Callable[[tuple[str, ...]], Any]] | None = None,
|
||||
) -> Any:
|
||||
"""Start a sync v2 streaming run driven by transformer projections.
|
||||
|
||||
Builds a `StreamMux` from the built-in transformers, this
|
||||
graph's compile-time `stream_transformers`, and any additional
|
||||
`transformers=` supplied at the call site. Returns a
|
||||
`GraphRunStream` that the caller drives by iterating any
|
||||
projection — no background thread.
|
||||
|
||||
`run.output`, `run.interrupted` and `run.interrupts` work
|
||||
regardless of which transformers are registered.
|
||||
|
||||
Note:
|
||||
Nesting v1 `stream(stream_mode="messages")` inside a node
|
||||
of a `stream_v2` run is not fully supported. The outer v2
|
||||
messages handler reroutes `BaseChatModel.invoke` through
|
||||
the v2 event protocol, so the inner v1 handler does not see
|
||||
`on_llm_new_token` chunks. The inner stream still yields a
|
||||
finalized message via `on_llm_end`. Use `stream_v2` for
|
||||
the inner graph as well, or call
|
||||
`chat_model.stream(...)` explicitly, to get token-level
|
||||
streaming.
|
||||
|
||||
Args:
|
||||
input: Graph input.
|
||||
config: Optional runnable config forwarded to the graph.
|
||||
interrupt_before: Nodes to interrupt before, if any.
|
||||
interrupt_after: Nodes to interrupt after, if any.
|
||||
control: Optional run control used to request cooperative drain.
|
||||
transformers: Extra transformer classes or configured factories
|
||||
appended after compile-time `stream_transformers`. Factories
|
||||
are called as `factory(scope)` so they can propagate to
|
||||
subgraph scopes.
|
||||
|
||||
Returns:
|
||||
A `GraphRunStream` the caller iterates to drive the run.
|
||||
"""
|
||||
parent_ns = _resolve_parent_ns(self.config, config)
|
||||
compiled_factories = _normalize_stream_transformer_factories(
|
||||
self.stream_transformers
|
||||
)
|
||||
extra_factories = _normalize_stream_transformer_factories(transformers)
|
||||
mux = StreamMux(
|
||||
factories=[
|
||||
ValuesTransformer,
|
||||
MessagesTransformer,
|
||||
LifecycleTransformer,
|
||||
SubgraphTransformer,
|
||||
*compiled_factories,
|
||||
*extra_factories,
|
||||
],
|
||||
scope=parent_ns,
|
||||
is_async=False,
|
||||
)
|
||||
graph_iter = iter(
|
||||
self.stream(
|
||||
input,
|
||||
patch_configurable(config, {CONFIG_KEY_STREAM_MESSAGES_V2: True}),
|
||||
stream_mode=_collect_stream_modes(mux),
|
||||
subgraphs=True,
|
||||
version="v2",
|
||||
interrupt_before=interrupt_before,
|
||||
interrupt_after=interrupt_after,
|
||||
control=control,
|
||||
)
|
||||
)
|
||||
return GraphRunStream(graph_iter, mux)
|
||||
|
||||
async def astream_v2(
|
||||
self,
|
||||
input: InputT | Command | None,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
control: RunControl | None = None,
|
||||
transformers: Sequence[Callable[[tuple[str, ...]], Any]] | None = None,
|
||||
) -> Any:
|
||||
"""Async counterpart to `stream_v2`.
|
||||
|
||||
Returns an `AsyncGraphRunStream` whose projections can be awaited
|
||||
concurrently; each subscribed cursor drives the pump when its
|
||||
buffer is empty.
|
||||
|
||||
Note:
|
||||
Same nesting limitation as `stream_v2`: nesting v1
|
||||
`astream(stream_mode="messages")` inside a node of an
|
||||
`astream_v2` run drops `on_llm_new_token` chunks because
|
||||
the outer v2 handler reroutes `BaseChatModel.invoke`
|
||||
through the v2 event protocol. Use `astream_v2` for the
|
||||
inner graph as well, or call `chat_model.astream(...)`
|
||||
explicitly, to get token-level streaming.
|
||||
|
||||
Args:
|
||||
input: Graph input.
|
||||
config: Optional runnable config forwarded to the graph.
|
||||
interrupt_before: Nodes to interrupt before, if any.
|
||||
interrupt_after: Nodes to interrupt after, if any.
|
||||
control: Optional run control used to request cooperative drain.
|
||||
transformers: Extra transformer classes or configured factories
|
||||
appended after compile-time `stream_transformers`. Factories
|
||||
are called as `factory(scope)` so they can propagate to
|
||||
subgraph scopes.
|
||||
"""
|
||||
parent_ns = _resolve_parent_ns(self.config, config)
|
||||
compiled_factories = _normalize_stream_transformer_factories(
|
||||
self.stream_transformers
|
||||
)
|
||||
extra_factories = _normalize_stream_transformer_factories(transformers)
|
||||
mux = StreamMux(
|
||||
factories=[
|
||||
ValuesTransformer,
|
||||
MessagesTransformer,
|
||||
LifecycleTransformer,
|
||||
SubgraphTransformer,
|
||||
*compiled_factories,
|
||||
*extra_factories,
|
||||
],
|
||||
scope=parent_ns,
|
||||
is_async=True,
|
||||
)
|
||||
graph_aiter = self.astream(
|
||||
input,
|
||||
patch_configurable(config, {CONFIG_KEY_STREAM_MESSAGES_V2: True}),
|
||||
stream_mode=_collect_stream_modes(mux),
|
||||
subgraphs=True,
|
||||
version="v2",
|
||||
interrupt_before=interrupt_before,
|
||||
interrupt_after=interrupt_after,
|
||||
control=control,
|
||||
).__aiter__()
|
||||
return AsyncGraphRunStream(graph_aiter, mux)
|
||||
|
||||
@overload
|
||||
def invoke(
|
||||
self,
|
||||
@@ -3194,6 +3602,7 @@ class Pregel(
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
durability: Durability | None = None,
|
||||
control: RunControl | None = None,
|
||||
version: Literal["v2"],
|
||||
**kwargs: Any,
|
||||
) -> GraphOutput[OutputT]: ...
|
||||
@@ -3211,6 +3620,7 @@ class Pregel(
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
durability: Durability | None = None,
|
||||
control: RunControl | None = None,
|
||||
version: Literal["v2"],
|
||||
**kwargs: Any,
|
||||
) -> list[StreamPart[StateT, OutputT]]: ...
|
||||
@@ -3228,6 +3638,7 @@ class Pregel(
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
durability: Durability | None = None,
|
||||
control: RunControl | None = None,
|
||||
version: Literal["v1"] = ...,
|
||||
**kwargs: Any,
|
||||
) -> dict[str, Any] | Any: ...
|
||||
@@ -3244,6 +3655,7 @@ class Pregel(
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
durability: Durability | None = None,
|
||||
control: RunControl | None = None,
|
||||
version: Literal["v1", "v2"] = "v1",
|
||||
**kwargs: Any,
|
||||
) -> dict[str, Any] | Any:
|
||||
@@ -3268,6 +3680,7 @@ class Pregel(
|
||||
- `"sync"`: Changes are persisted synchronously before the next step starts.
|
||||
- `"async"`: Changes are persisted asynchronously while the next step executes.
|
||||
- `"exit"`: Changes are persisted only when the graph exits.
|
||||
control: Optional run control used to request cooperative drain.
|
||||
version: The streaming format version. `"v1"` (default) returns the
|
||||
traditional format, `"v2"` returns `StreamPart` typed dicts when
|
||||
`stream_mode` is not `"values"`.
|
||||
@@ -3295,6 +3708,7 @@ class Pregel(
|
||||
interrupt_before=interrupt_before,
|
||||
interrupt_after=interrupt_after,
|
||||
durability=durability,
|
||||
control=control,
|
||||
version=version,
|
||||
**kwargs,
|
||||
):
|
||||
@@ -3318,6 +3732,7 @@ class Pregel(
|
||||
interrupt_before=interrupt_before,
|
||||
interrupt_after=interrupt_after,
|
||||
durability=durability,
|
||||
control=control,
|
||||
**kwargs,
|
||||
):
|
||||
if stream_mode == "values":
|
||||
@@ -3364,6 +3779,7 @@ class Pregel(
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
durability: Durability | None = None,
|
||||
control: RunControl | None = None,
|
||||
version: Literal["v2"],
|
||||
**kwargs: Any,
|
||||
) -> GraphOutput[OutputT]: ...
|
||||
@@ -3381,6 +3797,7 @@ class Pregel(
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
durability: Durability | None = None,
|
||||
control: RunControl | None = None,
|
||||
version: Literal["v2"],
|
||||
**kwargs: Any,
|
||||
) -> list[StreamPart[StateT, OutputT]]: ...
|
||||
@@ -3398,6 +3815,7 @@ class Pregel(
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
durability: Durability | None = None,
|
||||
control: RunControl | None = None,
|
||||
version: Literal["v1"] = ...,
|
||||
**kwargs: Any,
|
||||
) -> dict[str, Any] | Any: ...
|
||||
@@ -3414,6 +3832,7 @@ class Pregel(
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
durability: Durability | None = None,
|
||||
control: RunControl | None = None,
|
||||
version: Literal["v1", "v2"] = "v1",
|
||||
**kwargs: Any,
|
||||
) -> dict[str, Any] | Any:
|
||||
@@ -3438,6 +3857,7 @@ class Pregel(
|
||||
- `"sync"`: Changes are persisted synchronously before the next step starts.
|
||||
- `"async"`: Changes are persisted asynchronously while the next step executes.
|
||||
- `"exit"`: Changes are persisted only when the graph exits.
|
||||
control: Optional run control used to request cooperative drain.
|
||||
version: The streaming format version. `"v1"` (default) returns the
|
||||
traditional format, `"v2"` returns `StreamPart` typed dicts when
|
||||
`stream_mode` is not `"values"`.
|
||||
@@ -3465,6 +3885,7 @@ class Pregel(
|
||||
interrupt_before=interrupt_before,
|
||||
interrupt_after=interrupt_after,
|
||||
durability=durability,
|
||||
control=control,
|
||||
version=version,
|
||||
**kwargs,
|
||||
):
|
||||
@@ -3488,6 +3909,7 @@ class Pregel(
|
||||
interrupt_before=interrupt_before,
|
||||
interrupt_after=interrupt_after,
|
||||
durability=durability,
|
||||
control=control,
|
||||
**kwargs,
|
||||
):
|
||||
if stream_mode == "values":
|
||||
@@ -3656,18 +4078,35 @@ def _coerce_checkpoint_values(payload: Any, mapper: Callable[[Any], Any]) -> Non
|
||||
payload["values"] = mapper(payload["values"])
|
||||
|
||||
|
||||
def _resolve_parent_ns(
|
||||
graph_config: RunnableConfig | None, call_config: RunnableConfig | None
|
||||
) -> tuple[str, ...]:
|
||||
"""Return the checkpoint namespace the caller is running under.
|
||||
|
||||
`stream_v2` uses this to scope its native projections
|
||||
(`ValuesTransformer`, `MessagesTransformer`) to events emitted at
|
||||
the run's own level. A root call resolves to `()`; a call made
|
||||
from inside a node carries the outer graph's task namespace so the
|
||||
projection still matches its own root-level events.
|
||||
"""
|
||||
merged = ensure_config(graph_config, call_config)
|
||||
ns = merged.get(CONF, {}).get(CONFIG_KEY_CHECKPOINT_NS)
|
||||
if not ns:
|
||||
return ()
|
||||
return tuple(ns.split(NS_SEP))
|
||||
|
||||
|
||||
def _build_server_info(
|
||||
config: RunnableConfig, parent_runtime: Runtime[Any]
|
||||
) -> ServerInfo | None:
|
||||
"""Build ServerInfo from config metadata and configurable.
|
||||
"""Build ServerInfo from config configurable.
|
||||
|
||||
The server puts assistant_id/graph_id in config metadata and the
|
||||
The server puts assistant_id/graph_id in config configurable and the
|
||||
authenticated user dict in configurable["langgraph_auth_user"].
|
||||
"""
|
||||
metadata = config.get("metadata") or {}
|
||||
configurable = config.get(CONF) or {}
|
||||
assistant_id = metadata.get("assistant_id")
|
||||
graph_id = metadata.get("graph_id")
|
||||
assistant_id = configurable.get("assistant_id")
|
||||
graph_id = configurable.get("graph_id")
|
||||
|
||||
# Read authenticated user from configurable (set by LangGraph Server).
|
||||
# We prefer isinstance(BaseUser) but fall back to hasattr("identity")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field, replace
|
||||
from typing import Any, Generic, cast
|
||||
|
||||
@@ -15,6 +16,7 @@ from langgraph.typing import ContextT
|
||||
__all__ = (
|
||||
"BaseUser",
|
||||
"ExecutionInfo",
|
||||
"RunControl",
|
||||
"Runtime",
|
||||
"ServerInfo",
|
||||
"get_runtime",
|
||||
@@ -74,16 +76,49 @@ class ServerInfo:
|
||||
"""
|
||||
|
||||
|
||||
class RunControl:
|
||||
"""Run-scoped control surface for cooperative draining.
|
||||
|
||||
Intended for a single graph run. Create a fresh `RunControl` per run;
|
||||
reusing a control after `request_drain()` leaves it drained.
|
||||
|
||||
Safe to call from any thread: the drain request is represented by a
|
||||
single attribute write, so no lock is needed for this signal.
|
||||
If more mutable state is added here, add synchronization.
|
||||
"""
|
||||
|
||||
__slots__ = ("_drain_reason",)
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._drain_reason: str | None = None
|
||||
|
||||
def request_drain(self, reason: str = "shutdown") -> None:
|
||||
self._drain_reason = reason
|
||||
|
||||
@property
|
||||
def drain_requested(self) -> bool:
|
||||
return self._drain_reason is not None
|
||||
|
||||
@property
|
||||
def drain_reason(self) -> str | None:
|
||||
return self._drain_reason
|
||||
|
||||
|
||||
def _no_op_stream_writer(_: Any) -> None: ...
|
||||
|
||||
|
||||
def _no_op_heartbeat() -> None: ...
|
||||
|
||||
|
||||
class _RuntimeOverrides(TypedDict, Generic[ContextT], total=False):
|
||||
context: ContextT
|
||||
store: BaseStore | None
|
||||
stream_writer: StreamWriter
|
||||
heartbeat: Callable[[], None]
|
||||
previous: Any
|
||||
execution_info: ExecutionInfo
|
||||
server_info: ServerInfo | None
|
||||
control: RunControl | None
|
||||
|
||||
|
||||
@dataclass(**_DC_KWARGS)
|
||||
@@ -162,7 +197,7 @@ class Runtime(Generic[ContextT]):
|
||||
|
||||
context: ContextT = field(default=None) # type: ignore[assignment]
|
||||
"""Static context for the graph run, like `user_id`, `db_conn`, etc.
|
||||
|
||||
|
||||
Can also be thought of as 'run dependencies'."""
|
||||
|
||||
store: BaseStore | None = field(default=None)
|
||||
@@ -171,9 +206,19 @@ class Runtime(Generic[ContextT]):
|
||||
stream_writer: StreamWriter = field(default=_no_op_stream_writer)
|
||||
"""Function that writes to the custom stream."""
|
||||
|
||||
heartbeat: Callable[[], None] = field(default=_no_op_heartbeat)
|
||||
"""Record progress for the current node's `idle_timeout`.
|
||||
|
||||
Call this from inside long-running work that does not naturally emit
|
||||
writes, stream chunks, child tasks, or LangChain callback events, to
|
||||
prevent the node from being treated as idle. It is also the only
|
||||
progress signal honored under `TimeoutPolicy(refresh_on="heartbeat")`.
|
||||
Outside an idle-timed attempt this is a no-op.
|
||||
"""
|
||||
|
||||
previous: Any = field(default=None)
|
||||
"""The previous return value for the given thread.
|
||||
|
||||
|
||||
Only available with the functional API when a checkpointer is provided.
|
||||
"""
|
||||
|
||||
@@ -185,6 +230,13 @@ class Runtime(Generic[ContextT]):
|
||||
server_info: ServerInfo | None = field(default=None)
|
||||
"""Metadata injected by LangGraph Server. None when running open-source LangGraph without LangSmith deployments."""
|
||||
|
||||
control: RunControl | None = field(default=None)
|
||||
"""Run-scoped control plane for cooperative draining.
|
||||
|
||||
Populated automatically during graph runs. None outside an active
|
||||
graph runtime.
|
||||
"""
|
||||
|
||||
def merge(self, other: Runtime[ContextT]) -> Runtime[ContextT]:
|
||||
"""Merge two runtimes together.
|
||||
|
||||
@@ -196,9 +248,13 @@ class Runtime(Generic[ContextT]):
|
||||
stream_writer=other.stream_writer
|
||||
if other.stream_writer is not _no_op_stream_writer
|
||||
else self.stream_writer,
|
||||
heartbeat=other.heartbeat
|
||||
if other.heartbeat is not _no_op_heartbeat
|
||||
else self.heartbeat,
|
||||
previous=self.previous if other.previous is None else other.previous,
|
||||
execution_info=other.execution_info or self.execution_info,
|
||||
server_info=other.server_info or self.server_info,
|
||||
control=other.control or self.control,
|
||||
)
|
||||
|
||||
def override(
|
||||
@@ -217,13 +273,23 @@ class Runtime(Generic[ContextT]):
|
||||
execution_info=self.execution_info.patch(**overrides),
|
||||
)
|
||||
|
||||
@property
|
||||
def drain_requested(self) -> bool:
|
||||
return self.control.drain_requested if self.control is not None else False
|
||||
|
||||
@property
|
||||
def drain_reason(self) -> str | None:
|
||||
return self.control.drain_reason if self.control is not None else None
|
||||
|
||||
|
||||
DEFAULT_RUNTIME = Runtime(
|
||||
context=None,
|
||||
store=None,
|
||||
stream_writer=_no_op_stream_writer,
|
||||
heartbeat=_no_op_heartbeat,
|
||||
previous=None,
|
||||
execution_info=None,
|
||||
control=None,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Streaming infrastructure for LangGraph.
|
||||
|
||||
Compile a graph with `transformers=[...]` and call `graph.stream_v2()` /
|
||||
`graph.astream_v2()` to drive a transformer pipeline that projects the
|
||||
graph's raw events into ergonomic per-channel streams.
|
||||
"""
|
||||
|
||||
from langgraph.stream._types import ProtocolEvent, StreamTransformer
|
||||
from langgraph.stream.run_stream import (
|
||||
AsyncGraphRunStream,
|
||||
AsyncSubgraphRunStream,
|
||||
GraphRunStream,
|
||||
SubgraphRunStream,
|
||||
)
|
||||
from langgraph.stream.stream_channel import StreamChannel
|
||||
from langgraph.stream.transformers import (
|
||||
CheckpointsTransformer,
|
||||
CustomTransformer,
|
||||
DebugTransformer,
|
||||
LifecyclePayload,
|
||||
LifecycleTransformer,
|
||||
SubgraphStatus,
|
||||
SubgraphTransformer,
|
||||
TasksTransformer,
|
||||
UpdatesTransformer,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AsyncGraphRunStream",
|
||||
"AsyncSubgraphRunStream",
|
||||
"CheckpointsTransformer",
|
||||
"CustomTransformer",
|
||||
"DebugTransformer",
|
||||
"GraphRunStream",
|
||||
"LifecyclePayload",
|
||||
"LifecycleTransformer",
|
||||
"ProtocolEvent",
|
||||
"StreamChannel",
|
||||
"StreamTransformer",
|
||||
"SubgraphRunStream",
|
||||
"SubgraphStatus",
|
||||
"SubgraphTransformer",
|
||||
"TasksTransformer",
|
||||
"UpdatesTransformer",
|
||||
]
|
||||
@@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any, cast
|
||||
|
||||
from langgraph.stream._types import ProtocolEvent, _ProtocolEventParams
|
||||
from langgraph.types import StreamPart
|
||||
|
||||
|
||||
def convert_to_protocol_event(part: StreamPart) -> ProtocolEvent:
|
||||
"""Convert a v2 StreamPart to a ProtocolEvent.
|
||||
|
||||
Args:
|
||||
part: A stream part with keys `type`, `ns`, `data`, and
|
||||
optionally `interrupts` (present on values events).
|
||||
|
||||
Returns:
|
||||
The equivalent ProtocolEvent.
|
||||
"""
|
||||
part_dict = cast(dict[str, Any], part)
|
||||
params: _ProtocolEventParams = {
|
||||
"namespace": list(part_dict["ns"]),
|
||||
"timestamp": int(time.time() * 1000),
|
||||
"data": part_dict["data"],
|
||||
}
|
||||
if "interrupts" in part_dict:
|
||||
params["interrupts"] = part_dict["interrupts"]
|
||||
return {
|
||||
"type": "event",
|
||||
"method": part_dict["type"],
|
||||
"params": params,
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from langgraph.stream._types import (
|
||||
ProtocolEvent,
|
||||
StreamTransformer,
|
||||
transformer_requires_async,
|
||||
)
|
||||
from langgraph.stream.stream_channel import StreamChannel
|
||||
|
||||
TransformerFactory = Callable[["tuple[str, ...]"], StreamTransformer]
|
||||
"""Factory that builds a scoped transformer for a mux.
|
||||
|
||||
Called once per `StreamMux` with the mux's scope (typically `()` for
|
||||
the root). Standard transformer classes accept a single positional
|
||||
scope argument, so the class itself is a valid factory. User
|
||||
transformers can close over their config:
|
||||
`lambda scope: MyTransformer(scope, foo=...)`.
|
||||
"""
|
||||
|
||||
|
||||
class StreamMux:
|
||||
"""Central event dispatcher for the streaming infrastructure.
|
||||
|
||||
Owns the main event log and routes events through a transformer
|
||||
pipeline. StreamChannels with a name discovered in transformer
|
||||
projections are auto-wired so that every `push()` also injects a
|
||||
`ProtocolEvent` into the main log. StreamChannels without a name
|
||||
are local-only.
|
||||
|
||||
Pass `is_async=True` when the mux will be consumed via async
|
||||
iteration (`handler.astream()`). All StreamChannel instances
|
||||
discovered during registration are automatically bound to the
|
||||
matching mode.
|
||||
|
||||
Attributes:
|
||||
extensions: Merged projection dict across all registered
|
||||
transformers. Treat as read-only — mutations won't be
|
||||
reflected back in individual transformers' state.
|
||||
native_keys: Projection keys contributed by transformers with
|
||||
`_native = True`.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
transformers: list[StreamTransformer] | None = None,
|
||||
*,
|
||||
is_async: bool = False,
|
||||
factories: list[TransformerFactory] | None = None,
|
||||
scope: tuple[str, ...] = (),
|
||||
_assign_seq: bool = True,
|
||||
) -> None:
|
||||
"""Initialize the mux and register transformers in order.
|
||||
|
||||
Callers pass either `transformers` (pre-built instances) or
|
||||
`factories` (callables producing fresh instances per mux). Each
|
||||
transformer's `init()` is called, projections are merged into
|
||||
`extensions`, `_native` keys are recorded in `native_keys`, and
|
||||
any StreamChannel instances are bound and (if named) wired.
|
||||
|
||||
Args:
|
||||
transformers: Already-built transformer instances. Registered
|
||||
only on this mux — they are NOT cloned into child
|
||||
mini-muxes built by `_make_child`. Use `factories` for
|
||||
transformers that should propagate to nested scopes.
|
||||
is_async: True for async dispatch (`apush` / `aclose` /
|
||||
`afail`), False for the sync path.
|
||||
factories: One-argument callables `(scope) -> StreamTransformer`.
|
||||
Called once with this mux's `scope` here, and cloned
|
||||
again per child scope by `_make_child` so each
|
||||
sub-mux gets fresh instances.
|
||||
scope: The namespace the mux operates within. The root mux
|
||||
is `()`.
|
||||
_assign_seq: Internal flag for child muxes. Root muxes assign
|
||||
monotonic `seq` numbers when appending to their main event
|
||||
log; child muxes share forwarded event objects and must not
|
||||
mutate their envelopes.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If any transformer requires an async run but
|
||||
the mux is in sync mode.
|
||||
TypeError: If a transformer's `init()` doesn't return a dict.
|
||||
ValueError: If transformers' projection keys collide.
|
||||
"""
|
||||
self.is_async = is_async
|
||||
self.scope: tuple[str, ...] = scope
|
||||
self._assign_seq = _assign_seq
|
||||
self._events: StreamChannel[ProtocolEvent] = StreamChannel()
|
||||
self._events._bind(is_async=is_async)
|
||||
self._events._bind_mux(self)
|
||||
self._transformers: list[StreamTransformer] = []
|
||||
self._channels: list[StreamChannel[Any]] = []
|
||||
self._seq = 0
|
||||
self._push_seq = 0
|
||||
|
||||
self.extensions: dict[str, Any] = {}
|
||||
self.native_keys: set[str] = set()
|
||||
self._projection_owners: dict[str, str] = {}
|
||||
self._transformer_by_key: dict[str, StreamTransformer] = {}
|
||||
|
||||
# Stored only when constructed from factories — used by
|
||||
# `_make_child` to clone the transformer pipeline at a deeper
|
||||
# scope. Pre-built transformers can't be cloned, so a mux
|
||||
# built with `transformers=` rejects child construction.
|
||||
self._factories: list[TransformerFactory] | None = (
|
||||
list(factories) if factories is not None else None
|
||||
)
|
||||
self._pump_fn: Callable[[], bool] | None = None
|
||||
self._apump_fn: Callable[[], Awaitable[bool]] | None = None
|
||||
|
||||
# Factories run first (they propagate to child mini-muxes
|
||||
# via `_make_child`), then any pre-built `transformers=`
|
||||
# instances are registered as root-only — they aren't cloned
|
||||
# for child scopes.
|
||||
if factories is not None:
|
||||
for factory in factories:
|
||||
self._register(factory(scope))
|
||||
for transformer in transformers or ():
|
||||
self._register(transformer)
|
||||
|
||||
def transformer_by_key(self, key: str) -> StreamTransformer | None:
|
||||
"""Return the transformer that contributed `key` to the projection."""
|
||||
return self._transformer_by_key.get(key)
|
||||
|
||||
def _next_push_seq(self) -> int:
|
||||
self._push_seq += 1
|
||||
return self._push_seq
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Pump wiring + mini-mux nesting
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def bind_pump(self, fn: Callable[[], bool]) -> None:
|
||||
"""Wire the sync pull callback onto every projection in this mux.
|
||||
|
||||
Records the pump on the mux so child mini-muxes built by
|
||||
`_make_child` can inherit it. Propagates to:
|
||||
- the main event log (`self._events`)
|
||||
- every projection StreamChannel in `extensions`
|
||||
- any registered transformer that exposes `_bind_pump` (e.g.
|
||||
`MessagesTransformer` so `ChatModelStream` instances drive the
|
||||
shared pump from their cursors)
|
||||
"""
|
||||
self._pump_fn = fn
|
||||
self._events._request_more = fn
|
||||
for ch in self._channels:
|
||||
ch._request_more = fn
|
||||
for transformer in self._transformers:
|
||||
bind = getattr(transformer, "_bind_pump", None)
|
||||
if bind is not None:
|
||||
bind(fn)
|
||||
|
||||
def bind_apump(self, fn: Callable[[], Awaitable[bool]]) -> None:
|
||||
"""Async counterpart to `bind_pump`."""
|
||||
self._apump_fn = fn
|
||||
self._events._arequest_more = fn
|
||||
for ch in self._channels:
|
||||
ch._arequest_more = fn
|
||||
for transformer in self._transformers:
|
||||
abind = getattr(transformer, "_bind_apump", None)
|
||||
if abind is not None:
|
||||
abind(fn)
|
||||
|
||||
def _make_child(self, scope: tuple[str, ...]) -> StreamMux:
|
||||
"""Build a mini-mux with the same factories scoped to `scope`.
|
||||
|
||||
Used by `SubgraphTransformer` to attach a fresh transformer
|
||||
pipeline to each discovered subgraph handle. The child mux
|
||||
inherits the current pump bindings (so cursors on its
|
||||
projection logs drive the root pump), carries the same factory
|
||||
list forward to any grandchild subgraphs, and does not assign
|
||||
`seq` numbers so forwarded events can be shared without
|
||||
mutating their envelope.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the mux was not constructed with
|
||||
`factories=`. Mini-muxes require factories so each scope
|
||||
gets its own fresh transformer instances.
|
||||
"""
|
||||
if self._factories is None:
|
||||
raise RuntimeError(
|
||||
"StreamMux._make_child requires the mux to be constructed "
|
||||
"with `factories=`; pre-built transformers can't be "
|
||||
"cloned to a new scope."
|
||||
)
|
||||
child = StreamMux(
|
||||
factories=self._factories,
|
||||
is_async=self.is_async,
|
||||
scope=scope,
|
||||
_assign_seq=False,
|
||||
)
|
||||
if self._pump_fn is not None:
|
||||
child.bind_pump(self._pump_fn)
|
||||
if self._apump_fn is not None:
|
||||
child.bind_apump(self._apump_fn)
|
||||
return child
|
||||
|
||||
def _register(self, transformer: StreamTransformer) -> None:
|
||||
"""Register a single transformer.
|
||||
|
||||
Calls `transformer.init()`, stores the transformer for event
|
||||
processing, binds any StreamChannel instances in the projection,
|
||||
and merges the projection into `extensions`.
|
||||
"""
|
||||
if transformer_requires_async(transformer) and not self.is_async:
|
||||
raise RuntimeError(
|
||||
f"{type(transformer).__name__} requires an async run — "
|
||||
"it overrides aprocess/afinalize/afail or sets "
|
||||
"requires_async=True. Use astream(), not stream()."
|
||||
)
|
||||
projection = transformer.init()
|
||||
if not isinstance(projection, dict):
|
||||
raise TypeError(
|
||||
f"StreamTransformer.init() must return a dict, "
|
||||
f"got {type(projection).__name__}"
|
||||
)
|
||||
conflicts = set(projection) & set(self.extensions)
|
||||
if conflicts:
|
||||
attributions = ", ".join(
|
||||
f"{key!r} (owned by {self._projection_owners[key]})"
|
||||
for key in sorted(conflicts)
|
||||
)
|
||||
raise ValueError(
|
||||
f"Transformer {type(transformer).__name__} returned "
|
||||
f"projection keys that conflict with already-registered "
|
||||
f"keys: {attributions}"
|
||||
)
|
||||
is_native = bool(getattr(transformer, "_native", False))
|
||||
self._transformers.append(transformer)
|
||||
self._bind_and_wire(projection, native=is_native)
|
||||
self.extensions.update(projection)
|
||||
owner_name = type(transformer).__name__
|
||||
for key in projection:
|
||||
self._projection_owners[key] = owner_name
|
||||
self._transformer_by_key[key] = transformer
|
||||
if is_native:
|
||||
self.native_keys.update(projection.keys())
|
||||
transformer._on_register(self)
|
||||
|
||||
def push(self, event: ProtocolEvent) -> None:
|
||||
"""Route an event through all transformers, then append to the main log.
|
||||
|
||||
Each transformer's `process()` is called in registration order.
|
||||
If any transformer returns False, the event is suppressed from
|
||||
the main log, but transformers that already saw it keep their
|
||||
side effects.
|
||||
|
||||
On the root mux, `seq` is assigned right before an event enters
|
||||
the main log, not before the transformer pipeline runs. This
|
||||
ensures that events auto-forwarded from StreamChannels during
|
||||
`process()` get earlier seq numbers than the original event,
|
||||
preserving monotonic ordering in the root log. Child muxes do
|
||||
not assign `seq`, so subgraph forwarding can share event objects
|
||||
without mutating their envelopes.
|
||||
|
||||
Args:
|
||||
event: The protocol event to dispatch.
|
||||
"""
|
||||
keep = True
|
||||
for transformer in self._transformers:
|
||||
if not transformer.process(event):
|
||||
keep = False
|
||||
if keep:
|
||||
if self._assign_seq:
|
||||
self._seq += 1
|
||||
event["seq"] = self._seq
|
||||
self._events.push(event)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Finalize all transformers, close all projections and the main log.
|
||||
|
||||
StreamChannels discovered in transformer projections are
|
||||
auto-closed after `finalize()` runs — transformers don't need
|
||||
to close them manually. If any transformer's `finalize()` raises,
|
||||
the remaining transformers, projections, and the main log are
|
||||
still closed; the first error is re-raised after cleanup
|
||||
completes.
|
||||
|
||||
Raises:
|
||||
BaseException: The first error raised by a transformer's
|
||||
`finalize()`, re-raised after cleanup finishes.
|
||||
"""
|
||||
first_error: BaseException | None = None
|
||||
for transformer in self._transformers:
|
||||
try:
|
||||
transformer.finalize()
|
||||
except BaseException as e:
|
||||
if first_error is None:
|
||||
first_error = e
|
||||
for ch in self._channels:
|
||||
if not ch._closed:
|
||||
ch.close()
|
||||
self._events.close()
|
||||
if first_error is not None:
|
||||
raise first_error
|
||||
|
||||
def fail(self, err: BaseException) -> None:
|
||||
"""Fail all transformers, projections, and the main log.
|
||||
|
||||
StreamChannels discovered in transformer projections are
|
||||
auto-failed — transformers don't need to fail them manually.
|
||||
If any transformer's `fail()` raises, the remaining
|
||||
transformers, projections, and the main log are still failed.
|
||||
|
||||
Args:
|
||||
err: The exception that ended the run.
|
||||
"""
|
||||
for transformer in self._transformers:
|
||||
try:
|
||||
transformer.fail(err)
|
||||
except BaseException:
|
||||
pass
|
||||
for ch in self._channels:
|
||||
if not ch._closed:
|
||||
ch.fail(err)
|
||||
self._events.fail(err)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Async dispatch
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def apush(self, event: ProtocolEvent) -> None:
|
||||
"""Dispatch an event on the async lane.
|
||||
|
||||
Awaits each transformer's `aprocess` in registration order
|
||||
before appending to the main log. A slow `aprocess` serializes
|
||||
the pipeline by design — that's the guarantee that lets a later
|
||||
transformer (or a synchronous consumer) see the result of the
|
||||
async work. For decoupled work, use `schedule()` from inside
|
||||
`process` / `aprocess` instead.
|
||||
|
||||
The main log append is a non-blocking `push` — matching v1's
|
||||
`put_nowait` shape. The root mux assigns `seq`; child muxes do
|
||||
not, so forwarded subgraph events can be shared without copying.
|
||||
Memory is bounded by caller pace via the caller-driven pump; see
|
||||
`StreamChannel` for the full tradeoff story.
|
||||
|
||||
Args:
|
||||
event: The protocol event to dispatch.
|
||||
"""
|
||||
keep = True
|
||||
for transformer in self._transformers:
|
||||
if not await transformer.aprocess(event):
|
||||
keep = False
|
||||
if keep:
|
||||
if self._assign_seq:
|
||||
self._seq += 1
|
||||
event["seq"] = self._seq
|
||||
self._events.push(event)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
"""Finalize on the async lane.
|
||||
|
||||
Awaits every task started via `StreamTransformer.schedule()`
|
||||
across all transformers, then calls `afinalize()` on each,
|
||||
then auto-closes channels and the main event log.
|
||||
|
||||
If any scheduled task raised under `on_error="raise"`, or any
|
||||
transformer's `afinalize` raises, the exception propagates.
|
||||
The caller (the pump) handles it by routing into `afail`.
|
||||
|
||||
Raises:
|
||||
BaseException: The first scheduled-task or `afinalize`
|
||||
error, re-raised after cleanup.
|
||||
"""
|
||||
pending = self._collect_scheduled_tasks()
|
||||
if pending:
|
||||
results = await asyncio.gather(*pending, return_exceptions=True)
|
||||
first_err = next(
|
||||
(
|
||||
r
|
||||
for r in results
|
||||
if isinstance(r, BaseException)
|
||||
and not isinstance(r, asyncio.CancelledError)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if first_err is not None:
|
||||
raise first_err
|
||||
|
||||
first_error: BaseException | None = None
|
||||
for transformer in self._transformers:
|
||||
try:
|
||||
await transformer.afinalize()
|
||||
except BaseException as e:
|
||||
if first_error is None:
|
||||
first_error = e
|
||||
for ch in self._channels:
|
||||
if not ch._closed:
|
||||
ch.close()
|
||||
self._events.close()
|
||||
if first_error is not None:
|
||||
raise first_error
|
||||
|
||||
async def afail(self, err: BaseException) -> None:
|
||||
"""Fail on the async lane.
|
||||
|
||||
Cancels every scheduled task across all transformers, awaits
|
||||
them to completion, then runs each transformer's `afail` hook
|
||||
and auto-fails channels and the main event log.
|
||||
|
||||
Args:
|
||||
err: The exception that ended the run.
|
||||
"""
|
||||
pending = self._collect_scheduled_tasks()
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
if pending:
|
||||
await asyncio.gather(*pending, return_exceptions=True)
|
||||
|
||||
for transformer in self._transformers:
|
||||
try:
|
||||
await transformer.afail(err)
|
||||
except BaseException:
|
||||
pass
|
||||
for ch in self._channels:
|
||||
if not ch._closed:
|
||||
ch.fail(err)
|
||||
if not self._events._closed:
|
||||
self._events.fail(err)
|
||||
|
||||
def _collect_scheduled_tasks(self) -> list[asyncio.Task[Any]]:
|
||||
"""Return a snapshot of in-flight tasks scheduled via transformers."""
|
||||
return [
|
||||
task
|
||||
for transformer in self._transformers
|
||||
for task in getattr(transformer, "_stream_scheduled_tasks", ())
|
||||
if not task.done()
|
||||
]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Binding and StreamChannel auto-wiring
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _bind_and_wire(
|
||||
self, projection: dict[str, Any], *, native: bool = False
|
||||
) -> None:
|
||||
"""Bind and optionally wire StreamChannel instances in a projection.
|
||||
|
||||
All StreamChannels are bound and tracked. Channels with a name
|
||||
are additionally wired for protocol auto-forwarding.
|
||||
|
||||
Args:
|
||||
projection: The projection dict returned by a transformer's
|
||||
`init()`.
|
||||
native: True when the owning transformer is `_native`.
|
||||
Named channels owned by a native transformer use the
|
||||
channel name directly as the protocol method;
|
||||
user-defined channels are prefixed with `custom:`.
|
||||
"""
|
||||
for value in projection.values():
|
||||
if isinstance(value, StreamChannel):
|
||||
value._bind(is_async=self.is_async)
|
||||
value._bind_mux(self)
|
||||
self._channels.append(value)
|
||||
if value.name is not None:
|
||||
method = value.name if native else f"custom:{value.name}"
|
||||
|
||||
def _make_forward(method_name: str) -> Callable[[Any], None]:
|
||||
def _forward(item: Any) -> None:
|
||||
self._forward(method_name, item)
|
||||
|
||||
return _forward
|
||||
|
||||
value._wire(_make_forward(method))
|
||||
|
||||
def _forward(self, method: str, item: Any) -> None:
|
||||
"""Inject a ProtocolEvent for a StreamChannel push.
|
||||
|
||||
Forwarded events bypass the transformer pipeline to avoid
|
||||
infinite recursion (a transformer that pushes to a channel
|
||||
during `process()` would re-trigger itself). These events are
|
||||
visible in this mux's main event log but are not passed through
|
||||
transformers' `process()` methods. Only the root mux assigns
|
||||
`seq` to forwarded channel events.
|
||||
|
||||
Args:
|
||||
method: The full protocol method (already with or without
|
||||
the `custom:` prefix; resolved by `_bind_and_wire`).
|
||||
item: The payload pushed onto the channel.
|
||||
"""
|
||||
event: ProtocolEvent = {
|
||||
"type": "event",
|
||||
"method": method,
|
||||
"params": {
|
||||
"namespace": [],
|
||||
"timestamp": int(time.time() * 1000),
|
||||
"data": item,
|
||||
},
|
||||
}
|
||||
if self._assign_seq:
|
||||
self._seq += 1
|
||||
event["seq"] = self._seq
|
||||
self._events.push(event)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user