mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-10-03 23:15:10 +02:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9d5b0bc15 | ||
|
|
998aa88ea1 | ||
|
|
627cf19464 | ||
|
|
8e89c8b155 | ||
|
|
98fd216ce7 | ||
|
|
247ef5edf7 | ||
|
|
648f03d715 |
@@ -13,6 +13,7 @@ permissions:
|
||||
|
||||
env:
|
||||
PYTHON_VERSION: "3.11"
|
||||
GO_VERSION: "1.23"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -34,6 +35,70 @@ 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
|
||||
|
||||
- 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="langgraph_cli/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
|
||||
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,
|
||||
@@ -46,6 +111,7 @@ 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 }}
|
||||
|
||||
|
||||
@@ -339,6 +339,7 @@ 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
+7
-7
@@ -231,7 +231,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langchain-core"
|
||||
version = "1.2.28"
|
||||
version = "1.2.22"
|
||||
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/f8/a4/317a1a3ac1df33a64adb3670bf88bbe3b3d5baa274db6863a979db472897/langchain_core-1.2.28.tar.gz", hash = "sha256:271a3d8bd618f795fdeba112b0753980457fc90537c46a0c11998516a74dc2cb", size = 846119, upload-time = "2026-04-08T18:19:34.867Z" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -263,7 +263,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-checkpoint-conformance"
|
||||
version = "0.0.2"
|
||||
version = "0.0.1"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "langgraph-checkpoint" },
|
||||
@@ -623,7 +623,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.3"
|
||||
version = "9.0.2"
|
||||
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/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Generated
+6
-6
@@ -240,7 +240,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langchain-core"
|
||||
version = "1.2.28"
|
||||
version = "1.2.22"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jsonpatch" },
|
||||
@@ -252,9 +252,9 @@ dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "uuid-utils" },
|
||||
]
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -950,7 +950,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.3"
|
||||
version = "9.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
@@ -961,9 +961,9 @@ dependencies = [
|
||||
{ name = "pygments" },
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Generated
+6
-6
@@ -249,7 +249,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langchain-core"
|
||||
version = "1.2.28"
|
||||
version = "1.2.22"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jsonpatch" },
|
||||
@@ -261,9 +261,9 @@ dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "uuid-utils" },
|
||||
]
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -862,7 +862,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.3"
|
||||
version = "9.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
@@ -873,9 +873,9 @@ dependencies = [
|
||||
{ name = "pygments" },
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Generated
+6
-6
@@ -267,7 +267,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langchain-core"
|
||||
version = "1.2.28"
|
||||
version = "1.2.23"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jsonpatch" },
|
||||
@@ -279,9 +279,9 @@ dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "uuid-utils" },
|
||||
]
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1117,7 +1117,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.3"
|
||||
version = "9.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
@@ -1128,9 +1128,9 @@ dependencies = [
|
||||
{ name = "pygments" },
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
.langgraph_api/
|
||||
# Go cross-compiled binaries (built at release time, bundled into wheels)
|
||||
langgraph_cli/bin/
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
# 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
|
||||
+84
-3
@@ -1,15 +1,21 @@
|
||||
.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"
|
||||
test:
|
||||
GO_FILES=$(shell find cmd internal -type f -name '*.go' 2>/dev/null)
|
||||
test: test-go
|
||||
uv run pytest $(TEST)
|
||||
test-integration:
|
||||
uv run pytest tests/integration_tests
|
||||
|
||||
test-go:
|
||||
[ ! -f go.mod ] || go test ./...
|
||||
|
||||
######################
|
||||
# LINTING AND FORMATTING
|
||||
######################
|
||||
@@ -23,19 +29,94 @@ 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 lint_diff lint_package lint_tests: lint-go
|
||||
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 format_diff: format-go
|
||||
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
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
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))
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module github.com/langchain-ai/langgraph/libs/cli
|
||||
|
||||
go 1.23.0
|
||||
@@ -0,0 +1,56 @@
|
||||
"""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:
|
||||
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 = Path("langgraph_cli/bin")
|
||||
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}"
|
||||
@@ -0,0 +1,672 @@
|
||||
// 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) {
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not read config file: %w", err)
|
||||
}
|
||||
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, fmt.Errorf("Invalid JSON in %s: %s", configPath, err.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: %s", err.Error())
|
||||
}
|
||||
|
||||
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]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,568 @@
|
||||
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
@@ -0,0 +1,804 @@
|
||||
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")
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
// 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()
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// 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
@@ -0,0 +1,205 @@
|
||||
package root
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"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 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package version
|
||||
|
||||
var (
|
||||
Version = "dev"
|
||||
Commit = "unknown"
|
||||
Date = "unknown"
|
||||
)
|
||||
@@ -1086,6 +1086,11 @@
|
||||
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"
|
||||
@@ -1777,6 +1782,13 @@ 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"
|
||||
@@ -3676,12 +3688,16 @@ keyv@^4.5.4:
|
||||
json-buffer "3.0.1"
|
||||
|
||||
"langsmith@>=0.5.0 <1.0.0":
|
||||
version "0.5.18"
|
||||
resolved "https://registry.yarnpkg.com/langsmith/-/langsmith-0.5.18.tgz#c691ad23614f0b46eaf07d982e0ac988e1f43880"
|
||||
integrity sha512-3zuZUWffTHQ+73EAwnodADtf534VNEZUpXr9jC12qyG8/IQuJET7PRsCpTb9wX2lmBspakwLUpqpj3tNm/0bVA==
|
||||
version "0.5.4"
|
||||
resolved "https://registry.yarnpkg.com/langsmith/-/langsmith-0.5.4.tgz#f75b82b08e30db72a7d1d595b341e9666bd525e5"
|
||||
integrity sha512-qYkNIoKpf0ZYt+cYzrDV+XI3FCexApmZmp8EMs3eDTMv0OvrHMLoxJ9IpkeoXJSX24+GPk0/jXjKx2hWerpy9w==
|
||||
dependencies:
|
||||
p-queue "6.6.2"
|
||||
uuid "10.0.0"
|
||||
"@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"
|
||||
|
||||
leven@^3.1.0:
|
||||
version "3.1.0"
|
||||
@@ -3991,7 +4007,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==
|
||||
@@ -4287,7 +4303,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.7.2, semver@^7.7.3:
|
||||
semver@^7.5.3, semver@^7.5.4, semver@^7.6.3, 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==
|
||||
@@ -4395,6 +4411,11 @@ 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"
|
||||
@@ -4849,7 +4870,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,6 +217,11 @@
|
||||
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"
|
||||
@@ -338,6 +343,13 @@ 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"
|
||||
@@ -496,11 +508,38 @@ 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"
|
||||
@@ -1020,6 +1059,11 @@ 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"
|
||||
@@ -1328,12 +1372,16 @@ keyv@^4.5.4:
|
||||
json-buffer "3.0.1"
|
||||
|
||||
"langsmith@>=0.5.0 <1.0.0":
|
||||
version "0.5.18"
|
||||
resolved "https://registry.yarnpkg.com/langsmith/-/langsmith-0.5.18.tgz#c691ad23614f0b46eaf07d982e0ac988e1f43880"
|
||||
integrity sha512-3zuZUWffTHQ+73EAwnodADtf534VNEZUpXr9jC12qyG8/IQuJET7PRsCpTb9wX2lmBspakwLUpqpj3tNm/0bVA==
|
||||
version "0.5.4"
|
||||
resolved "https://registry.yarnpkg.com/langsmith/-/langsmith-0.5.4.tgz#f75b82b08e30db72a7d1d595b341e9666bd525e5"
|
||||
integrity sha512-qYkNIoKpf0ZYt+cYzrDV+XI3FCexApmZmp8EMs3eDTMv0OvrHMLoxJ9IpkeoXJSX24+GPk0/jXjKx2hWerpy9w==
|
||||
dependencies:
|
||||
p-queue "6.6.2"
|
||||
uuid "10.0.0"
|
||||
"@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"
|
||||
|
||||
levn@^0.4.1:
|
||||
version "0.4.1"
|
||||
@@ -1480,7 +1528,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==
|
||||
@@ -1642,6 +1690,11 @@ 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"
|
||||
@@ -1730,6 +1783,11 @@ 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"
|
||||
@@ -1780,6 +1838,13 @@ 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"
|
||||
@@ -1901,7 +1966,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,4 +1,4 @@
|
||||
from .cli import cli
|
||||
from .entrypoint import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli()
|
||||
main()
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""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
|
||||
@@ -34,7 +34,7 @@ Slack = "https://www.langchain.com/join-community"
|
||||
Reddit = "https://www.reddit.com/r/LangChain/"
|
||||
|
||||
[project.scripts]
|
||||
langgraph = "langgraph_cli.cli:cli"
|
||||
langgraph = "langgraph_cli.entrypoint:main"
|
||||
|
||||
[dependency-groups]
|
||||
test = [
|
||||
@@ -61,6 +61,9 @@ 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"
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
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
|
||||
Generated
+3
-3
@@ -215,7 +215,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langchain-core"
|
||||
version = "1.2.28"
|
||||
version = "1.2.27"
|
||||
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/f8/a4/317a1a3ac1df33a64adb3670bf88bbe3b3d5baa274db6863a979db472897/langchain_core-1.2.28.tar.gz", hash = "sha256:271a3d8bd618f795fdeba112b0753980457fc90537c46a0c11998516a74dc2cb", size = 846119, upload-time = "2026-04-08T18:19:34.867Z" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Generated
+3
-3
@@ -191,7 +191,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langchain-core"
|
||||
version = "1.2.28"
|
||||
version = "1.2.27"
|
||||
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/f8/a4/317a1a3ac1df33a64adb3670bf88bbe3b3d5baa274db6863a979db472897/langchain_core-1.2.28.tar.gz", hash = "sha256:271a3d8bd618f795fdeba112b0753980457fc90537c46a0c11998516a74dc2cb", size = 846119, upload-time = "2026-04-08T18:19:34.867Z" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Generated
+73
-73
@@ -413,62 +413,62 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "46.0.7"
|
||||
version = "46.0.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a4/ba/04b1bd4218cbc58dc90ce967106d51582371b898690f3ae0402876cc4f34/cryptography-46.0.6.tar.gz", hash = "sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759", size = 750542, upload-time = "2026-03-25T23:34:53.396Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/0c/dca8abb64e7ca4f6b2978769f6fea5ad06686a190cec381f0a796fdcaaba/cryptography-46.0.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f", size = 3476879, upload-time = "2026-04-08T01:57:38.664Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/ea/075aac6a84b7c271578d81a2f9968acb6e273002408729f2ddff517fed4a/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15", size = 4219700, upload-time = "2026-04-08T01:57:40.625Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/7b/1c55db7242b5e5612b29fc7a630e91ee7a6e3c8e7bf5406d22e206875fbd/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455", size = 4385982, upload-time = "2026-04-08T01:57:42.725Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/da/9870eec4b69c63ef5925bf7d8342b7e13bc2ee3d47791461c4e49ca212f4/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65", size = 4219115, upload-time = "2026-04-08T01:57:44.939Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/72/05aa5832b82dd341969e9a734d1812a6aadb088d9eb6f0430fc337cc5a8f/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968", size = 4385479, upload-time = "2026-04-08T01:57:46.86Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/2a/1b016902351a523aa2bd446b50a5bc1175d7a7d1cf90fe2ef904f9b84ebc/cryptography-46.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4", size = 3412829, upload-time = "2026-04-08T01:57:48.874Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/23/9285e15e3bc57325b0a72e592921983a701efc1ee8f91c06c5f0235d86d9/cryptography-46.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8", size = 7176401, upload-time = "2026-03-25T23:33:22.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/f8/e61f8f13950ab6195b31913b42d39f0f9afc7d93f76710f299b5ec286ae6/cryptography-46.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30", size = 4275275, upload-time = "2026-03-25T23:33:23.844Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/69/732a736d12c2631e140be2348b4ad3d226302df63ef64d30dfdb8db7ad1c/cryptography-46.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a", size = 4425320, upload-time = "2026-03-25T23:33:25.703Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/12/123be7292674abf76b21ac1fc0e1af50661f0e5b8f0ec8285faac18eb99e/cryptography-46.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175", size = 4278082, upload-time = "2026-03-25T23:33:27.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/ba/d5e27f8d68c24951b0a484924a84c7cdaed7502bac9f18601cd357f8b1d2/cryptography-46.0.6-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463", size = 4926514, upload-time = "2026-03-25T23:33:29.206Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/71/1ea5a7352ae516d5512d17babe7e1b87d9db5150b21f794b1377eac1edc0/cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97", size = 4457766, upload-time = "2026-03-25T23:33:30.834Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/59/562be1e653accee4fdad92c7a2e88fced26b3fdfce144047519bbebc299e/cryptography-46.0.6-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c", size = 3986535, upload-time = "2026-03-25T23:33:33.02Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/8b/b1ebfeb788bf4624d36e45ed2662b8bd43a05ff62157093c1539c1288a18/cryptography-46.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507", size = 4277618, upload-time = "2026-03-25T23:33:34.567Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/52/a005f8eabdb28df57c20f84c44d397a755782d6ff6d455f05baa2785bd91/cryptography-46.0.6-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19", size = 4890802, upload-time = "2026-03-25T23:33:37.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/4d/8e7d7245c79c617d08724e2efa397737715ca0ec830ecb3c91e547302555/cryptography-46.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738", size = 4457425, upload-time = "2026-03-25T23:33:38.904Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/5c/f6c3596a1430cec6f949085f0e1a970638d76f81c3ea56d93d564d04c340/cryptography-46.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c", size = 4405530, upload-time = "2026-03-25T23:33:40.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/c9/9f9cea13ee2dbde070424e0c4f621c091a91ffcc504ffea5e74f0e1daeff/cryptography-46.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f", size = 4667896, upload-time = "2026-03-25T23:33:42.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/b5/1895bc0821226f129bc74d00eccfc6a5969e2028f8617c09790bf89c185e/cryptography-46.0.6-cp311-abi3-win32.whl", hash = "sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2", size = 3026348, upload-time = "2026-03-25T23:33:45.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/f8/c9bcbf0d3e6ad288b9d9aa0b1dee04b063d19e8c4f871855a03ab3a297ab/cryptography-46.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124", size = 3483896, upload-time = "2026-03-25T23:33:46.649Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/41/3a578f7fd5c70611c0aacba52cd13cb364a5dee895a5c1d467208a9380b0/cryptography-46.0.6-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275", size = 7117147, upload-time = "2026-03-25T23:33:48.249Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/87/887f35a6fca9dde90cad08e0de0c89263a8e59b2d2ff904fd9fcd8025b6f/cryptography-46.0.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4", size = 4266221, upload-time = "2026-03-25T23:33:49.874Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/a8/0a90c4f0b0871e0e3d1ed126aed101328a8a57fd9fd17f00fb67e82a51ca/cryptography-46.0.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b", size = 4408952, upload-time = "2026-03-25T23:33:52.128Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/0b/b239701eb946523e4e9f329336e4ff32b1247e109cbab32d1a7b61da8ed7/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707", size = 4270141, upload-time = "2026-03-25T23:33:54.11Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/a8/976acdd4f0f30df7b25605f4b9d3d89295351665c2091d18224f7ad5cdbf/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361", size = 4904178, upload-time = "2026-03-25T23:33:55.725Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/1b/bf0e01a88efd0e59679b69f42d4afd5bced8700bb5e80617b2d63a3741af/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b", size = 4441812, upload-time = "2026-03-25T23:33:57.364Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/8b/11df86de2ea389c65aa1806f331cae145f2ed18011f30234cc10ca253de8/cryptography-46.0.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca", size = 3963923, upload-time = "2026-03-25T23:33:59.361Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/e0/207fb177c3a9ef6a8108f234208c3e9e76a6aa8cf20d51932916bd43bda0/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013", size = 4269695, upload-time = "2026-03-25T23:34:00.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/5e/19f3260ed1e95bced52ace7501fabcd266df67077eeb382b79c81729d2d3/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4", size = 4869785, upload-time = "2026-03-25T23:34:02.796Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/38/cd7864d79aa1d92ef6f1a584281433419b955ad5a5ba8d1eb6c872165bcb/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a", size = 4441404, upload-time = "2026-03-25T23:34:04.35Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/0a/4fe7a8d25fed74419f91835cf5829ade6408fd1963c9eae9c4bce390ecbb/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d", size = 4397549, upload-time = "2026-03-25T23:34:06.342Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/a0/7d738944eac6513cd60a8da98b65951f4a3b279b93479a7e8926d9cd730b/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736", size = 4651874, upload-time = "2026-03-25T23:34:07.916Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/f1/c2326781ca05208845efca38bf714f76939ae446cd492d7613808badedf1/cryptography-46.0.6-cp314-cp314t-win32.whl", hash = "sha256:97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed", size = 3001511, upload-time = "2026-03-25T23:34:09.892Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/57/fe4a23eb549ac9d903bd4698ffda13383808ef0876cc912bcb2838799ece/cryptography-46.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4", size = 3471692, upload-time = "2026-03-25T23:34:11.613Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/cc/f330e982852403da79008552de9906804568ae9230da8432f7496ce02b71/cryptography-46.0.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a", size = 7162776, upload-time = "2026-03-25T23:34:13.308Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/b3/dc27efd8dcc4bff583b3f01d4a3943cd8b5821777a58b3a6a5f054d61b79/cryptography-46.0.6-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8", size = 4270529, upload-time = "2026-03-25T23:34:15.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/05/e8d0e6eb4f0d83365b3cb0e00eb3c484f7348db0266652ccd84632a3d58d/cryptography-46.0.6-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77", size = 4414827, upload-time = "2026-03-25T23:34:16.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/97/daba0f5d2dc6d855e2dcb70733c812558a7977a55dd4a6722756628c44d1/cryptography-46.0.6-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290", size = 4271265, upload-time = "2026-03-25T23:34:18.586Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/06/fe1fce39a37ac452e58d04b43b0855261dac320a2ebf8f5260dd55b201a9/cryptography-46.0.6-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410", size = 4916800, upload-time = "2026-03-25T23:34:20.561Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/8a/b14f3101fe9c3592603339eb5d94046c3ce5f7fc76d6512a2d40efd9724e/cryptography-46.0.6-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d", size = 4448771, upload-time = "2026-03-25T23:34:22.406Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/b3/0796998056a66d1973fd52ee89dc1bb3b6581960a91ad4ac705f182d398f/cryptography-46.0.6-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70", size = 3978333, upload-time = "2026-03-25T23:34:24.281Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/3d/db200af5a4ffd08918cd55c08399dc6c9c50b0bc72c00a3246e099d3a849/cryptography-46.0.6-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d", size = 4271069, upload-time = "2026-03-25T23:34:25.895Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/18/61acfd5b414309d74ee838be321c636fe71815436f53c9f0334bf19064fa/cryptography-46.0.6-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa", size = 4878358, upload-time = "2026-03-25T23:34:27.67Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/65/5bf43286d566f8171917cae23ac6add941654ccf085d739195a4eacf1674/cryptography-46.0.6-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58", size = 4448061, upload-time = "2026-03-25T23:34:29.375Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/25/7e49c0fa7205cf3597e525d156a6bce5b5c9de1fd7e8cb01120e459f205a/cryptography-46.0.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb", size = 4399103, upload-time = "2026-03-25T23:34:32.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/46/466269e833f1c4718d6cd496ffe20c56c9c8d013486ff66b4f69c302a68d/cryptography-46.0.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72", size = 4659255, upload-time = "2026-03-25T23:34:33.679Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/09/ddc5f630cc32287d2c953fc5d32705e63ec73e37308e5120955316f53827/cryptography-46.0.6-cp38-abi3-win32.whl", hash = "sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c", size = 3010660, upload-time = "2026-03-25T23:34:35.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/82/ca4893968aeb2709aacfb57a30dec6fa2ab25b10fa9f064b8882ce33f599/cryptography-46.0.6-cp38-abi3-win_amd64.whl", hash = "sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f", size = 3471160, upload-time = "2026-03-25T23:34:37.191Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/84/7ccff00ced5bac74b775ce0beb7d1be4e8637536b522b5df9b73ada42da2/cryptography-46.0.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:2ea0f37e9a9cf0df2952893ad145fd9627d326a59daec9b0802480fa3bcd2ead", size = 3475444, upload-time = "2026-03-25T23:34:38.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/1f/4c926f50df7749f000f20eede0c896769509895e2648db5da0ed55db711d/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a3e84d5ec9ba01f8fd03802b2147ba77f0c8f2617b2aff254cedd551844209c8", size = 4218227, upload-time = "2026-03-25T23:34:40.871Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/65/707be3ffbd5f786028665c3223e86e11c4cda86023adbc56bd72b1b6bab5/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:12f0fa16cc247b13c43d56d7b35287ff1569b5b1f4c5e87e92cc4fcc00cd10c0", size = 4381399, upload-time = "2026-03-25T23:34:42.609Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/6d/73557ed0ef7d73d04d9aba745d2c8e95218213687ee5e76b7d236a5030fc/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:50575a76e2951fe7dbd1f56d181f8c5ceeeb075e9ff88e7ad997d2f42af06e7b", size = 4217595, upload-time = "2026-03-25T23:34:44.205Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/c5/e1594c4eec66a567c3ac4400008108a415808be2ce13dcb9a9045c92f1a0/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:90e5f0a7b3be5f40c3a0a0eafb32c681d8d2c181fc2a1bdabe9b3f611d9f6b1a", size = 4380912, upload-time = "2026-03-25T23:34:46.328Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/89/843b53614b47f97fe1abc13f9a86efa5ec9e275292c457af1d4a60dc80e0/cryptography-46.0.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6728c49e3b2c180ef26f8e9f0a883a2c585638db64cf265b49c9ba10652d430e", size = 3409955, upload-time = "2026-03-25T23:34:48.465Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -907,7 +907,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langchain-core"
|
||||
version = "1.2.28"
|
||||
version = "1.2.27"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jsonpatch", marker = "python_full_version >= '3.11'" },
|
||||
@@ -919,9 +919,9 @@ dependencies = [
|
||||
{ name = "typing-extensions", marker = "python_full_version >= '3.11'" },
|
||||
{ name = "uuid-utils", marker = "python_full_version >= '3.11'" },
|
||||
]
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2318,28 +2318,28 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "uv"
|
||||
version = "0.11.6"
|
||||
version = "0.11.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/dd/f3/8aceeab67ea69805293ab290e7ca8cc1b61a064d28b8a35c76d8eba063dd/uv-0.11.6.tar.gz", hash = "sha256:e3b21b7e80024c95ff339fcd147ac6fc3dd98d3613c9d45d3a1f4fd1057f127b", size = 4073298, upload-time = "2026-04-09T12:09:01.738Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/88/ed/f11c558e8d2e02fba6057dacd9e92a71557359a80bd5355452310b89f40f/uv-0.11.3.tar.gz", hash = "sha256:6a6fcaf1fec28bbbdf0dfc5a0a6e34be4cea08c6287334b08c24cf187300f20d", size = 4027684, upload-time = "2026-04-01T21:47:22.096Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/fe/4b61a3d5ad9d02e8a4405026ccd43593d7044598e0fa47d892d4dafe44c9/uv-0.11.6-py3-none-linux_armv6l.whl", hash = "sha256:ada04dcf89ddea5b69d27ac9cdc5ef575a82f90a209a1392e930de504b2321d6", size = 23780079, upload-time = "2026-04-09T12:08:56.609Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/db/d27519a9e1a5ffee9d71af1a811ad0e19ce7ab9ae815453bef39dd479389/uv-0.11.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5be013888420f96879c6e0d3081e7bcf51b539b034a01777041934457dfbedf3", size = 23214721, upload-time = "2026-04-09T12:09:32.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/8f/4399fa8b882bd7e0efffc829f73ab24d117d490a93e6bc7104a50282b854/uv-0.11.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ffa5dc1cbb52bdce3b8447e83d1601a57ad4da6b523d77d4b47366db8b1ceb18", size = 21750109, upload-time = "2026-04-09T12:09:24.357Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/07/5a12944c31c3dda253632da7a363edddb869ed47839d4d92a2dc5f546c93/uv-0.11.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:bfb107b4dade1d2c9e572992b06992d51dd5f2136eb8ceee9e62dd124289e825", size = 23551146, upload-time = "2026-04-09T12:09:10.439Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/5b/2ec8b0af80acd1016ed596baf205ddc77b19ece288473b01926c4a9cf6db/uv-0.11.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:9e2fe7ce12161d8016b7deb1eaad7905a76ff7afec13383333ca75e0c4b5425d", size = 23331192, upload-time = "2026-04-09T12:09:34.792Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/7d/eea35935f2112b21c296a3e42645f3e4b1aa8bcd34dcf13345fbd55134b7/uv-0.11.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7ed9c6f70c25e8dfeedddf4eddaf14d353f5e6b0eb43da9a14d3a1033d51d915", size = 23337686, upload-time = "2026-04-09T12:09:18.522Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/47/2584f5ab618f6ebe9bdefb2f765f2ca8540e9d739667606a916b35449eec/uv-0.11.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d68a013e609cebf82077cbeeb0809ed5e205257814273bfd31e02fc0353bbfc2", size = 25008139, upload-time = "2026-04-09T12:09:03.983Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/81/497ae5c1d36355b56b97dc59f550c7e89d0291c163a3f203c6f341dff195/uv-0.11.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:93f736dddca03dae732c6fdea177328d3bc4bf137c75248f3d433c57416a4311", size = 25712458, upload-time = "2026-04-09T12:09:07.598Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/1c/74083238e4fab2672b63575b9008f1ea418b02a714bcfcf017f4f6a309b6/uv-0.11.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e96a66abe53fced0e3389008b8d2eff8278cfa8bb545d75631ae8ceb9c929aba", size = 24915507, upload-time = "2026-04-09T12:08:50.892Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/ee/e14fe10ba455a823ed18233f12de6699a601890905420b5c504abf115116/uv-0.11.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b096311b2743b228df911a19532b3f18fa420bf9530547aecd6a8e04bbfaccd", size = 24971011, upload-time = "2026-04-09T12:08:54.016Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/a1/7b9c83eaadf98e343317ff6384a7227a4855afd02cdaf9696bcc71ee6155/uv-0.11.6-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:904d537b4a6e798015b4a64ff5622023bd4601b43b6cd1e5f423d63471f5e948", size = 23640234, upload-time = "2026-04-09T12:09:15.735Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/51/75ccdd23e76ff1703b70eb82881cd5b4d2a954c9679f8ef7e0136ef2cfab/uv-0.11.6-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:4ed8150c26b5e319381d75ae2ce6aba1e9c65888f4850f4e3b3fa839953c90a5", size = 24452664, upload-time = "2026-04-09T12:09:26.875Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/86/ace80fe47d8d48b5e3b5aee0b6eb1a49deaacc2313782870250b3faa36f5/uv-0.11.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1c9218c8d4ac35ca6e617fb0951cc0ab2d907c91a6aea2617de0a5494cf162c0", size = 24494599, upload-time = "2026-04-09T12:09:37.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/2d/4b642669b56648194f026de79bc992cbfc3ac2318b0a8d435f3c284934e8/uv-0.11.6-py3-none-musllinux_1_1_i686.whl", hash = "sha256:9e211c83cc890c569b86a4183fcf5f8b6f0c7adc33a839b699a98d30f1310d3a", size = 24159150, upload-time = "2026-04-09T12:09:13.17Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/24/7eecd76fe983a74fed1fc700a14882e70c4e857f1d562a9f2303d4286c12/uv-0.11.6-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:d2a1d2089afdf117ad19a4c1dd36b8189c00ae1ad4135d3bfbfced82342595cf", size = 25164324, upload-time = "2026-04-09T12:08:59.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/e0/bbd4ba7c2e5067bbba617d87d306ec146889edaeeaa2081d3e122178ca08/uv-0.11.6-py3-none-win32.whl", hash = "sha256:6e8344f38fa29f85dcfd3e62dc35a700d2448f8e90381077ef393438dcd5012e", size = 22865693, upload-time = "2026-04-09T12:09:21.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/33/1983ce113c538a856f2d620d16e39691962ecceef091a84086c5785e32e5/uv-0.11.6-py3-none-win_amd64.whl", hash = "sha256:a28bea69c1186303d1200f155c7a28c449f8a4431e458fcf89360cc7ef546e40", size = 25371258, upload-time = "2026-04-09T12:09:40.52Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/01/be0873f44b9c9bc250fcbf263367fcfc1f59feab996355bcb6b52fff080d/uv-0.11.6-py3-none-win_arm64.whl", hash = "sha256:a78f6d64b9950e24061bc7ec7f15ff8089ad7f5a976e7b65fcadce58fe02f613", size = 23869585, upload-time = "2026-04-09T12:09:29.425Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/93/4f04c49fd6046a18293de341d795ded3b9cbd95db261d687e26db0f11d1e/uv-0.11.3-py3-none-linux_armv6l.whl", hash = "sha256:deb533e780e8181e0859c68c84f546620072cd1bd827b38058cb86ebfba9bb7d", size = 23337334, upload-time = "2026-04-01T21:46:47.545Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/4b/c44fd3fbc80ac2f81e2ad025d235c820aac95b228076da85be3f5d509781/uv-0.11.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d2b3b0fa1693880ca354755c216ae1c65dd938a4f1a24374d0c3f4b9538e0ee6", size = 22940169, upload-time = "2026-04-01T21:47:32.72Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/c7/7d01be259a47d42fa9e80adcb7a829d81e7c376aa8fa1b714f31d7dfc226/uv-0.11.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:71f5d0b9e73daa5d8a7e2db3fa2e22a4537d24bb4fe78130db797280280d4edc", size = 21473579, upload-time = "2026-04-01T21:47:25.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/71/fffcd890290a4639a3799cf3f3e87947c10d1b0de19eba3cf837cb418dd8/uv-0.11.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:55ba578752f29a3f2b22879b22a162edad1454e3216f3ca4694fdbd4093a6822", size = 23132691, upload-time = "2026-04-01T21:47:44.587Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/7b/1ac9e1f753a19b6252434f0bbe96efdcc335cd74677f4c6f431a7c916114/uv-0.11.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:3b1fe09d5e1d8e19459cd28d7825a3b66ef147b98328345bad6e17b87c4fea48", size = 22955764, upload-time = "2026-04-01T21:46:51.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/51/1a6010a681a3c3e0a8ec99737ba2d0452194dc372a5349a9267873261c02/uv-0.11.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:088165b9eed981d2c2a58566cc75dd052d613e47c65e2416842d07308f793a6f", size = 22966245, upload-time = "2026-04-01T21:47:07.403Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/74/1a1b0712daead7e85f56d620afe96fe166a04b615524c14027b4edd39b82/uv-0.11.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ef0ae8ee2988928092616401ec7f473612b8e9589fe1567452c45dbc56840f85", size = 24623370, upload-time = "2026-04-01T21:47:03.59Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/62/5c3aa5e7bd2744810e50ad72a5951386ec84a513e109b1b5cb7ec442f3b6/uv-0.11.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6708827ecb846d00c5512a7e4dc751c2e27b92e9bd55a0be390561ac68930c32", size = 25142735, upload-time = "2026-04-01T21:46:55.756Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/ab/6266a04980e0877af5518762adfe23a0c1ab0b801ae3099a2e7b74e34411/uv-0.11.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8df030ea7563e99c09854e1bc82ab743dfa2d0ba18976e6861979cb40d04dba7", size = 24512083, upload-time = "2026-04-01T21:46:43.531Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/be/7c66d350f833eb437f9aa0875655cc05e07b441e3f4a770f8bced56133f7/uv-0.11.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fde893b5ab9f6997fe357138e794bac09d144328052519fbbe2e6f72145e457", size = 24589293, upload-time = "2026-04-01T21:47:11.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/4f/22ada41564a8c8c36653fc86f89faae4c54a4cdd5817bda53764a3eb352d/uv-0.11.3-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:45006bcd9e8718248a23ab81448a5beb46a72a9dd508e3212d6f3b8c63aeb88a", size = 23214854, upload-time = "2026-04-01T21:46:59.491Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/18/8669840657fea9fd668739dec89643afe1061c023c1488228b02f79a2399/uv-0.11.3-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:089b9d338a64463956b6fee456f03f73c9a916479bdb29009600781dc1e1d2a7", size = 23914434, upload-time = "2026-04-01T21:47:29.164Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/0d/c59f24b3a1ae5f377aa6fd9653562a0968ea6be946fe35761871a0072919/uv-0.11.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3ff461335888336467402cc5cb792c911df95dd0b52e369182cfa4c902bb21f4", size = 23971481, upload-time = "2026-04-01T21:47:48.551Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/7d/f83ed79921310ef216ed6d73fcd3822dff4b66749054fb97e09b7bd5901e/uv-0.11.3-py3-none-musllinux_1_1_i686.whl", hash = "sha256:a62e29277efd39c35caf4a0fe739c4ebeb14d4ce4f02271f3f74271d608061ff", size = 23784797, upload-time = "2026-04-01T21:47:40.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/19/3ff3539c44ca7dc2aa87b021d4a153ba6a72866daa19bf91c289e4318f95/uv-0.11.3-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:ebccdcdebd2b288925f0f7c18c39705dc783175952eacaf94912b01d3b381b86", size = 24794606, upload-time = "2026-04-01T21:47:36.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/e5/e676454bb7cc5dcf5c4637ed3ef0ff97309d84a149b832a4dea53f04c0ab/uv-0.11.3-py3-none-win32.whl", hash = "sha256:794aae3bab141eafbe37c51dc5dd0139658a755a6fa9cc74d2dbd7c71dcc4826", size = 22573432, upload-time = "2026-04-01T21:47:15.143Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/a0/95d22d524bd3b4708043d65035f02fc9656e5fb6e0aaef73510313b1641b/uv-0.11.3-py3-none-win_amd64.whl", hash = "sha256:68fda574f2e5e7536a2b747dcea88329a71aad7222317e8f4717d0af8f99fbd4", size = 24969508, upload-time = "2026-04-01T21:47:19.515Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/6d/3f0b90a06e8c4594e11f813651756d6896de6dd4461f554fd7e4984a1c4f/uv-0.11.3-py3-none-win_arm64.whl", hash = "sha256:92ffc4d521ab2c4738ef05d8ef26f2750e26d31f3ad5611cdfefc52445be9ace", size = 23488911, upload-time = "2026-04-01T21:47:52.427Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import ChainMap
|
||||
from collections.abc import Sequence
|
||||
from collections.abc import Mapping, Sequence
|
||||
from os import getenv
|
||||
from typing import Any, cast
|
||||
|
||||
@@ -217,16 +217,14 @@ def get_callback_manager_for_config(
|
||||
callbacks.add_tags(all_tags)
|
||||
if metadata := config.get("metadata"):
|
||||
callbacks.add_metadata(metadata)
|
||||
manager = callbacks
|
||||
return callbacks
|
||||
else:
|
||||
# otherwise create a new manager
|
||||
manager = CallbackManager.configure(
|
||||
return 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(
|
||||
@@ -257,16 +255,14 @@ def get_async_callback_manager_for_config(
|
||||
callbacks.add_tags(all_tags)
|
||||
if metadata := config.get("metadata"):
|
||||
callbacks.add_metadata(metadata)
|
||||
manager = callbacks
|
||||
return callbacks
|
||||
else:
|
||||
# otherwise create a new manager
|
||||
manager = AsyncCallbackManager.configure(
|
||||
return 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:
|
||||
@@ -312,54 +308,22 @@ 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
|
||||
|
||||
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
|
||||
_empty_metadata = empty["metadata"]
|
||||
for key, value in empty[CONF].items():
|
||||
if _exclude_as_metadata(key, value, _empty_metadata):
|
||||
continue
|
||||
_empty_metadata[key] = value
|
||||
return empty
|
||||
|
||||
|
||||
_OMIT = ("key", "token", "secret", "password", "auth")
|
||||
|
||||
|
||||
def _exclude_as_metadata(key: str, value: Any) -> bool:
|
||||
def _exclude_as_metadata(key: str, value: Any, metadata: Mapping[str, 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",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,412 +0,0 @@
|
||||
"""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 add_handler(
|
||||
self,
|
||||
handler: BaseCallbackHandler,
|
||||
inherit: bool = True, # noqa: FBT001,FBT002
|
||||
) -> None:
|
||||
if not isinstance(handler, GraphCallbackHandler):
|
||||
raise TypeError("handlers must inherit GraphCallbackHandler")
|
||||
super().add_handler(handler, inherit=inherit)
|
||||
|
||||
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 add_handler(
|
||||
self,
|
||||
handler: BaseCallbackHandler,
|
||||
inherit: bool = True, # noqa: FBT001,FBT002
|
||||
) -> None:
|
||||
if not isinstance(handler, GraphCallbackHandler):
|
||||
raise TypeError("handlers must inherit GraphCallbackHandler")
|
||||
super().add_handler(handler, inherit=inherit)
|
||||
|
||||
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,
|
||||
)
|
||||
@@ -62,11 +62,6 @@ 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.untracked_value import UntrackedValue
|
||||
from langgraph.constants import TAG_HIDDEN
|
||||
@@ -122,7 +117,6 @@ from langgraph.types import (
|
||||
CachePolicy,
|
||||
Command,
|
||||
Durability,
|
||||
Interrupt,
|
||||
PregelExecutableTask,
|
||||
RetryPolicy,
|
||||
Send,
|
||||
@@ -209,8 +203,6 @@ class PregelLoop:
|
||||
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
|
||||
|
||||
@@ -236,7 +228,6 @@ 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
|
||||
@@ -261,8 +252,6 @@ 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)
|
||||
@@ -314,40 +303,6 @@ class PregelLoop:
|
||||
)
|
||||
self.prev_checkpoint_config = None
|
||||
|
||||
def _push_graph_lifecycle_event(
|
||||
self,
|
||||
kind: Literal["resume", "interrupt"],
|
||||
*,
|
||||
interrupts: tuple[Interrupt, ...] = (),
|
||||
) -> None:
|
||||
if kind == "resume":
|
||||
self._graph_lifecycle_events.append(
|
||||
GraphResumeEvent(
|
||||
run_id=None,
|
||||
status=self.status,
|
||||
checkpoint_id=self.checkpoint["id"],
|
||||
checkpoint_ns=self.checkpoint_ns,
|
||||
)
|
||||
)
|
||||
elif kind == "interrupt":
|
||||
self._graph_lifecycle_events.append(
|
||||
GraphInterruptEvent(
|
||||
run_id=None,
|
||||
status=self.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."""
|
||||
if not writes:
|
||||
@@ -830,8 +785,6 @@ 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:
|
||||
@@ -932,10 +885,8 @@ class PregelLoop:
|
||||
self._put_checkpoint(self.checkpoint_metadata)
|
||||
self._put_pending_writes()
|
||||
# suppress interrupt
|
||||
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)
|
||||
suppress = isinstance(exc_value, GraphInterrupt) and not self.is_nested
|
||||
if suppress:
|
||||
# emit one last "values" event, with pending writes applied
|
||||
if (
|
||||
hasattr(self, "tasks")
|
||||
@@ -962,11 +913,12 @@ class PregelLoop:
|
||||
self.channels,
|
||||
)
|
||||
# emit INTERRUPT if exception is empty (otherwise emitted by put_writes)
|
||||
if not interrupt.args or not interrupt.args[0]:
|
||||
interrupt_payload = interrupt.args[0] if interrupt.args else ()
|
||||
if exc_value is not None and (not exc_value.args or not exc_value.args[0]):
|
||||
self._emit(
|
||||
"updates",
|
||||
lambda: iter([{INTERRUPT: interrupt_payload}]),
|
||||
lambda: iter(
|
||||
[{INTERRUPT: cast(GraphInterrupt, exc_value).args[0]}]
|
||||
),
|
||||
)
|
||||
# save final output
|
||||
self.output = read_channels(self.channels, self.output_keys)
|
||||
@@ -1088,7 +1040,6 @@ 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,
|
||||
@@ -1110,7 +1061,6 @@ 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:
|
||||
@@ -1186,7 +1136,6 @@ 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):
|
||||
@@ -1287,7 +1236,6 @@ 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,
|
||||
@@ -1309,7 +1257,6 @@ 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:
|
||||
@@ -1388,7 +1335,6 @@ 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):
|
||||
|
||||
@@ -14,7 +14,7 @@ from langchain_core.messages import BaseMessage
|
||||
from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, LLMResult
|
||||
from pydantic import BaseModel
|
||||
|
||||
from langgraph._internal._constants import NS_END, NS_SEP
|
||||
from langgraph._internal._constants import NS_SEP
|
||||
from langgraph.constants import TAG_HIDDEN, TAG_NOSTREAM
|
||||
from langgraph.pregel.protocol import StreamChunk
|
||||
from langgraph.types import Command
|
||||
@@ -132,23 +132,15 @@ class StreamMessagesHandler(BaseCallbackHandler, _StreamingCallbackHandler):
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
if metadata and (not tags or (TAG_NOSTREAM not in tags)):
|
||||
task_checkpoint_ns = cast(str, metadata["langgraph_checkpoint_ns"])
|
||||
checkpoint_ns = (
|
||||
f"{task_checkpoint_ns.rsplit(NS_END, 1)[0]}{NS_END}"
|
||||
if NS_END in task_checkpoint_ns
|
||||
else task_checkpoint_ns
|
||||
)
|
||||
ns = tuple(task_checkpoint_ns.split(NS_SEP))[:-1]
|
||||
ns = tuple(cast(str, metadata["langgraph_checkpoint_ns"]).split(NS_SEP))[
|
||||
:-1
|
||||
]
|
||||
if not self.subgraphs and len(ns) > 0 and ns != self.parent_ns:
|
||||
return
|
||||
stream_metadata = dict(metadata)
|
||||
stream_metadata["langgraph_checkpoint_ns"] = checkpoint_ns
|
||||
# Preserve backwards-compatible streamed checkpoint metadata shape.
|
||||
stream_metadata["checkpoint_ns"] = checkpoint_ns
|
||||
if tags:
|
||||
if filtered_tags := [t for t in tags if not t.startswith("seq:step")]:
|
||||
stream_metadata["tags"] = filtered_tags
|
||||
self.metadata[run_id] = (ns, stream_metadata)
|
||||
metadata["tags"] = filtered_tags
|
||||
self.metadata[run_id] = (ns, metadata)
|
||||
|
||||
def on_llm_new_token(
|
||||
self,
|
||||
|
||||
@@ -16,7 +16,7 @@ from collections.abc import (
|
||||
Mapping,
|
||||
Sequence,
|
||||
)
|
||||
from dataclasses import is_dataclass, replace
|
||||
from dataclasses import is_dataclass
|
||||
from functools import partial
|
||||
from inspect import isclass
|
||||
from typing import (
|
||||
@@ -96,12 +96,6 @@ from langgraph._internal._runnable import (
|
||||
coerce_to_runnable,
|
||||
)
|
||||
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
|
||||
@@ -2591,10 +2585,6 @@ class Pregel(
|
||||
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,
|
||||
)
|
||||
try:
|
||||
# assign defaults
|
||||
(
|
||||
@@ -2679,17 +2669,6 @@ 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),
|
||||
@@ -2710,9 +2689,7 @@ 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(
|
||||
@@ -2774,11 +2751,9 @@ 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,
|
||||
@@ -2953,10 +2928,6 @@ class Pregel(
|
||||
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(
|
||||
@@ -3071,28 +3042,6 @@ 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),
|
||||
@@ -3113,9 +3062,7 @@ 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(
|
||||
@@ -3197,7 +3144,6 @@ 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)
|
||||
@@ -3206,8 +3152,6 @@ 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,
|
||||
@@ -3715,14 +3659,15 @@ def _coerce_checkpoint_values(payload: Any, mapper: Callable[[Any], Any]) -> Non
|
||||
def _build_server_info(
|
||||
config: RunnableConfig, parent_runtime: Runtime[Any]
|
||||
) -> ServerInfo | None:
|
||||
"""Build ServerInfo from config configurable.
|
||||
"""Build ServerInfo from config metadata and configurable.
|
||||
|
||||
The server puts assistant_id/graph_id in config configurable and the
|
||||
The server puts assistant_id/graph_id in config metadata and the
|
||||
authenticated user dict in configurable["langgraph_auth_user"].
|
||||
"""
|
||||
metadata = config.get("metadata") or {}
|
||||
configurable = config.get(CONF) or {}
|
||||
assistant_id = configurable.get("assistant_id")
|
||||
graph_id = configurable.get("graph_id")
|
||||
assistant_id = metadata.get("assistant_id")
|
||||
graph_id = metadata.get("graph_id")
|
||||
|
||||
# Read authenticated user from configurable (set by LangGraph Server).
|
||||
# We prefer isinstance(BaseUser) but fall back to hasattr("identity")
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "langgraph"
|
||||
version = "1.1.7a2"
|
||||
version = "1.1.6"
|
||||
description = "Building stateful, multi-actor applications with LLMs"
|
||||
authors = []
|
||||
requires-python = ">=3.10"
|
||||
@@ -24,7 +24,7 @@ classifiers = [
|
||||
'Programming Language :: Python :: 3.13',
|
||||
]
|
||||
dependencies = [
|
||||
"langchain-core==1.3.0a2",
|
||||
"langchain-core>=0.1",
|
||||
"langgraph-checkpoint>=2.1.0,<5.0.0",
|
||||
"langgraph-sdk>=0.3.0,<0.4.0",
|
||||
"langgraph-prebuilt>=1.0.9,<1.1.0",
|
||||
|
||||
@@ -1,277 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from langchain_core.callbacks.base import BaseCallbackHandler
|
||||
from langchain_core.callbacks.manager import CallbackManager
|
||||
from langgraph.checkpoint.memory import InMemorySaver
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from langgraph.callbacks import (
|
||||
GraphCallbackHandler,
|
||||
GraphInterruptEvent,
|
||||
GraphResumeEvent,
|
||||
)
|
||||
from langgraph.graph import START, StateGraph
|
||||
from langgraph.types import Command, Interrupt, interrupt
|
||||
|
||||
NEEDS_CONTEXTVARS = pytest.mark.skipif(
|
||||
sys.version_info < (3, 11),
|
||||
reason="Python 3.11+ is required for async contextvars support",
|
||||
)
|
||||
|
||||
|
||||
class _GraphEventHandler(GraphCallbackHandler):
|
||||
def __init__(self) -> None:
|
||||
self.interrupt_events: list[GraphInterruptEvent] = []
|
||||
self.resume_events: list[GraphResumeEvent] = []
|
||||
|
||||
def on_interrupt(self, event: GraphInterruptEvent) -> Any:
|
||||
self.interrupt_events.append(event)
|
||||
|
||||
def on_resume(self, event: GraphResumeEvent) -> Any:
|
||||
self.resume_events.append(event)
|
||||
|
||||
|
||||
class _LangChainCustomEventHandler(BaseCallbackHandler):
|
||||
run_inline = True
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.events: list[str] = []
|
||||
|
||||
def on_custom_event(self, name: str, data: Any, **kwargs: Any) -> Any:
|
||||
self.events.append(name)
|
||||
|
||||
|
||||
class _RaisingGraphEventHandler(GraphCallbackHandler):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
raise_on_interrupt: bool = False,
|
||||
raise_on_resume: bool = False,
|
||||
raise_error: bool = False,
|
||||
) -> None:
|
||||
self.raise_on_interrupt = raise_on_interrupt
|
||||
self.raise_on_resume = raise_on_resume
|
||||
self.raise_error = raise_error
|
||||
|
||||
def on_interrupt(self, event: GraphInterruptEvent) -> Any:
|
||||
if self.raise_on_interrupt:
|
||||
raise ValueError("boom-interrupt")
|
||||
|
||||
def on_resume(self, event: GraphResumeEvent) -> Any:
|
||||
if self.raise_on_resume:
|
||||
raise ValueError("boom-resume")
|
||||
|
||||
|
||||
class _AsyncRaisingGraphEventHandler(GraphCallbackHandler):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
raise_on_interrupt: bool = False,
|
||||
raise_on_resume: bool = False,
|
||||
raise_error: bool = False,
|
||||
) -> None:
|
||||
self.raise_on_interrupt = raise_on_interrupt
|
||||
self.raise_on_resume = raise_on_resume
|
||||
self.raise_error = raise_error
|
||||
|
||||
async def on_interrupt(self, event: GraphInterruptEvent) -> Any:
|
||||
if self.raise_on_interrupt:
|
||||
raise ValueError("boom-interrupt")
|
||||
|
||||
async def on_resume(self, event: GraphResumeEvent) -> Any:
|
||||
if self.raise_on_resume:
|
||||
raise ValueError("boom-resume")
|
||||
|
||||
|
||||
class _State(TypedDict):
|
||||
answer: str | None
|
||||
|
||||
|
||||
def _build_interrupt_graph() -> Any:
|
||||
def ask(state: _State) -> _State:
|
||||
answer = interrupt("Provide value")
|
||||
return {"answer": answer}
|
||||
|
||||
builder = StateGraph(_State)
|
||||
builder.add_node("ask", ask)
|
||||
builder.add_edge(START, "ask")
|
||||
return builder.compile(checkpointer=InMemorySaver())
|
||||
|
||||
|
||||
def test_graph_callbacks_interrupt_and_resume_sync() -> None:
|
||||
graph = _build_interrupt_graph()
|
||||
handler = _GraphEventHandler()
|
||||
langchain_handler = _LangChainCustomEventHandler()
|
||||
config = {
|
||||
"configurable": {"thread_id": "graph-callback-sync"},
|
||||
"callbacks": [langchain_handler, handler],
|
||||
}
|
||||
|
||||
first = graph.invoke({"answer": None}, config)
|
||||
assert "__interrupt__" in first
|
||||
|
||||
assert len(handler.interrupt_events) == 1
|
||||
assert handler.interrupt_events[0].interrupts
|
||||
assert isinstance(handler.interrupt_events[0].interrupts[0], Interrupt)
|
||||
assert handler.interrupt_events[0].checkpoint_ns == ()
|
||||
assert langchain_handler.events == []
|
||||
|
||||
handler.resume_events.clear()
|
||||
resumed = graph.invoke(Command(resume="done"), config)
|
||||
assert resumed == {"answer": "done"}
|
||||
|
||||
assert len(handler.resume_events) == 1
|
||||
assert handler.resume_events[0].checkpoint_ns == ()
|
||||
assert langchain_handler.events == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@NEEDS_CONTEXTVARS
|
||||
async def test_graph_callbacks_interrupt_and_resume_async() -> None:
|
||||
graph = _build_interrupt_graph()
|
||||
handler = _GraphEventHandler()
|
||||
langchain_handler = _LangChainCustomEventHandler()
|
||||
config = {
|
||||
"configurable": {"thread_id": "graph-callback-async"},
|
||||
"callbacks": [langchain_handler, handler],
|
||||
}
|
||||
|
||||
first = await graph.ainvoke({"answer": None}, config)
|
||||
assert "__interrupt__" in first
|
||||
|
||||
assert len(handler.interrupt_events) == 1
|
||||
assert handler.interrupt_events[0].interrupts
|
||||
assert isinstance(handler.interrupt_events[0].interrupts[0], Interrupt)
|
||||
assert handler.interrupt_events[0].checkpoint_ns == ()
|
||||
assert langchain_handler.events == []
|
||||
|
||||
handler.resume_events.clear()
|
||||
resumed = await graph.ainvoke(Command(resume="done"), config)
|
||||
assert resumed == {"answer": "done"}
|
||||
|
||||
assert len(handler.resume_events) == 1
|
||||
assert handler.resume_events[0].checkpoint_ns == ()
|
||||
assert langchain_handler.events == []
|
||||
|
||||
|
||||
def test_graph_callbacks_continue_when_interrupt_handler_raises_sync() -> None:
|
||||
graph = _build_interrupt_graph()
|
||||
raising_handler = _RaisingGraphEventHandler(raise_on_interrupt=True)
|
||||
recording_handler = _GraphEventHandler()
|
||||
|
||||
first = graph.invoke(
|
||||
{"answer": None},
|
||||
{
|
||||
"configurable": {"thread_id": "graph-callback-sync-raises"},
|
||||
"callbacks": [raising_handler, recording_handler],
|
||||
},
|
||||
)
|
||||
|
||||
assert "__interrupt__" in first
|
||||
assert len(recording_handler.interrupt_events) == 1
|
||||
|
||||
|
||||
def test_graph_callbacks_continue_when_resume_handler_raises_sync() -> None:
|
||||
graph = _build_interrupt_graph()
|
||||
raising_handler = _RaisingGraphEventHandler(raise_on_resume=True)
|
||||
recording_handler = _GraphEventHandler()
|
||||
config = {
|
||||
"configurable": {"thread_id": "graph-callback-sync-raises-resume"},
|
||||
"callbacks": [raising_handler, recording_handler],
|
||||
}
|
||||
|
||||
first = graph.invoke({"answer": None}, config)
|
||||
assert "__interrupt__" in first
|
||||
|
||||
resumed = graph.invoke(Command(resume="done"), config)
|
||||
assert resumed == {"answer": "done"}
|
||||
assert len(recording_handler.resume_events) == 1
|
||||
|
||||
|
||||
def test_graph_callbacks_raise_error_propagates_sync() -> None:
|
||||
graph = _build_interrupt_graph()
|
||||
raising_handler = _RaisingGraphEventHandler(
|
||||
raise_on_interrupt=True,
|
||||
raise_error=True,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="boom-interrupt"):
|
||||
graph.invoke(
|
||||
{"answer": None},
|
||||
{
|
||||
"configurable": {"thread_id": "graph-callback-sync-raise-error"},
|
||||
"callbacks": [raising_handler],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@NEEDS_CONTEXTVARS
|
||||
async def test_graph_callbacks_continue_when_handler_raises_async() -> None:
|
||||
graph = _build_interrupt_graph()
|
||||
raising_interrupt_handler = _AsyncRaisingGraphEventHandler(raise_on_interrupt=True)
|
||||
recording_handler = _GraphEventHandler()
|
||||
config = {
|
||||
"configurable": {"thread_id": "graph-callback-async-raises-interrupt"},
|
||||
"callbacks": [raising_interrupt_handler, recording_handler],
|
||||
}
|
||||
|
||||
first = await graph.ainvoke({"answer": None}, config)
|
||||
assert "__interrupt__" in first
|
||||
assert len(recording_handler.interrupt_events) == 1
|
||||
|
||||
graph = _build_interrupt_graph()
|
||||
raising_resume_handler = _AsyncRaisingGraphEventHandler(raise_on_resume=True)
|
||||
recording_handler = _GraphEventHandler()
|
||||
config = {
|
||||
"configurable": {"thread_id": "graph-callback-async-raises-resume"},
|
||||
"callbacks": [raising_resume_handler, recording_handler],
|
||||
}
|
||||
|
||||
first = await graph.ainvoke({"answer": None}, config)
|
||||
assert "__interrupt__" in first
|
||||
resumed = await graph.ainvoke(Command(resume="done"), config)
|
||||
assert resumed == {"answer": "done"}
|
||||
assert len(recording_handler.resume_events) == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@NEEDS_CONTEXTVARS
|
||||
async def test_graph_callbacks_raise_error_propagates_async() -> None:
|
||||
graph = _build_interrupt_graph()
|
||||
raising_handler = _AsyncRaisingGraphEventHandler(
|
||||
raise_on_interrupt=True,
|
||||
raise_error=True,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="boom-interrupt"):
|
||||
await graph.ainvoke(
|
||||
{"answer": None},
|
||||
{
|
||||
"configurable": {"thread_id": "graph-callback-async-raise-error"},
|
||||
"callbacks": [raising_handler],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_graph_callbacks_accept_base_callback_manager() -> None:
|
||||
graph = _build_interrupt_graph()
|
||||
graph_handler = _GraphEventHandler()
|
||||
custom_handler = _LangChainCustomEventHandler()
|
||||
manager = CallbackManager.configure(inheritable_callbacks=[custom_handler])
|
||||
manager.add_handler(graph_handler)
|
||||
|
||||
first = graph.invoke(
|
||||
{"answer": None},
|
||||
{
|
||||
"configurable": {"thread_id": "graph-callback-base-manager"},
|
||||
"callbacks": manager,
|
||||
},
|
||||
)
|
||||
|
||||
assert "__interrupt__" in first
|
||||
assert len(graph_handler.interrupt_events) == 1
|
||||
@@ -1396,6 +1396,7 @@ def test_prebuilt_tool_chat(snapshot: SnapshotAssertion) -> None:
|
||||
"langgraph_path": (PULL, "agent"),
|
||||
"langgraph_checkpoint_ns": AnyStr("agent:"),
|
||||
"checkpoint_ns": AnyStr("agent:"),
|
||||
"_type": "generic-fake-chat-model",
|
||||
"ls_provider": "fakechatmodel",
|
||||
"ls_model_type": "chat",
|
||||
"ls_integration": "langchain_chat_model",
|
||||
@@ -1458,6 +1459,7 @@ def test_prebuilt_tool_chat(snapshot: SnapshotAssertion) -> None:
|
||||
"langgraph_path": (PULL, "agent"),
|
||||
"langgraph_checkpoint_ns": AnyStr("agent:"),
|
||||
"checkpoint_ns": AnyStr("agent:"),
|
||||
"_type": "generic-fake-chat-model",
|
||||
"ls_provider": "fakechatmodel",
|
||||
"ls_model_type": "chat",
|
||||
"ls_integration": "langchain_chat_model",
|
||||
@@ -1510,6 +1512,7 @@ def test_prebuilt_tool_chat(snapshot: SnapshotAssertion) -> None:
|
||||
"langgraph_path": (PULL, "agent"),
|
||||
"langgraph_checkpoint_ns": AnyStr("agent:"),
|
||||
"checkpoint_ns": AnyStr("agent:"),
|
||||
"_type": "generic-fake-chat-model",
|
||||
"ls_provider": "fakechatmodel",
|
||||
"ls_model_type": "chat",
|
||||
"ls_integration": "langchain_chat_model",
|
||||
@@ -6881,6 +6884,7 @@ def test_weather_subgraph(
|
||||
"langgraph_path": ("__pregel_pull", "router_node"),
|
||||
"langgraph_checkpoint_ns": AnyStr("router_node:"),
|
||||
"checkpoint_ns": AnyStr("router_node:"),
|
||||
"_type": "fake-messages-list-chat-model",
|
||||
"ls_provider": "fakemessageslistchatmodel",
|
||||
"ls_model_type": "chat",
|
||||
"ls_integration": "langchain_chat_model",
|
||||
@@ -6908,6 +6912,7 @@ def test_weather_subgraph(
|
||||
"langgraph_path": ("__pregel_pull", "model_node"),
|
||||
"langgraph_checkpoint_ns": AnyStr("weather_graph:"),
|
||||
"checkpoint_ns": AnyStr("weather_graph:"),
|
||||
"_type": "fake-messages-list-chat-model",
|
||||
"ls_provider": "fakemessageslistchatmodel",
|
||||
"ls_model_type": "chat",
|
||||
"ls_integration": "langchain_chat_model",
|
||||
@@ -6944,6 +6949,7 @@ def test_weather_subgraph(
|
||||
"langgraph_path": ("__pregel_pull", "router_node"),
|
||||
"langgraph_checkpoint_ns": AnyStr("router_node:"),
|
||||
"checkpoint_ns": AnyStr("router_node:"),
|
||||
"_type": "fake-messages-list-chat-model",
|
||||
"ls_provider": "fakemessageslistchatmodel",
|
||||
"ls_model_type": "chat",
|
||||
"ls_integration": "langchain_chat_model",
|
||||
|
||||
@@ -1147,6 +1147,7 @@ async def test_prebuilt_tool_chat() -> None:
|
||||
"langgraph_path": (PULL, "agent"),
|
||||
"langgraph_checkpoint_ns": AnyStr("agent:"),
|
||||
"checkpoint_ns": AnyStr("agent:"),
|
||||
"_type": "generic-fake-chat-model",
|
||||
"ls_provider": "fakechatmodel",
|
||||
"ls_model_type": "chat",
|
||||
"ls_integration": "langchain_chat_model",
|
||||
@@ -1209,6 +1210,7 @@ async def test_prebuilt_tool_chat() -> None:
|
||||
"langgraph_path": (PULL, "agent"),
|
||||
"langgraph_checkpoint_ns": AnyStr("agent:"),
|
||||
"checkpoint_ns": AnyStr("agent:"),
|
||||
"_type": "generic-fake-chat-model",
|
||||
"ls_provider": "fakechatmodel",
|
||||
"ls_model_type": "chat",
|
||||
"ls_integration": "langchain_chat_model",
|
||||
@@ -1261,6 +1263,7 @@ async def test_prebuilt_tool_chat() -> None:
|
||||
"langgraph_path": (PULL, "agent"),
|
||||
"langgraph_checkpoint_ns": AnyStr("agent:"),
|
||||
"checkpoint_ns": AnyStr("agent:"),
|
||||
"_type": "generic-fake-chat-model",
|
||||
"ls_provider": "fakechatmodel",
|
||||
"ls_model_type": "chat",
|
||||
"ls_integration": "langchain_chat_model",
|
||||
@@ -3978,6 +3981,7 @@ async def test_weather_subgraph(
|
||||
"langgraph_path": ("__pregel_pull", "router_node"),
|
||||
"langgraph_checkpoint_ns": AnyStr("router_node:"),
|
||||
"checkpoint_ns": AnyStr("router_node:"),
|
||||
"_type": "fake-messages-list-chat-model",
|
||||
"ls_provider": "fakemessageslistchatmodel",
|
||||
"ls_model_type": "chat",
|
||||
"ls_integration": "langchain_chat_model",
|
||||
@@ -4005,6 +4009,7 @@ async def test_weather_subgraph(
|
||||
"langgraph_path": ("__pregel_pull", "model_node"),
|
||||
"langgraph_checkpoint_ns": AnyStr("weather_graph:"),
|
||||
"checkpoint_ns": AnyStr("weather_graph:"),
|
||||
"_type": "fake-messages-list-chat-model",
|
||||
"ls_provider": "fakemessageslistchatmodel",
|
||||
"ls_model_type": "chat",
|
||||
"ls_integration": "langchain_chat_model",
|
||||
@@ -4041,6 +4046,7 @@ async def test_weather_subgraph(
|
||||
"langgraph_path": ("__pregel_pull", "router_node"),
|
||||
"langgraph_checkpoint_ns": AnyStr("router_node:"),
|
||||
"checkpoint_ns": AnyStr("router_node:"),
|
||||
"_type": "fake-messages-list-chat-model",
|
||||
"ls_provider": "fakemessageslistchatmodel",
|
||||
"ls_model_type": "chat",
|
||||
"ls_integration": "langchain_chat_model",
|
||||
|
||||
@@ -1329,22 +1329,20 @@ def test_imp_nested(
|
||||
}
|
||||
|
||||
thread1 = {"configurable": {"thread_id": "1"}}
|
||||
result = [*graph.stream([0, 1], thread1, durability=durability)]
|
||||
# nested tasks run concurrently so output order is non-deterministic
|
||||
assert sorted(result[:-1], key=lambda d: str(d)) == [
|
||||
{"mapper": "00"},
|
||||
{"mapper": "11"},
|
||||
assert [*graph.stream([0, 1], thread1, durability=durability)] == [
|
||||
{"submapper": "0"},
|
||||
{"mapper": "00"},
|
||||
{"submapper": "1"},
|
||||
{"mapper": "11"},
|
||||
{
|
||||
"__interrupt__": (
|
||||
Interrupt(
|
||||
value="question",
|
||||
id=AnyStr(),
|
||||
),
|
||||
)
|
||||
},
|
||||
]
|
||||
assert result[-1] == {
|
||||
"__interrupt__": (
|
||||
Interrupt(
|
||||
value="question",
|
||||
id=AnyStr(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
assert graph.invoke(Command(resume="answer"), thread1, durability=durability) == [
|
||||
"00answera",
|
||||
@@ -6271,7 +6269,7 @@ def test_sync_streaming_with_functional_api() -> None:
|
||||
@task()
|
||||
def slow() -> dict:
|
||||
time.sleep(time_delay) # Simulate a delay of 10 ms
|
||||
return {"tic": time.monotonic()}
|
||||
return {"tic": time.time()}
|
||||
|
||||
@entrypoint()
|
||||
def graph(inputs: dict) -> list:
|
||||
@@ -6284,7 +6282,7 @@ def test_sync_streaming_with_functional_api() -> None:
|
||||
for chunk in graph.stream({}):
|
||||
if "slow" not in chunk: # We'll just look at the updates from `slow`
|
||||
continue
|
||||
arrival_times.append(time.monotonic())
|
||||
arrival_times.append(time.time())
|
||||
|
||||
assert len(arrival_times) == 2
|
||||
delta = arrival_times[1] - arrival_times[0]
|
||||
@@ -6893,6 +6891,7 @@ def test_tags_stream_mode_messages() -> None:
|
||||
"langgraph_path": ("__pregel_pull", "call_model"),
|
||||
"langgraph_checkpoint_ns": AnyStr("call_model:"),
|
||||
"checkpoint_ns": AnyStr("call_model:"),
|
||||
"_type": "generic-fake-chat-model",
|
||||
"ls_provider": "genericfakechatmodel",
|
||||
"ls_model_type": "chat",
|
||||
"ls_integration": "langchain_chat_model",
|
||||
@@ -6902,60 +6901,6 @@ def test_tags_stream_mode_messages() -> None:
|
||||
]
|
||||
|
||||
|
||||
def test_configurable_propagates_to_stream_metadata() -> None:
|
||||
"""Regression: thread_id, run_id, assistant_id, graph_id,
|
||||
and langgraph_auth_user_id from configurable must appear
|
||||
in stream_mode='messages' metadata."""
|
||||
|
||||
def my_node(state):
|
||||
return {"messages": HumanMessage(content="hello")}
|
||||
|
||||
graph = (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("my_node", my_node)
|
||||
.add_edge(START, "my_node")
|
||||
.compile()
|
||||
)
|
||||
|
||||
config = {
|
||||
"configurable": {
|
||||
"thread_id": "th-123",
|
||||
"checkpoint_id": "ckpt-1",
|
||||
"checkpoint_ns": "ns-1",
|
||||
"task_id": "task-1",
|
||||
"run_id": "run-456",
|
||||
"assistant_id": "asst-789",
|
||||
"graph_id": "graph-0",
|
||||
"model": "gpt-4o",
|
||||
"user_id": "uid-1",
|
||||
"cron_id": "cron-1",
|
||||
"langgraph_auth_user_id": "user-1",
|
||||
# these should NOT be propagated into metadata
|
||||
"some_api_key": "secret",
|
||||
"custom_setting": {"nested": True},
|
||||
},
|
||||
}
|
||||
results = list(graph.stream({"messages": []}, config, stream_mode="messages"))
|
||||
assert len(results) == 1
|
||||
_, metadata = results[0]
|
||||
# propagated keys
|
||||
assert metadata["thread_id"] == "th-123"
|
||||
assert metadata["checkpoint_id"] == "ckpt-1"
|
||||
assert metadata["checkpoint_ns"] == "ns-1"
|
||||
assert metadata["task_id"] == "task-1"
|
||||
assert metadata["run_id"] == "run-456"
|
||||
assert metadata["assistant_id"] == "asst-789"
|
||||
assert metadata["graph_id"] == "graph-0"
|
||||
# These are only present in trace metadata by default as of langgraph 1.2
|
||||
# assert metadata["model"] == "gpt-4o"
|
||||
# assert metadata["user_id"] == "uid-1"
|
||||
# assert metadata["cron_id"] == "cron-1"
|
||||
# assert metadata["langgraph_auth_user_id"] == "user-1"
|
||||
# non-allowlisted keys must not appear
|
||||
assert "some_api_key" not in metadata
|
||||
assert "custom_setting" not in metadata
|
||||
|
||||
|
||||
def test_stream_mode_messages_command() -> None:
|
||||
from langchain_core.messages import HumanMessage
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ from uuid import UUID
|
||||
|
||||
import pytest
|
||||
from langchain_core.language_models import GenericFakeChatModel
|
||||
from langchain_core.messages import HumanMessage
|
||||
from langchain_core.runnables import RunnableConfig, RunnableLambda, RunnablePassthrough
|
||||
from langchain_core.utils.aiter import aclosing
|
||||
from langgraph.cache.base import BaseCache
|
||||
@@ -7542,6 +7541,7 @@ async def test_tags_stream_mode_messages() -> None:
|
||||
"langgraph_path": ("__pregel_pull", "call_model"),
|
||||
"langgraph_checkpoint_ns": AnyStr("call_model:"),
|
||||
"checkpoint_ns": AnyStr("call_model:"),
|
||||
"_type": "generic-fake-chat-model",
|
||||
"ls_provider": "genericfakechatmodel",
|
||||
"ls_model_type": "chat",
|
||||
"ls_integration": "langchain_chat_model",
|
||||
@@ -7551,67 +7551,6 @@ async def test_tags_stream_mode_messages() -> None:
|
||||
]
|
||||
|
||||
|
||||
async def test_configurable_propagates_to_stream_metadata() -> None:
|
||||
"""Regression: thread_id, run_id, assistant_id, graph_id,
|
||||
and langgraph_auth_user_id from configurable must appear
|
||||
in stream_mode='messages' metadata."""
|
||||
|
||||
def my_node(state):
|
||||
return {"messages": HumanMessage(content="hello")}
|
||||
|
||||
graph = (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("my_node", my_node)
|
||||
.add_edge(START, "my_node")
|
||||
.compile()
|
||||
)
|
||||
|
||||
config = {
|
||||
"configurable": {
|
||||
"thread_id": "th-123",
|
||||
"checkpoint_id": "ckpt-1",
|
||||
"checkpoint_ns": "ns-1",
|
||||
"task_id": "task-1",
|
||||
"run_id": "run-456",
|
||||
"assistant_id": "asst-789",
|
||||
"graph_id": "graph-0",
|
||||
"model": "gpt-4o",
|
||||
"user_id": "uid-1",
|
||||
"cron_id": "cron-1",
|
||||
"langgraph_auth_user_id": "user-1",
|
||||
# these should NOT be propagated into metadata
|
||||
"some_api_key": "secret",
|
||||
"custom_setting": {"nested": True},
|
||||
},
|
||||
}
|
||||
results = [
|
||||
chunk
|
||||
async for chunk in graph.astream(
|
||||
{"messages": []}, config, stream_mode="messages"
|
||||
)
|
||||
]
|
||||
assert len(results) == 1
|
||||
_, metadata = results[0]
|
||||
# propagated keys
|
||||
assert metadata["thread_id"] == "th-123"
|
||||
assert metadata["checkpoint_id"] == "ckpt-1"
|
||||
assert metadata["checkpoint_ns"] == "ns-1"
|
||||
assert metadata["task_id"] == "task-1"
|
||||
assert metadata["run_id"] == "run-456"
|
||||
assert metadata["assistant_id"] == "asst-789"
|
||||
assert metadata["graph_id"] == "graph-0"
|
||||
|
||||
# These will only be traced as of langgraph 1.2 and not present by default in
|
||||
# metadata
|
||||
# assert metadata["model"] == "gpt-4o"
|
||||
# assert metadata["user_id"] == "uid-1"
|
||||
# assert metadata["cron_id"] == "cron-1"
|
||||
# assert metadata["langgraph_auth_user_id"] == "user-1"
|
||||
# non-allowlisted keys must not appear
|
||||
assert "some_api_key" not in metadata
|
||||
assert "custom_setting" not in metadata
|
||||
|
||||
|
||||
async def test_stream_mode_messages_command() -> None:
|
||||
from langchain_core.messages import HumanMessage
|
||||
|
||||
|
||||
@@ -501,13 +501,13 @@ async def test_execution_info_populated_in_graph_async() -> None:
|
||||
assert isinstance(info.node_first_attempt_time, float)
|
||||
|
||||
|
||||
def test_server_info_from_configurable() -> None:
|
||||
"""server_info is built from assistant_id/graph_id in config configurable."""
|
||||
def test_server_info_from_metadata() -> None:
|
||||
"""server_info is built from assistant_id/graph_id in config metadata."""
|
||||
captured: dict[str, Any] = {}
|
||||
compiled = _make_capture_graph(captured)
|
||||
compiled.invoke(
|
||||
{"message": "hi"},
|
||||
config={"configurable": {"assistant_id": "asst-abc", "graph_id": "my-graph"}},
|
||||
config={"metadata": {"assistant_id": "asst-abc", "graph_id": "my-graph"}},
|
||||
)
|
||||
si = captured["server_info"]
|
||||
assert si is not None
|
||||
@@ -516,8 +516,8 @@ def test_server_info_from_configurable() -> None:
|
||||
assert si.user is None
|
||||
|
||||
|
||||
def test_server_info_none_without_configurable() -> None:
|
||||
"""server_info is None when no assistant_id/graph_id in configurable."""
|
||||
def test_server_info_none_without_metadata() -> None:
|
||||
"""server_info is None when no assistant_id/graph_id in metadata."""
|
||||
captured: dict[str, Any] = {}
|
||||
compiled = _make_capture_graph(captured)
|
||||
compiled.invoke({"message": "hi"})
|
||||
@@ -579,11 +579,8 @@ def test_server_info_user_from_auth_user() -> None:
|
||||
compiled.invoke(
|
||||
{"message": "hi"},
|
||||
config={
|
||||
"configurable": {
|
||||
"langgraph_auth_user": proxy,
|
||||
"assistant_id": "asst-proxy",
|
||||
"graph_id": "graph-proxy",
|
||||
},
|
||||
"configurable": {"langgraph_auth_user": proxy},
|
||||
"metadata": {"assistant_id": "asst-proxy", "graph_id": "graph-proxy"},
|
||||
},
|
||||
)
|
||||
si = captured["server_info"]
|
||||
|
||||
@@ -11,19 +11,13 @@ from typing import (
|
||||
TypeVar,
|
||||
Union,
|
||||
)
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
import langsmith
|
||||
import pytest
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from langchain_core.tracers import LangChainTracer
|
||||
from typing_extensions import NotRequired, Required, TypedDict
|
||||
|
||||
from langgraph._internal._config import (
|
||||
_is_not_empty,
|
||||
ensure_config,
|
||||
get_callback_manager_for_config,
|
||||
)
|
||||
from langgraph._internal._config import _is_not_empty, ensure_config
|
||||
from langgraph._internal._fields import (
|
||||
_is_optional_type,
|
||||
get_enhanced_type_hints,
|
||||
@@ -304,7 +298,7 @@ def test_is_not_empty() -> None:
|
||||
assert not _is_not_empty({})
|
||||
|
||||
|
||||
def test_configurable_metadata() -> None:
|
||||
def test_configurable_metadata():
|
||||
config = {
|
||||
"configurable": {
|
||||
"a-key": "foo",
|
||||
@@ -315,115 +309,11 @@ def test_configurable_metadata() -> None:
|
||||
"andme": 42,
|
||||
"nested": {"foo": "bar"},
|
||||
"nooverride": -2,
|
||||
"thread_id": "th-123",
|
||||
"checkpoint_id": "ckpt-1",
|
||||
"checkpoint_ns": "ns-1",
|
||||
"task_id": "task-1",
|
||||
"run_id": "run-456",
|
||||
"assistant_id": "asst-789",
|
||||
"graph_id": "graph-0",
|
||||
"model": "gpt-4o",
|
||||
"user_id": "uid-1",
|
||||
"cron_id": "cron-1",
|
||||
"langgraph_auth_user_id": "user-1",
|
||||
},
|
||||
"metadata": {"nooverride": 18},
|
||||
}
|
||||
expected = {"includeme", "andme", "nooverride"}
|
||||
merged = ensure_config(config)
|
||||
metadata = merged["metadata"]
|
||||
assert set(metadata) == {
|
||||
"nooverride",
|
||||
"assistant_id",
|
||||
"thread_id",
|
||||
"checkpoint_id",
|
||||
"run_id",
|
||||
"graph_id",
|
||||
"checkpoint_ns",
|
||||
"task_id",
|
||||
}
|
||||
assert metadata.keys() == expected
|
||||
assert metadata["nooverride"] == 18
|
||||
|
||||
|
||||
def test_callback_manager_copies_whitelisted_configurable_ids_to_metadata() -> None:
|
||||
config = {
|
||||
"configurable": {
|
||||
"thread_id": "th-123",
|
||||
"checkpoint_id": "ckpt-1",
|
||||
"checkpoint_ns": "ns-1",
|
||||
"task_id": "task-1",
|
||||
"run_id": "run-456",
|
||||
"assistant_id": "asst-789",
|
||||
"graph_id": "graph-0",
|
||||
"model": "gpt-4o",
|
||||
"user_id": "uid-1",
|
||||
"cron_id": "cron-1",
|
||||
"langgraph_auth_user_id": "user-1",
|
||||
},
|
||||
"metadata": {
|
||||
"thread_id": "from-metadata",
|
||||
"nooverride": 18,
|
||||
},
|
||||
}
|
||||
manager = ensure_config(config)
|
||||
callback_manager = get_callback_manager_for_config(manager)
|
||||
assert callback_manager.metadata == {
|
||||
"thread_id": "from-metadata",
|
||||
"nooverride": 18,
|
||||
"checkpoint_id": "ckpt-1",
|
||||
"checkpoint_ns": "ns-1",
|
||||
"task_id": "task-1",
|
||||
"run_id": "run-456",
|
||||
"assistant_id": "asst-789",
|
||||
"graph_id": "graph-0",
|
||||
}
|
||||
|
||||
|
||||
def test_callback_manager_copies_configurable_ids_to_tracing_metadata() -> None:
|
||||
tracer = LangChainTracer(client=MagicMock())
|
||||
config: RunnableConfig = {
|
||||
"configurable": {
|
||||
"thread_id": "th-123",
|
||||
"checkpoint_id": "ckpt-1",
|
||||
"checkpoint_ns": "ns-1",
|
||||
"task_id": "task-1",
|
||||
"run_id": "run-456",
|
||||
"assistant_id": "asst-789",
|
||||
"graph_id": "graph-0",
|
||||
"model": "gpt-4o",
|
||||
"user_id": "uid-1",
|
||||
"cron_id": "cron-1",
|
||||
"langgraph_auth_user_id": "user-1",
|
||||
"includeme": "hi",
|
||||
"andme": 42,
|
||||
"__dontinclude": "bar",
|
||||
"some_api_key": "secret",
|
||||
"custom_setting": {"nested": True},
|
||||
},
|
||||
"metadata": {
|
||||
"thread_id": "from-metadata",
|
||||
"user_id": "from-metadata-user",
|
||||
"includeme": "from-metadata",
|
||||
},
|
||||
"callbacks": [tracer],
|
||||
}
|
||||
|
||||
manager = ensure_config(config)
|
||||
callback_manager = get_callback_manager_for_config(manager)
|
||||
handlers = callback_manager.handlers
|
||||
tracers = [handler for handler in handlers if isinstance(handler, LangChainTracer)]
|
||||
assert len(tracers) == 1
|
||||
tracer = tracers[0]
|
||||
assert tracer.tracing_metadata == {
|
||||
"checkpoint_id": "ckpt-1",
|
||||
"checkpoint_ns": "ns-1",
|
||||
"task_id": "task-1",
|
||||
"run_id": "run-456",
|
||||
"assistant_id": "asst-789",
|
||||
"graph_id": "graph-0",
|
||||
"model": "gpt-4o",
|
||||
"cron_id": "cron-1",
|
||||
"andme": 42,
|
||||
"includeme": "hi",
|
||||
"thread_id": "th-123",
|
||||
"user_id": "uid-1",
|
||||
}
|
||||
|
||||
Generated
+58
-58
@@ -524,61 +524,61 @@ toml = [
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "46.0.7"
|
||||
version = "46.0.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi", marker = "python_full_version >= '3.11' and python_full_version < '3.14' and platform_python_implementation != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a4/ba/04b1bd4218cbc58dc90ce967106d51582371b898690f3ae0402876cc4f34/cryptography-46.0.6.tar.gz", hash = "sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759", size = 750542, upload-time = "2026-03-25T23:34:53.396Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/0c/dca8abb64e7ca4f6b2978769f6fea5ad06686a190cec381f0a796fdcaaba/cryptography-46.0.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f", size = 3476879, upload-time = "2026-04-08T01:57:38.664Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/ea/075aac6a84b7c271578d81a2f9968acb6e273002408729f2ddff517fed4a/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15", size = 4219700, upload-time = "2026-04-08T01:57:40.625Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/7b/1c55db7242b5e5612b29fc7a630e91ee7a6e3c8e7bf5406d22e206875fbd/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455", size = 4385982, upload-time = "2026-04-08T01:57:42.725Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/da/9870eec4b69c63ef5925bf7d8342b7e13bc2ee3d47791461c4e49ca212f4/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65", size = 4219115, upload-time = "2026-04-08T01:57:44.939Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/72/05aa5832b82dd341969e9a734d1812a6aadb088d9eb6f0430fc337cc5a8f/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968", size = 4385479, upload-time = "2026-04-08T01:57:46.86Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/2a/1b016902351a523aa2bd446b50a5bc1175d7a7d1cf90fe2ef904f9b84ebc/cryptography-46.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4", size = 3412829, upload-time = "2026-04-08T01:57:48.874Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/23/9285e15e3bc57325b0a72e592921983a701efc1ee8f91c06c5f0235d86d9/cryptography-46.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8", size = 7176401, upload-time = "2026-03-25T23:33:22.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/f8/e61f8f13950ab6195b31913b42d39f0f9afc7d93f76710f299b5ec286ae6/cryptography-46.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30", size = 4275275, upload-time = "2026-03-25T23:33:23.844Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/69/732a736d12c2631e140be2348b4ad3d226302df63ef64d30dfdb8db7ad1c/cryptography-46.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a", size = 4425320, upload-time = "2026-03-25T23:33:25.703Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/12/123be7292674abf76b21ac1fc0e1af50661f0e5b8f0ec8285faac18eb99e/cryptography-46.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175", size = 4278082, upload-time = "2026-03-25T23:33:27.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/ba/d5e27f8d68c24951b0a484924a84c7cdaed7502bac9f18601cd357f8b1d2/cryptography-46.0.6-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463", size = 4926514, upload-time = "2026-03-25T23:33:29.206Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/71/1ea5a7352ae516d5512d17babe7e1b87d9db5150b21f794b1377eac1edc0/cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97", size = 4457766, upload-time = "2026-03-25T23:33:30.834Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/59/562be1e653accee4fdad92c7a2e88fced26b3fdfce144047519bbebc299e/cryptography-46.0.6-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c", size = 3986535, upload-time = "2026-03-25T23:33:33.02Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/8b/b1ebfeb788bf4624d36e45ed2662b8bd43a05ff62157093c1539c1288a18/cryptography-46.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507", size = 4277618, upload-time = "2026-03-25T23:33:34.567Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/52/a005f8eabdb28df57c20f84c44d397a755782d6ff6d455f05baa2785bd91/cryptography-46.0.6-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19", size = 4890802, upload-time = "2026-03-25T23:33:37.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/4d/8e7d7245c79c617d08724e2efa397737715ca0ec830ecb3c91e547302555/cryptography-46.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738", size = 4457425, upload-time = "2026-03-25T23:33:38.904Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/5c/f6c3596a1430cec6f949085f0e1a970638d76f81c3ea56d93d564d04c340/cryptography-46.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c", size = 4405530, upload-time = "2026-03-25T23:33:40.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/c9/9f9cea13ee2dbde070424e0c4f621c091a91ffcc504ffea5e74f0e1daeff/cryptography-46.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f", size = 4667896, upload-time = "2026-03-25T23:33:42.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/b5/1895bc0821226f129bc74d00eccfc6a5969e2028f8617c09790bf89c185e/cryptography-46.0.6-cp311-abi3-win32.whl", hash = "sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2", size = 3026348, upload-time = "2026-03-25T23:33:45.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/f8/c9bcbf0d3e6ad288b9d9aa0b1dee04b063d19e8c4f871855a03ab3a297ab/cryptography-46.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124", size = 3483896, upload-time = "2026-03-25T23:33:46.649Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/41/3a578f7fd5c70611c0aacba52cd13cb364a5dee895a5c1d467208a9380b0/cryptography-46.0.6-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275", size = 7117147, upload-time = "2026-03-25T23:33:48.249Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/87/887f35a6fca9dde90cad08e0de0c89263a8e59b2d2ff904fd9fcd8025b6f/cryptography-46.0.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4", size = 4266221, upload-time = "2026-03-25T23:33:49.874Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/a8/0a90c4f0b0871e0e3d1ed126aed101328a8a57fd9fd17f00fb67e82a51ca/cryptography-46.0.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b", size = 4408952, upload-time = "2026-03-25T23:33:52.128Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/0b/b239701eb946523e4e9f329336e4ff32b1247e109cbab32d1a7b61da8ed7/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707", size = 4270141, upload-time = "2026-03-25T23:33:54.11Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/a8/976acdd4f0f30df7b25605f4b9d3d89295351665c2091d18224f7ad5cdbf/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361", size = 4904178, upload-time = "2026-03-25T23:33:55.725Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/1b/bf0e01a88efd0e59679b69f42d4afd5bced8700bb5e80617b2d63a3741af/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b", size = 4441812, upload-time = "2026-03-25T23:33:57.364Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/8b/11df86de2ea389c65aa1806f331cae145f2ed18011f30234cc10ca253de8/cryptography-46.0.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca", size = 3963923, upload-time = "2026-03-25T23:33:59.361Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/e0/207fb177c3a9ef6a8108f234208c3e9e76a6aa8cf20d51932916bd43bda0/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013", size = 4269695, upload-time = "2026-03-25T23:34:00.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/5e/19f3260ed1e95bced52ace7501fabcd266df67077eeb382b79c81729d2d3/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4", size = 4869785, upload-time = "2026-03-25T23:34:02.796Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/38/cd7864d79aa1d92ef6f1a584281433419b955ad5a5ba8d1eb6c872165bcb/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a", size = 4441404, upload-time = "2026-03-25T23:34:04.35Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/0a/4fe7a8d25fed74419f91835cf5829ade6408fd1963c9eae9c4bce390ecbb/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d", size = 4397549, upload-time = "2026-03-25T23:34:06.342Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/a0/7d738944eac6513cd60a8da98b65951f4a3b279b93479a7e8926d9cd730b/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736", size = 4651874, upload-time = "2026-03-25T23:34:07.916Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/f1/c2326781ca05208845efca38bf714f76939ae446cd492d7613808badedf1/cryptography-46.0.6-cp314-cp314t-win32.whl", hash = "sha256:97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed", size = 3001511, upload-time = "2026-03-25T23:34:09.892Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/57/fe4a23eb549ac9d903bd4698ffda13383808ef0876cc912bcb2838799ece/cryptography-46.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4", size = 3471692, upload-time = "2026-03-25T23:34:11.613Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/cc/f330e982852403da79008552de9906804568ae9230da8432f7496ce02b71/cryptography-46.0.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a", size = 7162776, upload-time = "2026-03-25T23:34:13.308Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/b3/dc27efd8dcc4bff583b3f01d4a3943cd8b5821777a58b3a6a5f054d61b79/cryptography-46.0.6-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8", size = 4270529, upload-time = "2026-03-25T23:34:15.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/05/e8d0e6eb4f0d83365b3cb0e00eb3c484f7348db0266652ccd84632a3d58d/cryptography-46.0.6-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77", size = 4414827, upload-time = "2026-03-25T23:34:16.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/97/daba0f5d2dc6d855e2dcb70733c812558a7977a55dd4a6722756628c44d1/cryptography-46.0.6-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290", size = 4271265, upload-time = "2026-03-25T23:34:18.586Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/06/fe1fce39a37ac452e58d04b43b0855261dac320a2ebf8f5260dd55b201a9/cryptography-46.0.6-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410", size = 4916800, upload-time = "2026-03-25T23:34:20.561Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/8a/b14f3101fe9c3592603339eb5d94046c3ce5f7fc76d6512a2d40efd9724e/cryptography-46.0.6-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d", size = 4448771, upload-time = "2026-03-25T23:34:22.406Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/b3/0796998056a66d1973fd52ee89dc1bb3b6581960a91ad4ac705f182d398f/cryptography-46.0.6-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70", size = 3978333, upload-time = "2026-03-25T23:34:24.281Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/3d/db200af5a4ffd08918cd55c08399dc6c9c50b0bc72c00a3246e099d3a849/cryptography-46.0.6-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d", size = 4271069, upload-time = "2026-03-25T23:34:25.895Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/18/61acfd5b414309d74ee838be321c636fe71815436f53c9f0334bf19064fa/cryptography-46.0.6-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa", size = 4878358, upload-time = "2026-03-25T23:34:27.67Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/65/5bf43286d566f8171917cae23ac6add941654ccf085d739195a4eacf1674/cryptography-46.0.6-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58", size = 4448061, upload-time = "2026-03-25T23:34:29.375Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/25/7e49c0fa7205cf3597e525d156a6bce5b5c9de1fd7e8cb01120e459f205a/cryptography-46.0.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb", size = 4399103, upload-time = "2026-03-25T23:34:32.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/46/466269e833f1c4718d6cd496ffe20c56c9c8d013486ff66b4f69c302a68d/cryptography-46.0.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72", size = 4659255, upload-time = "2026-03-25T23:34:33.679Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/09/ddc5f630cc32287d2c953fc5d32705e63ec73e37308e5120955316f53827/cryptography-46.0.6-cp38-abi3-win32.whl", hash = "sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c", size = 3010660, upload-time = "2026-03-25T23:34:35.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/82/ca4893968aeb2709aacfb57a30dec6fa2ab25b10fa9f064b8882ce33f599/cryptography-46.0.6-cp38-abi3-win_amd64.whl", hash = "sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f", size = 3471160, upload-time = "2026-03-25T23:34:37.191Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/84/7ccff00ced5bac74b775ce0beb7d1be4e8637536b522b5df9b73ada42da2/cryptography-46.0.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:2ea0f37e9a9cf0df2952893ad145fd9627d326a59daec9b0802480fa3bcd2ead", size = 3475444, upload-time = "2026-03-25T23:34:38.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/1f/4c926f50df7749f000f20eede0c896769509895e2648db5da0ed55db711d/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a3e84d5ec9ba01f8fd03802b2147ba77f0c8f2617b2aff254cedd551844209c8", size = 4218227, upload-time = "2026-03-25T23:34:40.871Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/65/707be3ffbd5f786028665c3223e86e11c4cda86023adbc56bd72b1b6bab5/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:12f0fa16cc247b13c43d56d7b35287ff1569b5b1f4c5e87e92cc4fcc00cd10c0", size = 4381399, upload-time = "2026-03-25T23:34:42.609Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/6d/73557ed0ef7d73d04d9aba745d2c8e95218213687ee5e76b7d236a5030fc/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:50575a76e2951fe7dbd1f56d181f8c5ceeeb075e9ff88e7ad997d2f42af06e7b", size = 4217595, upload-time = "2026-03-25T23:34:44.205Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/c5/e1594c4eec66a567c3ac4400008108a415808be2ce13dcb9a9045c92f1a0/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:90e5f0a7b3be5f40c3a0a0eafb32c681d8d2c181fc2a1bdabe9b3f611d9f6b1a", size = 4380912, upload-time = "2026-03-25T23:34:46.328Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/89/843b53614b47f97fe1abc13f9a86efa5ec9e275292c457af1d4a60dc80e0/cryptography-46.0.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6728c49e3b2c180ef26f8e9f0a883a2c585638db64cf265b49c9ba10652d430e", size = 3409955, upload-time = "2026-03-25T23:34:48.465Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1348,7 +1348,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langchain-core"
|
||||
version = "1.3.0a2"
|
||||
version = "1.2.22"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jsonpatch" },
|
||||
@@ -1360,14 +1360,14 @@ dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "uuid-utils" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/af/bc/0bff31fcaff174d86031cc713471a3e85ed4ec8e5cd95ad0217f2aced20e/langchain_core-1.3.0a2.tar.gz", hash = "sha256:52d978c84552b74b9a3f16c1fced84f9e27cc96d7a67c601925ce6cbc4ea3cf9", size = 854580, upload-time = "2026-04-13T14:37:55.745Z" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/14/03c09686602567059f26af29de0c44546a83af2f2aa29925e61040e43ea2/langchain_core-1.3.0a2-py3-none-any.whl", hash = "sha256:9e929a34f0b0c6c1255e395a1de34f8626893ceb4cdae550a22a0bd18c87be54", size = 510233, upload-time = "2026-04-13T14:37:54.277Z" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langgraph"
|
||||
version = "1.1.7a2"
|
||||
version = "1.1.6"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -1439,7 +1439,7 @@ test = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "langchain-core", specifier = "==1.3.0a2" },
|
||||
{ name = "langchain-core", specifier = ">=0.1" },
|
||||
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
|
||||
{ name = "langgraph-prebuilt", editable = "../prebuilt" },
|
||||
{ name = "langgraph-sdk", editable = "../sdk-py" },
|
||||
@@ -2905,7 +2905,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.3"
|
||||
version = "9.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
@@ -2916,9 +2916,9 @@ dependencies = [
|
||||
{ name = "pygments" },
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Generated
+8
-8
@@ -249,7 +249,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langchain-core"
|
||||
version = "1.3.0a2"
|
||||
version = "1.2.25"
|
||||
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/af/bc/0bff31fcaff174d86031cc713471a3e85ed4ec8e5cd95ad0217f2aced20e/langchain_core-1.3.0a2.tar.gz", hash = "sha256:52d978c84552b74b9a3f16c1fced84f9e27cc96d7a67c601925ce6cbc4ea3cf9", size = 854580, upload-time = "2026-04-13T14:37:55.745Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/86/2a/d65de24fc9b7989137253da8973f850f3e39b4ce3e0377bc8200d6b3c189/langchain_core-1.2.25.tar.gz", hash = "sha256:77e032b96509d0eb1f6875042fdf97b7e2334a815314700c6894d9d078909b9c", size = 842347, upload-time = "2026-04-02T22:39:11.528Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/14/03c09686602567059f26af29de0c44546a83af2f2aa29925e61040e43ea2/langchain_core-1.3.0a2-py3-none-any.whl", hash = "sha256:9e929a34f0b0c6c1255e395a1de34f8626893ceb4cdae550a22a0bd18c87be54", size = 510233, upload-time = "2026-04-13T14:37:54.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/0e/7b31b0249f9b9b0fc7829d5b0ee484b8f8d43c78e376e9951e2ef3eac70c/langchain_core-1.2.25-py3-none-any.whl", hash = "sha256:0c05bf395aec6d2dfa14488fd006f7bcd0540e7e89287e04f92203532a82c828", size = 506866, upload-time = "2026-04-02T22:39:10.137Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langgraph"
|
||||
version = "1.1.7a2"
|
||||
version = "1.1.6"
|
||||
source = { editable = "../langgraph" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -281,7 +281,7 @@ dependencies = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "langchain-core", specifier = "==1.3.0a2" },
|
||||
{ name = "langchain-core", specifier = ">=0.1" },
|
||||
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
|
||||
{ name = "langgraph-prebuilt", editable = "." },
|
||||
{ name = "langgraph-sdk", editable = "../sdk-py" },
|
||||
@@ -1182,7 +1182,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.3"
|
||||
version = "9.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
@@ -1193,9 +1193,9 @@ dependencies = [
|
||||
{ name = "pygments" },
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Generated
+8
-8
@@ -262,7 +262,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langchain-core"
|
||||
version = "1.3.0a2"
|
||||
version = "1.2.22"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jsonpatch" },
|
||||
@@ -274,14 +274,14 @@ dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "uuid-utils" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/af/bc/0bff31fcaff174d86031cc713471a3e85ed4ec8e5cd95ad0217f2aced20e/langchain_core-1.3.0a2.tar.gz", hash = "sha256:52d978c84552b74b9a3f16c1fced84f9e27cc96d7a67c601925ce6cbc4ea3cf9", size = 854580, upload-time = "2026-04-13T14:37:55.745Z" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/14/03c09686602567059f26af29de0c44546a83af2f2aa29925e61040e43ea2/langchain_core-1.3.0a2-py3-none-any.whl", hash = "sha256:9e929a34f0b0c6c1255e395a1de34f8626893ceb4cdae550a22a0bd18c87be54", size = 510233, upload-time = "2026-04-13T14:37:54.277Z" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langgraph"
|
||||
version = "1.1.7a2"
|
||||
version = "1.1.6"
|
||||
source = { editable = "../langgraph" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -294,7 +294,7 @@ dependencies = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "langchain-core", specifier = "==1.3.0a2" },
|
||||
{ name = "langchain-core", specifier = ">=0.1" },
|
||||
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
|
||||
{ name = "langgraph-prebuilt", editable = "../prebuilt" },
|
||||
{ name = "langgraph-sdk", editable = "." },
|
||||
@@ -1000,7 +1000,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.3"
|
||||
version = "9.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
@@ -1011,9 +1011,9 @@ dependencies = [
|
||||
{ name = "pygments" },
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user