mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-10-02 14:35:18 +02:00
Compare commits
68
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4826448e1a | ||
|
|
8ea279b8ad | ||
|
|
eb2f09e321 | ||
|
|
26d279a0ac | ||
|
|
e850b21d08 | ||
|
|
6a92b7ff3c | ||
|
|
207dccf5b3 | ||
|
|
0623e4690c | ||
|
|
1366210740 | ||
|
|
b53c47675d | ||
|
|
1aeafeeebd | ||
|
|
ba2b2f4a6f | ||
|
|
61fb3563b4 | ||
|
|
63528f25af | ||
|
|
a59b3f1fee | ||
|
|
eeaac6d80d | ||
|
|
bb41c66547 | ||
|
|
52b586370d | ||
|
|
a3823395cf | ||
|
|
c1e62bad8a | ||
|
|
63841de505 | ||
|
|
0fe365ec4c | ||
|
|
1de3d82598 | ||
|
|
79a75645ca | ||
|
|
cdda595e6e | ||
|
|
7895051c96 | ||
|
|
901ab6b3f8 | ||
|
|
adb953ddd4 | ||
|
|
5ddfce1814 | ||
|
|
1f31e0b9b6 | ||
|
|
1b37ece92f | ||
|
|
e2e90da5dc | ||
|
|
d542d8aecb | ||
|
|
a04ec5d6f0 | ||
|
|
50df7d423a | ||
|
|
c4a4a46473 | ||
|
|
f178eb821e | ||
|
|
48167d7fec | ||
|
|
806878a421 | ||
|
|
8087e6a42c | ||
|
|
8fbdb14487 | ||
|
|
5093802f31 | ||
|
|
b89ef60b91 | ||
|
|
672da815a3 | ||
|
|
b704e41632 | ||
|
|
ea7906b177 | ||
|
|
ef7897e12e | ||
|
|
e9fed2798e | ||
|
|
ed293f16d6 | ||
|
|
f702729e04 | ||
|
|
b0f14649e0 | ||
|
|
ea20432b9b | ||
|
|
e2efab8061 | ||
|
|
9babffa054 | ||
|
|
73cebea3c2 | ||
|
|
b73b2d19eb | ||
|
|
ca26805b5f | ||
|
|
5ac837d7cd | ||
|
|
c4f5861166 | ||
|
|
172238b2d5 | ||
|
|
095da17833 | ||
|
|
e931c68669 | ||
|
|
666c224c2d | ||
|
|
21a6f41e0a | ||
|
|
acdf85aba6 | ||
|
|
9b18243fa6 | ||
|
|
0225b998af | ||
|
|
bec122d4a2 |
@@ -0,0 +1,5 @@
|
||||
<svg width="472" height="100" viewBox="0 0 472 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="100" y="6.10352e-05" width="100" height="100" rx="20" transform="rotate(90 100 6.10352e-05)" fill="#161F34"/>
|
||||
<path d="M32.1494 67.8579H45.2266C45.2246 75.0778 39.3716 80.93 32.1514 80.9302C24.9301 80.9301 19.0756 75.0762 19.0752 67.855C19.0752 60.6341 24.9288 54.78 32.1494 54.7788V67.8579ZM67.8691 54.7788C75.0906 54.779 80.9443 60.6335 80.9443 67.855C80.944 75.0762 75.0904 80.93 67.8691 80.9302C60.6488 80.9301 54.7949 75.0778 54.793 67.8579H67.8594V54.7788C67.8626 54.7788 67.8659 54.7788 67.8691 54.7788ZM67.8691 19.0757C75.0906 19.0759 80.9443 24.9304 80.9443 32.1519C80.944 39.3731 75.0904 45.2269 67.8691 45.2271C67.8659 45.2271 67.8626 45.2261 67.8594 45.2261V32.1479H54.793C54.795 24.9281 60.6489 19.0758 67.8691 19.0757ZM32.1514 19.0757C39.3716 19.0759 45.2246 24.9281 45.2266 32.1479H32.1494V45.2261C24.929 45.2249 19.0755 39.3725 19.0752 32.1519C19.0752 24.9303 24.9299 19.0758 32.1514 19.0757Z" fill="#7FC8FF"/>
|
||||
<path d="M142.427 70.248V65.748H153.227V32.748H142.427V28.248H158.147V65.748H168.947V70.248H142.427ZM189.174 70.608C182.454 70.608 177.894 67.248 177.894 61.668C177.894 55.548 182.154 52.128 190.194 52.128H199.194V50.028C199.194 46.068 196.374 43.668 191.574 43.668C187.254 43.668 184.374 45.708 183.774 48.828H178.854C179.574 42.828 184.434 39.288 191.814 39.288C199.614 39.288 204.114 43.188 204.114 50.328V63.708C204.114 65.328 204.714 65.748 206.094 65.748H207.654V70.248H204.954C200.874 70.248 199.494 68.508 199.434 65.508C197.514 68.268 194.454 70.608 189.174 70.608ZM189.534 66.408C195.654 66.408 199.194 62.868 199.194 57.768V56.268H189.714C185.334 56.268 182.874 57.888 182.874 61.368C182.874 64.368 185.454 66.408 189.534 66.408ZM216.601 70.248V39.648H220.861L221.521 43.788C223.321 41.448 226.321 39.288 231.121 39.288C237.601 39.288 243.001 42.948 243.001 52.848V70.248H238.081V53.148C238.081 47.028 235.201 43.788 230.281 43.788C224.941 43.788 221.521 47.928 221.521 53.988V70.248H216.601ZM266.348 82.608C258.548 82.608 253.088 78.948 252.308 72.228H257.348C258.188 76.068 261.608 78.228 266.708 78.228C273.128 78.228 276.608 75.228 276.608 68.568V64.968C274.568 68.448 271.268 70.608 266.108 70.608C257.648 70.608 251.408 64.908 251.408 54.948C251.408 45.588 257.648 39.288 266.108 39.288C271.268 39.288 274.688 41.508 276.608 44.928L277.268 39.648H281.528V68.748C281.528 77.568 276.848 82.608 266.348 82.608ZM266.588 66.228C272.588 66.228 276.668 61.608 276.668 55.068C276.668 48.348 272.588 43.668 266.588 43.668C260.528 43.668 256.448 48.288 256.448 54.948C256.448 61.608 260.528 66.228 266.588 66.228ZM303.555 82.608C295.755 82.608 290.295 78.948 289.515 72.228H294.555C295.395 76.068 298.815 78.228 303.915 78.228C310.335 78.228 313.815 75.228 313.815 68.568V64.968C311.775 68.448 308.475 70.608 303.315 70.608C294.855 70.608 288.615 64.908 288.615 54.948C288.615 45.588 294.855 39.288 303.315 39.288C308.475 39.288 311.895 41.508 313.815 44.928L314.475 39.648H318.735V68.748C318.735 77.568 314.055 82.608 303.555 82.608ZM303.795 66.228C309.795 66.228 313.875 61.608 313.875 55.068C313.875 48.348 309.795 43.668 303.795 43.668C297.735 43.668 293.655 48.288 293.655 54.948C293.655 61.608 297.735 66.228 303.795 66.228ZM327.862 70.248V65.748H335.422V44.148H327.862V39.648H340.222V44.928C341.602 42.588 344.482 39.648 350.482 39.648H355.582V44.448H349.942C342.562 44.448 340.342 49.968 340.342 54.828V65.748H353.902V70.248H327.862ZM375.209 70.608C368.489 70.608 363.929 67.248 363.929 61.668C363.929 55.548 368.189 52.128 376.229 52.128H385.229V50.028C385.229 46.068 382.409 43.668 377.609 43.668C373.289 43.668 370.409 45.708 369.809 48.828H364.889C365.609 42.828 370.469 39.288 377.849 39.288C385.649 39.288 390.149 43.188 390.149 50.328V63.708C390.149 65.328 390.749 65.748 392.129 65.748H393.689V70.248H390.989C386.909 70.248 385.529 68.508 385.469 65.508C383.549 68.268 380.489 70.608 375.209 70.608ZM375.569 66.408C381.689 66.408 385.229 62.868 385.229 57.768V56.268H375.749C371.369 56.268 368.909 57.888 368.909 61.368C368.909 64.368 371.489 66.408 375.569 66.408ZM401.076 82.248V39.648H405.336L405.996 44.568C408.036 41.748 411.336 39.288 416.496 39.288C424.956 39.288 431.196 44.988 431.196 54.948C431.196 64.308 424.956 70.608 416.496 70.608C411.336 70.608 407.856 68.508 405.996 65.568V82.248H401.076ZM416.016 66.228C422.076 66.228 426.156 61.608 426.156 54.948C426.156 48.288 422.076 43.668 416.016 43.668C410.016 43.668 405.936 48.288 405.936 54.828C405.936 61.548 410.016 66.228 416.016 66.228ZM439.663 70.248V28.248H444.583V43.788C446.863 40.968 450.403 39.288 454.363 39.288C462.043 39.288 466.423 44.388 466.423 53.208V70.248H461.503V53.508C461.503 47.268 458.623 43.788 453.523 43.788C448.063 43.788 444.583 48.108 444.583 54.948V70.248H439.663Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.7 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg width="472" height="100" viewBox="0 0 472 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="100" width="100" height="100" rx="20" transform="rotate(90 100 0)" fill="#161F34"/>
|
||||
<path d="M32.1494 67.8578H45.2266C45.2246 75.0776 39.3716 80.9299 32.1514 80.9301C24.9301 80.9299 19.0756 75.0761 19.0752 67.8549C19.0752 60.634 24.9288 54.7799 32.1494 54.7787V67.8578ZM67.8691 54.7787C75.0906 54.7789 80.9443 60.6334 80.9443 67.8549C80.944 75.076 75.0904 80.9299 67.8691 80.9301C60.6488 80.9299 54.7949 75.0777 54.793 67.8578H67.8594V54.7787C67.8626 54.7787 67.8659 54.7787 67.8691 54.7787ZM67.8691 19.0756C75.0906 19.0758 80.9443 24.9303 80.9443 32.1517C80.944 39.373 75.0904 45.2267 67.8691 45.2269C67.8659 45.2269 67.8626 45.226 67.8594 45.226V32.1478H54.793C54.795 24.928 60.6489 19.0757 67.8691 19.0756ZM32.1514 19.0756C39.3716 19.0757 45.2246 24.928 45.2266 32.1478H32.1494V45.226C24.929 45.2248 19.0755 39.3724 19.0752 32.1517C19.0752 24.9302 24.9299 19.0757 32.1514 19.0756Z" fill="#7FC8FF"/>
|
||||
<path d="M142.427 70.248V65.748H153.227V32.748H142.427V28.248H158.147V65.748H168.947V70.248H142.427ZM189.174 70.608C182.454 70.608 177.894 67.248 177.894 61.668C177.894 55.548 182.154 52.128 190.194 52.128H199.194V50.028C199.194 46.068 196.374 43.668 191.574 43.668C187.254 43.668 184.374 45.708 183.774 48.828H178.854C179.574 42.828 184.434 39.288 191.814 39.288C199.614 39.288 204.114 43.188 204.114 50.328V63.708C204.114 65.328 204.714 65.748 206.094 65.748H207.654V70.248H204.954C200.874 70.248 199.494 68.508 199.434 65.508C197.514 68.268 194.454 70.608 189.174 70.608ZM189.534 66.408C195.654 66.408 199.194 62.868 199.194 57.768V56.268H189.714C185.334 56.268 182.874 57.888 182.874 61.368C182.874 64.368 185.454 66.408 189.534 66.408ZM216.601 70.248V39.648H220.861L221.521 43.788C223.321 41.448 226.321 39.288 231.121 39.288C237.601 39.288 243.001 42.948 243.001 52.848V70.248H238.081V53.148C238.081 47.028 235.201 43.788 230.281 43.788C224.941 43.788 221.521 47.928 221.521 53.988V70.248H216.601ZM266.348 82.608C258.548 82.608 253.088 78.948 252.308 72.228H257.348C258.188 76.068 261.608 78.228 266.708 78.228C273.128 78.228 276.608 75.228 276.608 68.568V64.968C274.568 68.448 271.268 70.608 266.108 70.608C257.648 70.608 251.408 64.908 251.408 54.948C251.408 45.588 257.648 39.288 266.108 39.288C271.268 39.288 274.688 41.508 276.608 44.928L277.268 39.648H281.528V68.748C281.528 77.568 276.848 82.608 266.348 82.608ZM266.588 66.228C272.588 66.228 276.668 61.608 276.668 55.068C276.668 48.348 272.588 43.668 266.588 43.668C260.528 43.668 256.448 48.288 256.448 54.948C256.448 61.608 260.528 66.228 266.588 66.228ZM303.555 82.608C295.755 82.608 290.295 78.948 289.515 72.228H294.555C295.395 76.068 298.815 78.228 303.915 78.228C310.335 78.228 313.815 75.228 313.815 68.568V64.968C311.775 68.448 308.475 70.608 303.315 70.608C294.855 70.608 288.615 64.908 288.615 54.948C288.615 45.588 294.855 39.288 303.315 39.288C308.475 39.288 311.895 41.508 313.815 44.928L314.475 39.648H318.735V68.748C318.735 77.568 314.055 82.608 303.555 82.608ZM303.795 66.228C309.795 66.228 313.875 61.608 313.875 55.068C313.875 48.348 309.795 43.668 303.795 43.668C297.735 43.668 293.655 48.288 293.655 54.948C293.655 61.608 297.735 66.228 303.795 66.228ZM327.862 70.248V65.748H335.422V44.148H327.862V39.648H340.222V44.928C341.602 42.588 344.482 39.648 350.482 39.648H355.582V44.448H349.942C342.562 44.448 340.342 49.968 340.342 54.828V65.748H353.902V70.248H327.862ZM375.209 70.608C368.489 70.608 363.929 67.248 363.929 61.668C363.929 55.548 368.189 52.128 376.229 52.128H385.229V50.028C385.229 46.068 382.409 43.668 377.609 43.668C373.289 43.668 370.409 45.708 369.809 48.828H364.889C365.609 42.828 370.469 39.288 377.849 39.288C385.649 39.288 390.149 43.188 390.149 50.328V63.708C390.149 65.328 390.749 65.748 392.129 65.748H393.689V70.248H390.989C386.909 70.248 385.529 68.508 385.469 65.508C383.549 68.268 380.489 70.608 375.209 70.608ZM375.569 66.408C381.689 66.408 385.229 62.868 385.229 57.768V56.268H375.749C371.369 56.268 368.909 57.888 368.909 61.368C368.909 64.368 371.489 66.408 375.569 66.408ZM401.076 82.248V39.648H405.336L405.996 44.568C408.036 41.748 411.336 39.288 416.496 39.288C424.956 39.288 431.196 44.988 431.196 54.948C431.196 64.308 424.956 70.608 416.496 70.608C411.336 70.608 407.856 68.508 405.996 65.568V82.248H401.076ZM416.016 66.228C422.076 66.228 426.156 61.608 426.156 54.948C426.156 48.288 422.076 43.668 416.016 43.668C410.016 43.668 405.936 48.288 405.936 54.828C405.936 61.548 410.016 66.228 416.016 66.228ZM439.663 70.248V28.248H444.583V43.788C446.863 40.968 450.403 39.288 454.363 39.288C462.043 39.288 466.423 44.388 466.423 53.208V70.248H461.503V53.508C461.503 47.268 458.623 43.788 453.523 43.788C448.063 43.788 444.583 48.108 444.583 54.948V70.248H439.663Z" fill="#161F34"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.7 KiB |
@@ -31,6 +31,8 @@ jobs:
|
||||
workdir: libs/cli/examples/graphs_reqs_b
|
||||
tag: langgraph-test-d
|
||||
name: "CLI integration test"
|
||||
env:
|
||||
HAS_LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY != '' }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: libs/cli
|
||||
@@ -38,11 +40,12 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
if: github.event_name != 'workflow_dispatch'
|
||||
uses: Ana06/get-changed-files@v2.3.0
|
||||
with:
|
||||
filter: "libs/cli/**"
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
if: steps.changed-files.outputs.all
|
||||
if: (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch')
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
@@ -50,15 +53,15 @@ jobs:
|
||||
cache-suffix: "cli-integration-test"
|
||||
ignore-nothing-to-cache: true
|
||||
- name: Install cli globally
|
||||
if: steps.changed-files.outputs.all
|
||||
if: (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch')
|
||||
run: pip install -e .
|
||||
- name: Build service ${{ matrix.example.name }}
|
||||
if: steps.changed-files.outputs.all
|
||||
if: (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch')
|
||||
working-directory: ${{ matrix.example.workdir }}
|
||||
run: |
|
||||
langgraph build -t ${{ matrix.example.tag }}
|
||||
- name: Test service ${{ matrix.example.name }}
|
||||
if: ${{ steps.changed-files.outputs.all && secrets.LANGSMITH_API_KEY != '' }}
|
||||
if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&env.HAS_LANGSMITH_API_KEY == 'true' }}
|
||||
working-directory: ${{ matrix.example.workdir }}
|
||||
env:
|
||||
LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }}
|
||||
@@ -72,24 +75,24 @@ jobs:
|
||||
timeout 60 python "$REPO_ROOT/.github/scripts/run_langgraph_cli_test.py" -t ${{ matrix.example.tag }}
|
||||
|
||||
- name: Build JS service
|
||||
if: ${{ steps.changed-files.outputs.all && matrix.example.name == 'A' }}
|
||||
if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' }}
|
||||
working-directory: libs/cli/js-examples
|
||||
run: |
|
||||
langgraph build -t langgraph-test-e
|
||||
|
||||
- name: Build JS monorepo service
|
||||
if: ${{ steps.changed-files.outputs.all && matrix.example.name == 'A' }}
|
||||
if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' }}
|
||||
working-directory: libs/cli/js-monorepo-example
|
||||
run: |
|
||||
langgraph build -t langgraph-test-f -c apps/agent/langgraph.json --build-command "yarn run turbo build" --install-command "yarn install"
|
||||
|
||||
- name: Build Python monorepo service
|
||||
if: ${{ steps.changed-files.outputs.all && matrix.example.name == 'A' }}
|
||||
if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' }}
|
||||
working-directory: libs/cli/python-monorepo-example
|
||||
run: |
|
||||
langgraph build -t langgraph-test-g -c apps/agent/langgraph.json
|
||||
- name: Test Python monorepo service
|
||||
if: ${{ steps.changed-files.outputs.all && matrix.example.name == 'A' && secrets.LANGSMITH_API_KEY != '' }}
|
||||
if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' && env.HAS_LANGSMITH_API_KEY == 'true' }}
|
||||
working-directory: libs/cli/python-monorepo-example
|
||||
env:
|
||||
LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }}
|
||||
@@ -99,12 +102,12 @@ jobs:
|
||||
timeout 60 python ../../../.github/scripts/run_langgraph_cli_test.py -t langgraph-test-g -c apps/agent/langgraph.json
|
||||
|
||||
- name: Build prerelease reqs service
|
||||
if: ${{ steps.changed-files.outputs.all && matrix.example.name == 'A' }}
|
||||
if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' }}
|
||||
working-directory: libs/cli/examples/graph_prerelease_reqs
|
||||
run: |
|
||||
langgraph build -t langgraph-test-h
|
||||
- name: Test prerelease reqs service
|
||||
if: ${{ steps.changed-files.outputs.all && matrix.example.name == 'A' && secrets.LANGSMITH_API_KEY != '' }}
|
||||
if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' && env.HAS_LANGSMITH_API_KEY == 'true' }}
|
||||
working-directory: libs/cli/examples/graph_prerelease_reqs
|
||||
env:
|
||||
LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }}
|
||||
@@ -130,7 +133,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Build and test prerelease reqs fail service
|
||||
if: ${{ steps.changed-files.outputs.all && matrix.example.name == 'A' }}
|
||||
if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' }}
|
||||
working-directory: libs/cli/examples/graph_prerelease_reqs_fail
|
||||
run: |
|
||||
langgraph build -t langgraph-test-i || [ $? -eq 1 ]
|
||||
|
||||
@@ -34,11 +34,12 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
if: github.event_name != 'workflow_dispatch'
|
||||
uses: Ana06/get-changed-files@v2.3.0
|
||||
with:
|
||||
filter: "${{ inputs.working-directory }}/**"
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
if: steps.changed-files.outputs.all
|
||||
if: steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch'
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
@@ -46,12 +47,12 @@ jobs:
|
||||
cache-suffix: lint-${{ inputs.working-directory }}
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.changed-files.outputs.all
|
||||
if: steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch'
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
run: uv sync --frozen --group lint
|
||||
|
||||
- name: Get .mypy_cache to speed up mypy
|
||||
if: steps.changed-files.outputs.all
|
||||
if: steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch'
|
||||
uses: actions/cache@v5
|
||||
env:
|
||||
SEGMENT_DOWNLOAD_TIMEOUT_MIN: "2"
|
||||
@@ -61,7 +62,7 @@ jobs:
|
||||
key: mypy-lint-${{ runner.os }}-${{ runner.arch }}-py${{ matrix.python-version }}-${{ inputs.working-directory }}-${{ hashFiles(format('{0}/uv.lock', inputs.working-directory)) }}
|
||||
|
||||
- name: Analysing package code with our lint
|
||||
if: steps.changed-files.outputs.all
|
||||
if: steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch'
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
run: |
|
||||
if make lint_package > /dev/null 2>&1; then
|
||||
@@ -72,12 +73,12 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Install test dependencies
|
||||
if: steps.changed-files.outputs.all
|
||||
if: steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch'
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
run: uv sync --group lint
|
||||
|
||||
- name: Get .mypy_cache_test to speed up mypy
|
||||
if: steps.changed-files.outputs.all
|
||||
if: steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch'
|
||||
uses: actions/cache@v5
|
||||
env:
|
||||
SEGMENT_DOWNLOAD_TIMEOUT_MIN: "2"
|
||||
@@ -87,7 +88,7 @@ jobs:
|
||||
key: mypy-test-${{ runner.os }}-${{ runner.arch }}-py${{ matrix.python-version }}-${{ inputs.working-directory }}-${{ hashFiles(format('{0}/uv.lock', inputs.working-directory)) }}
|
||||
|
||||
- name: Analysing tests with our lint
|
||||
if: steps.changed-files.outputs.all
|
||||
if: steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch'
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
run: |
|
||||
if make lint_tests > /dev/null 2>&1; then
|
||||
|
||||
@@ -45,6 +45,13 @@ jobs:
|
||||
shell: bash
|
||||
run: make test_parallel
|
||||
|
||||
- name: Run strict msgpack pregel tests
|
||||
if: ${{ matrix.python-version == '3.13' }}
|
||||
shell: bash
|
||||
env:
|
||||
LANGGRAPH_STRICT_MSGPACK: "true"
|
||||
run: make test TEST="tests/test_pregel.py tests/test_pregel_async.py"
|
||||
|
||||
- name: Ensure the tests did not create any additional files
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -24,11 +26,12 @@ jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
python: ${{ steps.filter.outputs.python }}
|
||||
deps: ${{ steps.filter.outputs.deps }}
|
||||
python: ${{ steps.filter.outputs.python || 'true' }}
|
||||
deps: ${{ steps.filter.outputs.deps || 'true' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dorny/paths-filter@v3
|
||||
if: github.event_name != 'workflow_dispatch'
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
|
||||
@@ -1,331 +0,0 @@
|
||||
---
|
||||
name: CLI Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'cli-v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag (e.g., cli-v0.2.10)'
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write # OIDC trusted publishing for PyPI
|
||||
|
||||
concurrency:
|
||||
group: cli-release
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
# ──────────────────────────────────────────────
|
||||
# Build native binaries for each platform
|
||||
# ──────────────────────────────────────────────
|
||||
build:
|
||||
name: Build - ${{ matrix.name }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: libs/cli
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: linux-x64
|
||||
runner: ubuntu-latest
|
||||
target: x86_64-unknown-linux-gnu
|
||||
binary: langgraph
|
||||
npm_pkg: langgraph-cli-linux-x64
|
||||
|
||||
- name: linux-arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
target: aarch64-unknown-linux-gnu
|
||||
binary: langgraph
|
||||
npm_pkg: langgraph-cli-linux-arm64
|
||||
|
||||
- name: darwin-x64
|
||||
runner: macos-13 # Intel
|
||||
target: x86_64-apple-darwin
|
||||
binary: langgraph
|
||||
npm_pkg: langgraph-cli-darwin-x64
|
||||
|
||||
- name: darwin-arm64
|
||||
runner: macos-latest # Apple Silicon
|
||||
target: aarch64-apple-darwin
|
||||
binary: langgraph
|
||||
npm_pkg: langgraph-cli-darwin-arm64
|
||||
|
||||
- name: win32-x64
|
||||
runner: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
binary: langgraph.exe
|
||||
npm_pkg: langgraph-cli-win32-x64
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: libs/cli
|
||||
key: ${{ matrix.target }}
|
||||
|
||||
- name: Build release binary
|
||||
run: cargo build --release --target ${{ matrix.target }}
|
||||
|
||||
- name: Run tests
|
||||
if: matrix.name != 'linux-arm64' # skip on ARM (slow QEMU)
|
||||
run: cargo test --release --target ${{ matrix.target }}
|
||||
|
||||
# Upload raw binary for GitHub release
|
||||
- name: Prepare binary artifact
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p dist
|
||||
cp target/${{ matrix.target }}/release/${{ matrix.binary }} dist/
|
||||
cd dist
|
||||
if [[ "${{ matrix.name }}" == win32-* ]]; then
|
||||
7z a ../langgraph-${{ matrix.name }}.zip ${{ matrix.binary }}
|
||||
else
|
||||
tar czf ../langgraph-${{ matrix.name }}.tar.gz ${{ matrix.binary }}
|
||||
fi
|
||||
|
||||
- name: Upload binary artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: binary-${{ matrix.name }}
|
||||
path: |
|
||||
libs/cli/langgraph-${{ matrix.name }}.tar.gz
|
||||
libs/cli/langgraph-${{ matrix.name }}.zip
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Prepare npm platform package
|
||||
- name: Prepare npm platform package
|
||||
shell: bash
|
||||
run: |
|
||||
cp target/${{ matrix.target }}/release/${{ matrix.binary }} npm/${{ matrix.npm_pkg }}/bin/
|
||||
|
||||
- name: Upload npm platform package
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: npm-${{ matrix.name }}
|
||||
path: libs/cli/npm/${{ matrix.npm_pkg }}/
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Build Linux musl (Alpine-compatible) binary
|
||||
# ──────────────────────────────────────────────
|
||||
build-musl:
|
||||
name: Build - linux-x64-musl
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: libs/cli
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: x86_64-unknown-linux-musl
|
||||
|
||||
- name: Install musl tools
|
||||
run: sudo apt-get update && sudo apt-get install -y musl-tools
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: libs/cli
|
||||
key: x86_64-unknown-linux-musl
|
||||
|
||||
- name: Build release binary
|
||||
run: cargo build --release --target x86_64-unknown-linux-musl
|
||||
|
||||
- name: Prepare binary artifact
|
||||
run: |
|
||||
mkdir -p dist
|
||||
cp target/x86_64-unknown-linux-musl/release/langgraph dist/
|
||||
cd dist && tar czf ../langgraph-linux-x64-musl.tar.gz langgraph
|
||||
|
||||
- name: Upload binary artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: binary-linux-x64-musl
|
||||
path: libs/cli/langgraph-linux-x64-musl.tar.gz
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Build PyPI wheels via maturin
|
||||
# ──────────────────────────────────────────────
|
||||
pypi-wheels:
|
||||
name: PyPI wheel - ${{ matrix.name }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: linux-x64
|
||||
runner: ubuntu-latest
|
||||
target: x86_64-unknown-linux-gnu
|
||||
manylinux: manylinux_2_17
|
||||
|
||||
- name: linux-arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
target: aarch64-unknown-linux-gnu
|
||||
manylinux: manylinux_2_17
|
||||
|
||||
- name: linux-x64-musl
|
||||
runner: ubuntu-latest
|
||||
target: x86_64-unknown-linux-musl
|
||||
manylinux: musllinux_1_2
|
||||
|
||||
- name: darwin-x64
|
||||
runner: macos-13
|
||||
target: x86_64-apple-darwin
|
||||
manylinux: auto
|
||||
|
||||
- name: darwin-arm64
|
||||
runner: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
manylinux: auto
|
||||
|
||||
- name: win32-x64
|
||||
runner: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
manylinux: auto
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Build wheel
|
||||
uses: PyO3/maturin-action@v1
|
||||
with:
|
||||
target: ${{ matrix.target }}
|
||||
manylinux: ${{ matrix.manylinux }}
|
||||
args: --release --manifest-path libs/cli/Cargo.toml --out libs/cli/dist
|
||||
rust-toolchain: stable
|
||||
|
||||
- name: Upload wheel
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: wheel-${{ matrix.name }}
|
||||
path: libs/cli/dist/*.whl
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Build sdist
|
||||
# ──────────────────────────────────────────────
|
||||
pypi-sdist:
|
||||
name: PyPI sdist
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Build sdist
|
||||
uses: PyO3/maturin-action@v1
|
||||
with:
|
||||
command: sdist
|
||||
args: --manifest-path libs/cli/Cargo.toml --out libs/cli/dist
|
||||
|
||||
- name: Upload sdist
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: sdist
|
||||
path: libs/cli/dist/*.tar.gz
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Publish to PyPI
|
||||
# ──────────────────────────────────────────────
|
||||
pypi-publish:
|
||||
name: Publish to PyPI
|
||||
needs: [pypi-wheels, pypi-sdist]
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/cli-v')
|
||||
environment:
|
||||
name: pypi
|
||||
url: https://pypi.org/project/langgraph-cli/
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: wheel-*
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: sdist
|
||||
path: dist
|
||||
|
||||
- name: Publish to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Publish to npm
|
||||
# ──────────────────────────────────────────────
|
||||
npm-publish:
|
||||
name: Publish to npm
|
||||
needs: [build]
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/cli-v')
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
# Download all npm platform packages
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: npm-*
|
||||
path: npm-artifacts
|
||||
|
||||
# Publish each platform package
|
||||
- name: Publish platform packages
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: |
|
||||
for dir in npm-artifacts/npm-*/; do
|
||||
echo "Publishing $(basename $dir)..."
|
||||
cd "$dir"
|
||||
npm publish --access public
|
||||
cd -
|
||||
done
|
||||
|
||||
# Publish the main package
|
||||
- name: Publish main package
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: |
|
||||
cd libs/cli/npm/langgraph-cli
|
||||
npm publish --access public
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Create GitHub Release with standalone binaries
|
||||
# ──────────────────────────────────────────────
|
||||
github-release:
|
||||
name: GitHub Release
|
||||
needs: [build, build-musl]
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/cli-v')
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: binary-*
|
||||
path: release-artifacts
|
||||
merge-multiple: true
|
||||
|
||||
- name: Create release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: release-artifacts/*
|
||||
generate_release_notes: true
|
||||
draft: false
|
||||
@@ -1,49 +0,0 @@
|
||||
---
|
||||
name: CLI Rust
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'libs/cli/src/**'
|
||||
- 'libs/cli/Cargo.toml'
|
||||
- 'libs/cli/Cargo.lock'
|
||||
- 'libs/cli/build.rs'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'libs/cli/src/**'
|
||||
- 'libs/cli/Cargo.toml'
|
||||
- 'libs/cli/Cargo.lock'
|
||||
- 'libs/cli/build.rs'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Check / Lint / Test
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: libs/cli
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: libs/cli
|
||||
- name: Format check
|
||||
run: cargo fmt --check
|
||||
- name: Clippy
|
||||
run: cargo clippy -- -D warnings
|
||||
- name: Test
|
||||
run: cargo test
|
||||
- name: Build release
|
||||
run: cargo build --release
|
||||
@@ -0,0 +1,49 @@
|
||||
name: Deploy Redirects to GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- '.github/workflows/deploy-redirects.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: "pages"
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Generate redirect files
|
||||
run: python docs/generate_redirects.py
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v5
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
with:
|
||||
path: 'docs/_site'
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
@@ -1,7 +1,7 @@
|
||||
<picture class="github-only">
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://langchain-ai.github.io/langgraph/static/wordmark_dark.svg">
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://langchain-ai.github.io/langgraph/static/wordmark_light.svg">
|
||||
<img alt="LangGraph Logo" src="https://langchain-ai.github.io/langgraph/static/wordmark_dark.svg" width="80%">
|
||||
<source media="(prefers-color-scheme: light)" srcset=".github/images/logo-light.svg">
|
||||
<source media="(prefers-color-scheme: dark)" srcset=".github/images/logo-dark.svg">
|
||||
<img alt="LangGraph Logo" src=".github/images/logo-dark.svg" width="50%">
|
||||
</picture>
|
||||
|
||||
<div>
|
||||
@@ -56,6 +56,9 @@ Get started with the [LangGraph Quickstart](https://docs.langchain.com/oss/pytho
|
||||
|
||||
To quickly build agents with LangChain's `create_agent` (built on LangGraph), see the [LangChain Agents documentation](https://docs.langchain.com/oss/python/langchain/agents).
|
||||
|
||||
> [!TIP]
|
||||
> For developing, debugging, and deploying AI agents and LLM applications, see [LangSmith](https://docs.langchain.com/langsmith/home).
|
||||
|
||||
## Core benefits
|
||||
|
||||
LangGraph provides low-level supporting infrastructure for *any* long-running, stateful workflow or agent. LangGraph does not abstract prompts or architecture, and provides the following central benefits:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
_site/
|
||||
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate HTML redirect files from redirects.json.
|
||||
|
||||
Usage:
|
||||
python generate_redirects.py
|
||||
|
||||
This script reads redirects.json and generates individual HTML files
|
||||
for each redirect path. Each HTML file uses meta refresh (0 delay)
|
||||
which is SEO-friendly and treated similarly to 301 redirects by Google.
|
||||
|
||||
To add new redirects, simply edit redirects.json and re-run this script.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Default fallback URL for any path not in the redirect map
|
||||
DEFAULT_REDIRECT = "https://docs.langchain.com/oss/python/langgraph/overview"
|
||||
|
||||
HTML_TEMPLATE = """<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Redirecting...</title>
|
||||
<link rel="canonical" href="{url}">
|
||||
<meta name="robots" content="noindex">
|
||||
<script>var anchor=window.location.hash.substr(1);location.href="{url}"+(anchor?"#"+anchor:"")</script>
|
||||
<meta http-equiv="refresh" content="0; url={url}">
|
||||
</head>
|
||||
<body>
|
||||
Redirecting...
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
ROOT_HTML_TEMPLATE = """<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Redirecting to LangGraph Documentation</title>
|
||||
<link rel="canonical" href="{url}">
|
||||
<meta name="robots" content="noindex">
|
||||
<script>var anchor=window.location.hash.substr(1);location.href="{url}"+(anchor?"#"+anchor:"")</script>
|
||||
<meta http-equiv="refresh" content="0; url={url}">
|
||||
</head>
|
||||
<body>
|
||||
<h1>Documentation has moved</h1>
|
||||
<p>The LangGraph documentation has moved to <a href="{url}">docs.langchain.com</a>.</p>
|
||||
<p>Redirecting you now...</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
CATCHALL_404_TEMPLATE = """<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Redirecting to LangGraph Documentation</title>
|
||||
<link rel="canonical" href="{default_url}">
|
||||
<meta name="robots" content="noindex">
|
||||
<script>
|
||||
// Catchall redirect for any unmapped paths
|
||||
window.location.replace("{default_url}");
|
||||
</script>
|
||||
<meta http-equiv="refresh" content="0; url={default_url}">
|
||||
</head>
|
||||
<body>
|
||||
<h1>Documentation has moved</h1>
|
||||
<p>The LangGraph documentation has moved to <a href="{default_url}">docs.langchain.com</a>.</p>
|
||||
<p>Redirecting you now...</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def generate_redirects():
|
||||
script_dir = Path(__file__).parent
|
||||
output_dir = script_dir / "_site"
|
||||
|
||||
# Load redirects
|
||||
with open(script_dir / "redirects.json") as f:
|
||||
redirects = json.load(f)
|
||||
|
||||
# Clean output directory
|
||||
if output_dir.exists():
|
||||
import shutil
|
||||
shutil.rmtree(output_dir)
|
||||
output_dir.mkdir(parents=True)
|
||||
|
||||
# Generate individual HTML files for each redirect
|
||||
for old_path, new_url in redirects.items():
|
||||
# Remove leading slash and create directory structure
|
||||
path = old_path.lstrip("/")
|
||||
|
||||
# Check if path has a file extension (e.g., .txt, .xml)
|
||||
# If so, create the file directly instead of a directory with index.html
|
||||
path_obj = Path(path)
|
||||
has_extension = path_obj.suffix and len(path_obj.suffix) <= 5
|
||||
|
||||
if not path:
|
||||
html_path = output_dir / "index.html"
|
||||
elif has_extension:
|
||||
# For files with extensions, create the file directly
|
||||
html_path = output_dir / path
|
||||
else:
|
||||
# For directory-style URLs, create index.html inside
|
||||
html_path = output_dir / path / "index.html"
|
||||
|
||||
# Create parent directories
|
||||
html_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write the redirect HTML
|
||||
html_path.write_text(HTML_TEMPLATE.format(url=new_url))
|
||||
print(f"Created: {html_path}")
|
||||
|
||||
# Create root index.html
|
||||
root_index = output_dir / "index.html"
|
||||
if not root_index.exists():
|
||||
root_index.write_text(ROOT_HTML_TEMPLATE.format(url=DEFAULT_REDIRECT))
|
||||
print(f"Created: {root_index}")
|
||||
|
||||
# Create 404.html for catchall
|
||||
catchall_404 = output_dir / "404.html"
|
||||
catchall_404.write_text(CATCHALL_404_TEMPLATE.format(default_url=DEFAULT_REDIRECT))
|
||||
print(f"Created: {catchall_404}")
|
||||
|
||||
# Copy static files (like llms.txt) that can't be redirected via HTML
|
||||
static_files = ["llms.txt"]
|
||||
for static_file in static_files:
|
||||
src = script_dir / static_file
|
||||
if src.exists():
|
||||
dst = output_dir / static_file
|
||||
dst.write_text(src.read_text())
|
||||
print(f"Copied: {dst}")
|
||||
|
||||
print(f"\nGenerated {len(redirects)} redirect files in {output_dir}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
generate_redirects()
|
||||
@@ -0,0 +1,35 @@
|
||||
# LangGraph
|
||||
|
||||
LangGraph documentation has moved to docs.langchain.com.
|
||||
|
||||
## Overview
|
||||
|
||||
- [LangGraph Overview](https://docs.langchain.com/oss/python/langgraph/overview): Introduction to LangGraph, a library for building stateful, multi-actor applications with LLMs.
|
||||
- [Why LangGraph?](https://docs.langchain.com/oss/python/langgraph/why-langgraph): Motivation for LangGraph and its key features.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
- [Graph API](https://docs.langchain.com/oss/python/langgraph/graph-api): Learn how to define state, create nodes, and connect them with edges.
|
||||
- [Streaming](https://docs.langchain.com/oss/python/langgraph/streaming): Stream outputs from your graph for better UX.
|
||||
- [Persistence](https://docs.langchain.com/oss/python/langgraph/persistence): Add memory and checkpointing to your graphs.
|
||||
- [Add Memory](https://docs.langchain.com/oss/python/langgraph/add-memory): Implement short-term and long-term memory.
|
||||
- [Workflows & Agents](https://docs.langchain.com/oss/python/langgraph/workflows-agents): Build agents and workflows with LangGraph.
|
||||
|
||||
## How-To Guides
|
||||
|
||||
- [Use Subgraphs](https://docs.langchain.com/oss/python/langgraph/use-subgraphs): Compose graphs using subgraphs.
|
||||
- [Observability](https://docs.langchain.com/oss/python/langgraph/observability): Add tracing and debugging to your graphs.
|
||||
- [Common Errors](https://docs.langchain.com/oss/python/langgraph/common-errors): Troubleshoot common LangGraph errors.
|
||||
|
||||
## Tutorials
|
||||
|
||||
- [Agentic RAG](https://docs.langchain.com/oss/python/langgraph/agentic-rag): Build an agentic RAG system with LangGraph.
|
||||
- [SQL Agent](https://docs.langchain.com/oss/python/langgraph/sql-agent): Create a SQL agent with LangGraph.
|
||||
|
||||
## Reference
|
||||
|
||||
- [API Reference](https://reference.langchain.com/python/langgraph/): Complete API documentation for LangGraph.
|
||||
|
||||
## LangGraph Platform
|
||||
|
||||
For deploying LangGraph applications in production, see the [LangSmith documentation](https://docs.langchain.com/langsmith/agent-server).
|
||||
@@ -0,0 +1,296 @@
|
||||
{
|
||||
"/how-tos/stream-values": "https://docs.langchain.com/oss/python/langgraph/streaming",
|
||||
"/how-tos/stream-updates": "https://docs.langchain.com/oss/python/langgraph/streaming",
|
||||
"/how-tos/streaming-content": "https://docs.langchain.com/oss/python/langgraph/streaming",
|
||||
"/how-tos/stream-multiple": "https://docs.langchain.com/oss/python/langgraph/streaming",
|
||||
"/how-tos/streaming-tokens-without-langchain": "https://docs.langchain.com/oss/python/langgraph/streaming",
|
||||
"/how-tos/streaming-from-final-node": "https://docs.langchain.com/oss/python/langgraph/streaming",
|
||||
"/how-tos/streaming-events-from-within-tools-without-langchain": "https://docs.langchain.com/oss/python/langgraph/streaming",
|
||||
"/how-tos/state-reducers": "https://docs.langchain.com/oss/python/langgraph/graph-api#define-and-update-state",
|
||||
"/how-tos/sequence": "https://docs.langchain.com/oss/python/langgraph/graph-api#create-a-sequence-of-steps",
|
||||
"/how-tos/branching": "https://docs.langchain.com/oss/python/langgraph/graph-api#create-branches",
|
||||
"/how-tos/recursion-limit": "https://docs.langchain.com/oss/python/langgraph/graph-api#create-and-control-loops",
|
||||
"/how-tos/visualization": "https://docs.langchain.com/oss/python/langgraph/graph-api#visualize-your-graph",
|
||||
"/how-tos/input_output_schema": "https://docs.langchain.com/oss/python/langgraph/graph-api#define-input-and-output-schemas",
|
||||
"/how-tos/pass_private_state": "https://docs.langchain.com/oss/python/langgraph/graph-api#pass-private-state-between-nodes",
|
||||
"/how-tos/state-model": "https://docs.langchain.com/oss/python/langgraph/graph-api#use-pydantic-models-for-graph-state",
|
||||
"/how-tos/map-reduce": "https://docs.langchain.com/oss/python/langgraph/graph-api#map-reduce-and-the-send-api",
|
||||
"/how-tos/command": "https://docs.langchain.com/oss/python/langgraph/graph-api#combine-control-flow-and-state-updates-with-command",
|
||||
"/how-tos/configuration": "https://docs.langchain.com/oss/python/langgraph/graph-api#add-runtime-configuration",
|
||||
"/how-tos/node-retries": "https://docs.langchain.com/oss/python/langgraph/graph-api#add-retry-policies",
|
||||
"/how-tos/return-when-recursion-limit-hits": "https://docs.langchain.com/oss/python/langgraph/graph-api#impose-a-recursion-limit",
|
||||
"/how-tos/async": "https://docs.langchain.com/oss/python/langgraph/graph-api#async",
|
||||
"/how-tos/memory/manage-conversation-history": "https://docs.langchain.com/oss/python/langgraph/add-memory",
|
||||
"/how-tos/memory/delete-messages": "https://docs.langchain.com/oss/python/langgraph/add-memory#delete-messages",
|
||||
"/how-tos/memory/add-summary-conversation-history": "https://docs.langchain.com/oss/python/langgraph/add-memory#summarize-messages",
|
||||
"/how-tos/memory": "https://docs.langchain.com/oss/python/langgraph/add-memory",
|
||||
"/agents/memory": "https://docs.langchain.com/oss/python/langgraph/add-memory",
|
||||
"/how-tos/subgraph-transform-state": "https://docs.langchain.com/oss/python/langgraph/use-subgraphs#different-state-schemas",
|
||||
"/how-tos/subgraphs-manage-state": "https://docs.langchain.com/oss/python/langgraph/use-subgraphs#add-persistence",
|
||||
"/how-tos/persistence_postgres": "https://docs.langchain.com/oss/python/langgraph/add-memory#use-in-production",
|
||||
"/how-tos/persistence_mongodb": "https://docs.langchain.com/oss/python/langgraph/add-memory#use-in-production",
|
||||
"/how-tos/persistence_redis": "https://docs.langchain.com/oss/python/langgraph/add-memory#use-in-production",
|
||||
"/how-tos/subgraph-persistence": "https://docs.langchain.com/oss/python/langgraph/add-memory#use-with-subgraphs",
|
||||
"/how-tos/cross-thread-persistence": "https://docs.langchain.com/oss/python/langgraph/add-memory#add-long-term-memory",
|
||||
"/cloud/how-tos/copy_threads": "https://docs.langchain.com/langsmith/use-threads",
|
||||
"/cloud/how-tos/check-thread-status": "https://docs.langchain.com/langsmith/use-threads",
|
||||
"/cloud/concepts/threads": "https://docs.langchain.com/oss/python/langgraph/persistence#threads",
|
||||
"/how-tos/persistence": "https://docs.langchain.com/oss/python/langgraph/add-memory",
|
||||
"/how-tos/tool-calling-errors": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
|
||||
"/how-tos/pass-config-to-tools": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
|
||||
"/how-tos/pass-run-time-values-to-tools": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
|
||||
"/how-tos/update-state-from-tools": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
|
||||
"/agents/tools": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
|
||||
"/how-tos/agent-handoffs": "https://docs.langchain.com/oss/python/langgraph/graph-api",
|
||||
"/how-tos/multi-agent-network": "https://docs.langchain.com/oss/python/langgraph/graph-api",
|
||||
"/how-tos/multi-agent-multi-turn-convo": "https://docs.langchain.com/oss/python/langgraph/graph-api",
|
||||
"/cloud/index": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/cloud/how-tos/index": "https://docs.langchain.com/langsmith/home",
|
||||
"/cloud/concepts/api": "https://docs.langchain.com/langsmith/agent-server",
|
||||
"/cloud/concepts/cloud": "https://docs.langchain.com/langsmith/cloud",
|
||||
"/cloud/faq/studio": "https://docs.langchain.com/langsmith/studio",
|
||||
"/cloud/how-tos/human_in_the_loop_edit_state": "https://docs.langchain.com/langsmith/add-human-in-the-loop",
|
||||
"/cloud/how-tos/human_in_the_loop_user_input": "https://docs.langchain.com/langsmith/add-human-in-the-loop",
|
||||
"/concepts/platform_architecture": "https://docs.langchain.com/langsmith/cloud#architecture",
|
||||
"/cloud/how-tos/stream_values": "https://docs.langchain.com/langsmith/streaming",
|
||||
"/cloud/how-tos/stream_updates": "https://docs.langchain.com/langsmith/streaming",
|
||||
"/cloud/how-tos/stream_messages": "https://docs.langchain.com/langsmith/streaming",
|
||||
"/cloud/how-tos/stream_events": "https://docs.langchain.com/langsmith/streaming",
|
||||
"/cloud/how-tos/stream_debug": "https://docs.langchain.com/langsmith/streaming",
|
||||
"/cloud/how-tos/stream_multiple": "https://docs.langchain.com/langsmith/streaming",
|
||||
"/cloud/concepts/streaming": "https://docs.langchain.com/oss/python/langgraph/streaming",
|
||||
"/agents/streaming": "https://docs.langchain.com/oss/python/langgraph/streaming",
|
||||
"/how-tos/create-react-agent": "https://docs.langchain.com/oss/python/langchain/agents#basic-configuration",
|
||||
"/how-tos/create-react-agent-memory": "https://docs.langchain.com/oss/python/langgraph/add-memory",
|
||||
"/how-tos/create-react-agent-system-prompt": "https://docs.langchain.com/oss/python/langgraph/add-memory",
|
||||
"/how-tos/create-react-agent-structured-output": "https://docs.langchain.com/oss/python/langchain/agents#structured-output",
|
||||
"/prebuilt": "https://docs.langchain.com/oss/python/langchain/agents",
|
||||
"/reference/prebuilt": "https://reference.langchain.com/python/langgraph/agents/",
|
||||
"/concepts/high_level": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/concepts/index": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/concepts/v0-human-in-the-loop": "https://docs.langchain.com/oss/python/langgraph/interrupts",
|
||||
"/how-tos/index": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/introduction": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/agents/deployment": "https://docs.langchain.com/oss/python/langgraph/local-server",
|
||||
"/how-tos/deploy-self-hosted": "https://docs.langchain.com/langsmith/platform-setup",
|
||||
"/concepts/self_hosted": "https://docs.langchain.com/langsmith/platform-setup",
|
||||
"/tutorials/deployment": "https://docs.langchain.com/langsmith/deployments",
|
||||
"/cloud/how-tos/assistant_versioning": "https://docs.langchain.com/langsmith/configuration-cloud",
|
||||
"/cloud/concepts/runs": "https://docs.langchain.com/langsmith/assistants#execution",
|
||||
"/how-tos/wait-user-input-functional": "https://docs.langchain.com/oss/python/langgraph/functional-api",
|
||||
"/how-tos/review-tool-calls-functional": "https://docs.langchain.com/oss/python/langgraph/functional-api",
|
||||
"/how-tos/create-react-agent-hitl": "https://docs.langchain.com/oss/python/langgraph/interrupts",
|
||||
"/agents/human-in-the-loop": "https://docs.langchain.com/oss/python/langgraph/interrupts",
|
||||
"/how-tos/human_in_the_loop/dynamic_breakpoints": "https://docs.langchain.com/oss/python/langgraph/interrupts",
|
||||
"/concepts/breakpoints": "https://docs.langchain.com/oss/python/langgraph/interrupts",
|
||||
"/how-tos/human_in_the_loop/breakpoints": "https://docs.langchain.com/oss/python/langgraph/interrupts",
|
||||
"/cloud/how-tos/human_in_the_loop_breakpoint": "https://docs.langchain.com/langsmith/add-human-in-the-loop",
|
||||
"/how-tos/human_in_the_loop/edit-graph-state": "https://docs.langchain.com/oss/python/langgraph/use-time-travel",
|
||||
"/examples/index": "https://docs.langchain.com/oss/python/langgraph/case-studies",
|
||||
"/guides/index": "https://docs.langchain.com/oss/python/langchain/overview",
|
||||
"/tutorials/index": "https://docs.langchain.com/oss/python/learn",
|
||||
"/llms-txt-overview": "https://docs.langchain.com/llms.txt",
|
||||
"/tutorials/rag/langgraph_adaptive_rag": "https://docs.langchain.com/oss/python/langgraph/agentic-rag",
|
||||
"/tutorials/multi_agent/multi-agent-collaboration": "https://docs.langchain.com/oss/python/langchain/multi-agent",
|
||||
"/how-tos/create-react-agent-manage-message-history": "https://docs.langchain.com/oss/python/langgraph/add-memory",
|
||||
"/how-tos/many-tools": "https://docs.langchain.com/oss/python/langchain/tools",
|
||||
"/tutorials/customer-support/customer-support": "https://docs.langchain.com/oss/python/langgraph/agentic-rag",
|
||||
"/how-tos/react-agent-structured-output": "https://docs.langchain.com/oss/python/langchain/agents#structured-output",
|
||||
"/tutorials/code_assistant/langgraph_code_assistant": "https://docs.langchain.com/oss/python/langgraph/agentic-rag",
|
||||
"/tutorials/multi_agent/hierarchical_agent_teams": "https://docs.langchain.com/oss/python/langchain/supervisor",
|
||||
"/tutorials/auth/getting_started": "https://docs.langchain.com/langsmith/auth",
|
||||
"/tutorials/auth/resource_auth": "https://docs.langchain.com/langsmith/resource-auth",
|
||||
"/tutorials/auth/add_auth_server": "https://docs.langchain.com/langsmith/add-auth-server",
|
||||
"/how-tos/use-remote-graph": "https://docs.langchain.com/langsmith/use-remote-graph",
|
||||
"/how-tos/autogen-integration": "https://docs.langchain.com/langsmith/autogen-integration",
|
||||
"/how-tos/human_in_the_loop/wait-user-input": "https://docs.langchain.com/oss/python/langgraph/interrupts",
|
||||
"/cloud/how-tos/use_stream_react": "https://docs.langchain.com/langsmith/use-stream-react",
|
||||
"/cloud/how-tos/generative_ui_react": "https://docs.langchain.com/langsmith/generative-ui-react",
|
||||
"/concepts/langgraph_platform": "https://docs.langchain.com/langsmith/home",
|
||||
"/concepts/langgraph_components": "https://docs.langchain.com/langsmith/components",
|
||||
"/concepts/langgraph_server": "https://docs.langchain.com/langsmith/agent-server",
|
||||
"/concepts/langgraph_data_plane": "https://docs.langchain.com/langsmith/data-plane",
|
||||
"/concepts/langgraph_control_plane": "https://docs.langchain.com/langsmith/control-plane",
|
||||
"/concepts/langgraph_cli": "https://docs.langchain.com/langsmith/cli",
|
||||
"/concepts/langgraph_studio": "https://docs.langchain.com/langsmith/studio",
|
||||
"/cloud/how-tos/studio/quick_start": "https://docs.langchain.com/langsmith/quick-start-studio",
|
||||
"/cloud/how-tos/invoke_studio": "https://docs.langchain.com/langsmith/use-studio",
|
||||
"/cloud/how-tos/studio/manage_assistants": "https://docs.langchain.com/langsmith/use-studio",
|
||||
"/cloud/how-tos/threads_studio": "https://docs.langchain.com/langsmith/use-threads",
|
||||
"/cloud/how-tos/iterate_graph_studio": "https://docs.langchain.com/langsmith/use-studio",
|
||||
"/cloud/how-tos/studio/run_evals": "https://docs.langchain.com/langsmith/observability",
|
||||
"/cloud/how-tos/clone_traces_studio": "https://docs.langchain.com/langsmith/observability",
|
||||
"/cloud/how-tos/datasets_studio": "https://docs.langchain.com/langsmith/use-studio",
|
||||
"/concepts/sdk": "https://docs.langchain.com/langsmith/sdk",
|
||||
"/concepts/plans": "https://docs.langchain.com/langsmith/home",
|
||||
"/concepts/application_structure": "https://docs.langchain.com/langsmith/application-structure",
|
||||
"/concepts/scalability_and_resilience": "https://docs.langchain.com/langsmith/scalability-and-resilience",
|
||||
"/concepts/auth": "https://docs.langchain.com/langsmith/auth",
|
||||
"/how-tos/auth/custom_auth": "https://docs.langchain.com/langsmith/custom-auth",
|
||||
"/how-tos/auth/openapi_security": "https://docs.langchain.com/langsmith/openapi-security",
|
||||
"/concepts/assistants": "https://docs.langchain.com/langsmith/assistants",
|
||||
"/cloud/how-tos/configuration_cloud": "https://docs.langchain.com/langsmith/configuration-cloud",
|
||||
"/cloud/how-tos/use_threads": "https://docs.langchain.com/langsmith/use-threads",
|
||||
"/cloud/how-tos/background_run": "https://docs.langchain.com/langsmith/background-run",
|
||||
"/cloud/how-tos/same-thread": "https://docs.langchain.com/langsmith/same-thread",
|
||||
"/cloud/how-tos/stateless_runs": "https://docs.langchain.com/langsmith/stateless-runs",
|
||||
"/cloud/how-tos/configurable_headers": "https://docs.langchain.com/langsmith/configurable-headers",
|
||||
"/concepts/double_texting": "https://docs.langchain.com/langsmith/double-texting",
|
||||
"/cloud/how-tos/interrupt_concurrent": "https://docs.langchain.com/langsmith/interrupt-concurrent",
|
||||
"/cloud/how-tos/rollback_concurrent": "https://docs.langchain.com/langsmith/rollback-concurrent",
|
||||
"/cloud/how-tos/reject_concurrent": "https://docs.langchain.com/langsmith/reject-concurrent",
|
||||
"/cloud/how-tos/enqueue_concurrent": "https://docs.langchain.com/langsmith/enqueue-concurrent",
|
||||
"/cloud/concepts/webhooks": "https://docs.langchain.com/langsmith/use-webhooks",
|
||||
"/cloud/how-tos/webhooks": "https://docs.langchain.com/langsmith/use-webhooks",
|
||||
"/cloud/concepts/cron_jobs": "https://docs.langchain.com/langsmith/cron-jobs",
|
||||
"/cloud/how-tos/cron_jobs": "https://docs.langchain.com/langsmith/cron-jobs",
|
||||
"/how-tos/http/custom_lifespan": "https://docs.langchain.com/langsmith/custom-lifespan",
|
||||
"/how-tos/http/custom_middleware": "https://docs.langchain.com/langsmith/custom-middleware",
|
||||
"/how-tos/http/custom_routes": "https://docs.langchain.com/langsmith/custom-routes",
|
||||
"/cloud/concepts/data_storage_and_privacy": "https://docs.langchain.com/langsmith/data-storage-and-privacy",
|
||||
"/cloud/deployment/semantic_search": "https://docs.langchain.com/langsmith/semantic-search",
|
||||
"/how-tos/ttl/configure_ttl": "https://docs.langchain.com/langsmith/configure-ttl",
|
||||
"/concepts/deployment_options": "https://docs.langchain.com/langsmith/deployments",
|
||||
"/cloud/quick_start": "https://docs.langchain.com/langsmith/deployment-quickstart",
|
||||
"/cloud/deployment/setup": "https://docs.langchain.com/langsmith/setup-app-requirements-txt",
|
||||
"/cloud/deployment/setup_pyproject": "https://docs.langchain.com/langsmith/setup-pyproject",
|
||||
"/cloud/deployment/setup_javascript": "https://docs.langchain.com/langsmith/setup-javascript",
|
||||
"/cloud/deployment/custom_docker": "https://docs.langchain.com/langsmith/custom-docker",
|
||||
"/cloud/deployment/graph_rebuild": "https://docs.langchain.com/langsmith/graph-rebuild",
|
||||
"/concepts/langgraph_cloud": "https://docs.langchain.com/langsmith/cloud",
|
||||
"/concepts/langgraph_self_hosted_data_plane": "https://docs.langchain.com/langsmith/platform-setup",
|
||||
"/concepts/langgraph_self_hosted_control_plane": "https://docs.langchain.com/langsmith/platform-setup",
|
||||
"/concepts/langgraph_standalone_container": "https://docs.langchain.com/langsmith/docker",
|
||||
"/cloud/deployment/cloud": "https://docs.langchain.com/langsmith/cloud",
|
||||
"/cloud/deployment/self_hosted_data_plane": "https://docs.langchain.com/langsmith/platform-setup",
|
||||
"/cloud/deployment/self_hosted_control_plane": "https://docs.langchain.com/langsmith/platform-setup",
|
||||
"/cloud/deployment/standalone_container": "https://docs.langchain.com/langsmith/docker",
|
||||
"/concepts/server-mcp": "https://docs.langchain.com/langsmith/server-mcp",
|
||||
"/cloud/how-tos/human_in_the_loop_time_travel": "https://docs.langchain.com/langsmith/human-in-the-loop-time-travel",
|
||||
"/cloud/how-tos/add-human-in-the-loop": "https://docs.langchain.com/langsmith/add-human-in-the-loop",
|
||||
"/cloud/deployment/egress": "https://docs.langchain.com/langsmith/env-var",
|
||||
"/cloud/how-tos/streaming": "https://docs.langchain.com/langsmith/streaming",
|
||||
"/cloud/reference/api/api_ref": "https://docs.langchain.com/langsmith/server-api-ref",
|
||||
"/cloud/reference/langgraph_server_changelog": "https://docs.langchain.com/langsmith/agent-server-changelog",
|
||||
"/cloud/reference/api/api_ref_control_plane": "https://docs.langchain.com/langsmith/api-ref-control-plane",
|
||||
"/cloud/reference/cli": "https://docs.langchain.com/langsmith/cli",
|
||||
"/cloud/reference/env_var": "https://docs.langchain.com/langsmith/env-var",
|
||||
"/troubleshooting/studio": "https://docs.langchain.com/langsmith/troubleshooting-studio",
|
||||
"/index": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/agents/agents": "https://docs.langchain.com/oss/python/langchain/agents",
|
||||
"/concepts/why-langgraph": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/get-started/1-build-basic-chatbot": "https://docs.langchain.com/oss/python/langgraph/quickstart",
|
||||
"/tutorials/get-started/2-add-tools": "https://docs.langchain.com/oss/python/langgraph/quickstart",
|
||||
"/tutorials/get-started/3-add-memory": "https://docs.langchain.com/oss/python/langgraph/quickstart",
|
||||
"/tutorials/get-started/4-human-in-the-loop": "https://docs.langchain.com/oss/python/langgraph/quickstart",
|
||||
"/tutorials/get-started/5-customize-state": "https://docs.langchain.com/oss/python/langgraph/quickstart",
|
||||
"/tutorials/get-started/6-time-travel": "https://docs.langchain.com/oss/python/langgraph/quickstart",
|
||||
"/tutorials/langsmith/local-server": "https://docs.langchain.com/oss/python/langgraph/local-server",
|
||||
"/tutorials/workflows": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
|
||||
"/tutorials/plan-and-execute/plan-and-execute": "https://docs.langchain.com/oss/python/langchain/middleware/built-in#to-do-list",
|
||||
"/tutorials/langgraph-platform/local-server/local-server": "https://docs.langchain.com/langsmith/local-server",
|
||||
"/concepts/agentic_concepts": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
|
||||
"/agents/overview": "https://docs.langchain.com/oss/python/langchain/agents",
|
||||
"/agents/run_agents": "https://docs.langchain.com/oss/python/langgraph/quickstart",
|
||||
"/concepts/low_level": "https://docs.langchain.com/oss/python/langgraph/graph-api",
|
||||
"/how-tos/graph-api": "https://docs.langchain.com/oss/python/langgraph/graph-api",
|
||||
"/how-tos/react-agent-from-scratch": "https://docs.langchain.com/oss/python/langchain/quickstart",
|
||||
"/concepts/functional_api": "https://docs.langchain.com/oss/python/langgraph/functional-api",
|
||||
"/how-tos/use-functional-api": "https://docs.langchain.com/oss/python/langgraph/functional-api",
|
||||
"/concepts/pregel": "https://docs.langchain.com/oss/python/langgraph/pregel",
|
||||
"/concepts/streaming": "https://docs.langchain.com/oss/python/langgraph/streaming",
|
||||
"/how-tos/streaming": "https://docs.langchain.com/oss/python/langgraph/streaming",
|
||||
"/concepts/persistence": "https://docs.langchain.com/oss/python/langgraph/persistence",
|
||||
"/concepts/durable_execution": "https://docs.langchain.com/oss/python/langgraph/durable-execution",
|
||||
"/concepts/memory": "https://docs.langchain.com/oss/python/langgraph/memory",
|
||||
"/how-tos/memory/add-memory": "https://docs.langchain.com/oss/python/langgraph/add-memory",
|
||||
"/agents/context": "https://docs.langchain.com/oss/python/langgraph/add-memory",
|
||||
"/agents/models": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/concepts/tools": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
|
||||
"/how-tos/tool-calling": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
|
||||
"/concepts/human_in_the_loop": "https://docs.langchain.com/oss/python/langgraph/interrupts",
|
||||
"/how-tos/human_in_the_loop/add-human-in-the-loop": "https://docs.langchain.com/oss/python/langgraph/interrupts",
|
||||
"/concepts/time-travel": "https://docs.langchain.com/oss/python/langgraph/persistence",
|
||||
"/how-tos/human_in_the_loop/time-travel": "https://docs.langchain.com/oss/python/langgraph/use-time-travel",
|
||||
"/concepts/subgraphs": "https://docs.langchain.com/oss/python/langgraph/use-subgraphs",
|
||||
"/how-tos/subgraph": "https://docs.langchain.com/oss/python/langgraph/use-subgraphs",
|
||||
"/concepts/multi_agent": "https://docs.langchain.com/oss/python/langgraph/graph-api",
|
||||
"/agents/multi-agent": "https://docs.langchain.com/oss/python/langchain/multi-agent",
|
||||
"/how-tos/multi_agent": "https://docs.langchain.com/oss/python/langgraph/graph-api",
|
||||
"/concepts/mcp": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/agents/mcp": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/concepts/tracing": "https://docs.langchain.com/oss/python/langgraph/observability",
|
||||
"/how-tos/enable-tracing": "https://docs.langchain.com/oss/python/langgraph/observability",
|
||||
"/agents/evals": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/concepts/template_applications": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/rag/langgraph_agentic_rag": "https://docs.langchain.com/oss/python/langgraph/agentic-rag",
|
||||
"/tutorials/multi_agent/agent_supervisor": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
|
||||
"/tutorials/sql/sql-agent": "https://docs.langchain.com/oss/python/langgraph/sql-agent",
|
||||
"/agents/ui": "https://docs.langchain.com/oss/python/langgraph/ui",
|
||||
"/how-tos/run-id-langsmith": "https://docs.langchain.com/oss/python/langgraph/observability",
|
||||
"/troubleshooting/errors/index": "https://docs.langchain.com/oss/python/langgraph/common-errors",
|
||||
"/troubleshooting/errors/INVALID_CHAT_HISTORY": "https://docs.langchain.com/oss/python/langgraph/INVALID_CHAT_HISTORY",
|
||||
"/troubleshooting/errors/INVALID_LICENSE": "https://docs.langchain.com/oss/python/langgraph/common-errors",
|
||||
"/adopters": "https://docs.langchain.com/oss/python/langgraph/case-studies",
|
||||
"/concepts/faq": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/agents/prebuilt": "https://docs.langchain.com/oss/python/langchain/agents",
|
||||
"/reference/index": "https://reference.langchain.com/python/langgraph/",
|
||||
"/reference/graphs": "https://reference.langchain.com/python/langgraph/graphs/",
|
||||
"/reference/func": "https://reference.langchain.com/python/langgraph/func/",
|
||||
"/reference/pregel": "https://reference.langchain.com/python/langgraph/pregel/",
|
||||
"/reference/checkpoints": "https://reference.langchain.com/python/langgraph/checkpoints/",
|
||||
"/reference/store": "https://reference.langchain.com/python/langgraph/store/",
|
||||
"/reference/cache": "https://reference.langchain.com/python/langgraph/cache/",
|
||||
"/reference/types": "https://reference.langchain.com/python/langgraph/types/",
|
||||
"/reference/runtime": "https://reference.langchain.com/python/langgraph/runtime/",
|
||||
"/reference/config": "https://reference.langchain.com/python/langgraph/config/",
|
||||
"/reference/errors": "https://reference.langchain.com/python/langgraph/errors/",
|
||||
"/reference/constants": "https://reference.langchain.com/python/langgraph/constants/",
|
||||
"/reference/channels": "https://reference.langchain.com/python/langgraph/channels/",
|
||||
"/reference/agents": "https://reference.langchain.com/python/langgraph/agents/",
|
||||
"/reference/supervisor": "https://reference.langchain.com/python/langgraph/supervisor/",
|
||||
"/reference/swarm": "https://reference.langchain.com/python/langgraph/swarm/",
|
||||
"/reference/mcp": "https://reference.langchain.com/python/langgraph/mcp/",
|
||||
"/cloud/reference/sdk/python_sdk_ref": "https://reference.langchain.com/python/langsmith/deployment/sdk/",
|
||||
"/reference/remote_graph": "https://reference.langchain.com/python/langsmith/deployment/remote_graph/",
|
||||
"/additional-resources/index": "https://docs.langchain.com/oss/python/langchain/overview",
|
||||
"/cloud/reference/sdk/js_ts_sdk_ref": "https://reference.langchain.com/javascript/modules/langsmith.html",
|
||||
"/snippets/chat_model_tabs": "https://docs.langchain.com/oss/python/langchain/overview",
|
||||
"/troubleshooting/errors/GRAPH_RECURSION_LIMIT": "https://docs.langchain.com/oss/python/langgraph/GRAPH_RECURSION_LIMIT",
|
||||
"/troubleshooting/errors/INVALID_CONCURRENT_GRAPH_UPDATE": "https://docs.langchain.com/oss/python/langgraph/INVALID_CONCURRENT_GRAPH_UPDATE",
|
||||
"/troubleshooting/errors/INVALID_GRAPH_NODE_RETURN_VALUE": "https://docs.langchain.com/oss/python/langgraph/INVALID_GRAPH_NODE_RETURN_VALUE",
|
||||
"/troubleshooting/errors/MULTIPLE_SUBGRAPHS": "https://docs.langchain.com/oss/python/langgraph/MULTIPLE_SUBGRAPHS",
|
||||
"/tutorials/rag/langgraph_self_rag": "https://docs.langchain.com/oss/python/langgraph/agentic-rag",
|
||||
"/additional-resources": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/examples": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/guides": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/how-tos/autogen-integration-functional": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/how-tos/cross-thread-persistence-functional": "https://docs.langchain.com/oss/python/langgraph/add-memory#add-long-term-memory",
|
||||
"/how-tos/disable-streaming": "https://docs.langchain.com/oss/python/langgraph/streaming",
|
||||
"/how-tos/memory/semantic-search": "https://docs.langchain.com/oss/python/langgraph/add-memory",
|
||||
"/how-tos/multi-agent-multi-turn-convo-functional": "https://docs.langchain.com/oss/python/langgraph/graph-api",
|
||||
"/how-tos/multi-agent-network-functional": "https://docs.langchain.com/oss/python/langgraph/graph-api",
|
||||
"/how-tos/persistence-functional": "https://docs.langchain.com/oss/python/langgraph/add-memory",
|
||||
"/how-tos/react-agent-from-scratch-functional": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
|
||||
"/reference": "https://reference.langchain.com/python/langgraph/",
|
||||
"/troubleshooting/errors": "https://docs.langchain.com/oss/python/langgraph/common-errors",
|
||||
"/tutorials/chatbot-simulation-evaluation/agent-simulation-evaluation": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/chatbot-simulation-evaluation/langsmith-agent-simulation-evaluation": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/chatbots/information-gather-prompting": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/extraction/retries": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/langgraph-platform/local-server": "https://docs.langchain.com/langsmith/agent-server",
|
||||
"/tutorials/lats/lats": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/llm-compiler/LLMCompiler": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/rag/langgraph_adaptive_rag_local": "https://docs.langchain.com/oss/python/langgraph/agentic-rag",
|
||||
"/tutorials/rag/langgraph_crag": "https://docs.langchain.com/oss/python/langgraph/agentic-rag",
|
||||
"/tutorials/rag/langgraph_crag_local": "https://docs.langchain.com/oss/python/langgraph/agentic-rag",
|
||||
"/tutorials/rag/langgraph_self_rag_local": "https://docs.langchain.com/oss/python/langgraph/agentic-rag",
|
||||
"/tutorials/reflection/reflection": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/reflexion/reflexion": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/rewoo/rewoo": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/self-discover/self-discover": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/tnt-llm/tnt-llm": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/tot/tot": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/usaco/usaco": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/web-navigation/web_voyager": "https://docs.langchain.com/oss/python/langgraph/overview"
|
||||
}
|
||||
@@ -57,3 +57,9 @@ lint.select = [
|
||||
]
|
||||
lint.ignore = ["E501", "B008"]
|
||||
target-version = "py310"
|
||||
|
||||
[[tool.uv.index]]
|
||||
name = "testpypi"
|
||||
url = "https://test.pypi.org/simple/"
|
||||
publish-url = "https://test.pypi.org/legacy/"
|
||||
explicit = true
|
||||
|
||||
Generated
+37
-37
@@ -746,27 +746,27 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.1"
|
||||
version = "0.15.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/04/dc/4e6ac71b511b141cf626357a3946679abeba4cf67bc7cc5a17920f31e10d/ruff-0.15.1.tar.gz", hash = "sha256:c590fe13fb57c97141ae975c03a1aedb3d3156030cabd740d6ff0b0d601e203f", size = 4540855, upload-time = "2026-02-12T23:09:09.998Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/04/eab13a954e763b0606f460443fcbf6bb5a0faf06890ea3754ff16523dce5/ruff-0.15.2.tar.gz", hash = "sha256:14b965afee0969e68bb871eba625343b8673375f457af4abe98553e8bbb98342", size = 4558148, upload-time = "2026-02-19T22:32:20.271Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/23/bf/e6e4324238c17f9d9120a9d60aa99a7daaa21204c07fcd84e2ef03bb5fd1/ruff-0.15.1-py3-none-linux_armv6l.whl", hash = "sha256:b101ed7cf4615bda6ffe65bdb59f964e9f4a0d3f85cbf0e54f0ab76d7b90228a", size = 10367819, upload-time = "2026-02-12T23:09:03.598Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/ea/c8f89d32e7912269d38c58f3649e453ac32c528f93bb7f4219258be2e7ed/ruff-0.15.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:939c995e9277e63ea632cc8d3fae17aa758526f49a9a850d2e7e758bfef46602", size = 10798618, upload-time = "2026-02-12T23:09:22.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/0f/1d0d88bc862624247d82c20c10d4c0f6bb2f346559d8af281674cf327f15/ruff-0.15.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1d83466455fdefe60b8d9c8df81d3c1bbb2115cede53549d3b522ce2bc703899", size = 10148518, upload-time = "2026-02-12T23:08:58.339Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/c8/291c49cefaa4a9248e986256df2ade7add79388fe179e0691be06fae6f37/ruff-0.15.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9457e3c3291024866222b96108ab2d8265b477e5b1534c7ddb1810904858d16", size = 10518811, upload-time = "2026-02-12T23:09:31.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/1a/f5707440e5ae43ffa5365cac8bbb91e9665f4a883f560893829cf16a606b/ruff-0.15.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:92c92b003e9d4f7fbd33b1867bb15a1b785b1735069108dfc23821ba045b29bc", size = 10196169, upload-time = "2026-02-12T23:09:17.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/ff/26ddc8c4da04c8fd3ee65a89c9fb99eaa5c30394269d424461467be2271f/ruff-0.15.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fe5c41ab43e3a06778844c586251eb5a510f67125427625f9eb2b9526535779", size = 10990491, upload-time = "2026-02-12T23:09:25.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/00/50920cb385b89413f7cdb4bb9bc8fc59c1b0f30028d8bccc294189a54955/ruff-0.15.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66a6dd6df4d80dc382c6484f8ce1bcceb55c32e9f27a8b94c32f6c7331bf14fb", size = 11843280, upload-time = "2026-02-12T23:09:19.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/6d/2f5cad8380caf5632a15460c323ae326f1e1a2b5b90a6ee7519017a017ca/ruff-0.15.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a4a42cbb8af0bda9bcd7606b064d7c0bc311a88d141d02f78920be6acb5aa83", size = 11274336, upload-time = "2026-02-12T23:09:14.907Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/1d/5f56cae1d6c40b8a318513599b35ea4b075d7dc1cd1d04449578c29d1d75/ruff-0.15.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ab064052c31dddada35079901592dfba2e05f5b1e43af3954aafcbc1096a5b2", size = 11137288, upload-time = "2026-02-12T23:09:07.475Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/20/6f8d7d8f768c93b0382b33b9306b3b999918816da46537d5a61635514635/ruff-0.15.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5631c940fe9fe91f817a4c2ea4e81f47bee3ca4aa646134a24374f3c19ad9454", size = 11070681, upload-time = "2026-02-12T23:08:55.43Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/67/d640ac76069f64cdea59dba02af2e00b1fa30e2103c7f8d049c0cff4cafd/ruff-0.15.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68138a4ba184b4691ccdc39f7795c66b3c68160c586519e7e8444cf5a53e1b4c", size = 10486401, upload-time = "2026-02-12T23:09:27.927Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/3d/e1429f64a3ff89297497916b88c32a5cc88eeca7e9c787072d0e7f1d3e1e/ruff-0.15.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:518f9af03bfc33c03bdb4cb63fabc935341bb7f54af500f92ac309ecfbba6330", size = 10197452, upload-time = "2026-02-12T23:09:12.147Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/83/e2c3bade17dad63bf1e1c2ffaf11490603b760be149e1419b07049b36ef2/ruff-0.15.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:da79f4d6a826caaea95de0237a67e33b81e6ec2e25fc7e1993a4015dffca7c61", size = 10693900, upload-time = "2026-02-12T23:09:34.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/27/fdc0e11a813e6338e0706e8b39bb7a1d61ea5b36873b351acee7e524a72a/ruff-0.15.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3dd86dccb83cd7d4dcfac303ffc277e6048600dfc22e38158afa208e8bf94a1f", size = 11227302, upload-time = "2026-02-12T23:09:36.536Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/58/ac864a75067dcbd3b95be5ab4eb2b601d7fbc3d3d736a27e391a4f92a5c1/ruff-0.15.1-py3-none-win32.whl", hash = "sha256:660975d9cb49b5d5278b12b03bb9951d554543a90b74ed5d366b20e2c57c2098", size = 10462555, upload-time = "2026-02-12T23:09:29.899Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/5e/d4ccc8a27ecdb78116feac4935dfc39d1304536f4296168f91ed3ec00cd2/ruff-0.15.1-py3-none-win_amd64.whl", hash = "sha256:c820fef9dd5d4172a6570e5721704a96c6679b80cf7be41659ed439653f62336", size = 11599956, upload-time = "2026-02-12T23:09:01.157Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/07/5bda6a85b220c64c65686bc85bd0bbb23b29c62b3a9f9433fa55f17cda93/ruff-0.15.1-py3-none-win_arm64.whl", hash = "sha256:5ff7d5f0f88567850f45081fac8f4ec212be8d0b963e385c3f7d0d2eb4899416", size = 10874604, upload-time = "2026-02-12T23:09:05.515Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/70/3a4dc6d09b13cb3e695f28307e5d889b2e1a66b7af9c5e257e796695b0e6/ruff-0.15.2-py3-none-linux_armv6l.whl", hash = "sha256:120691a6fdae2f16d65435648160f5b81a9625288f75544dc40637436b5d3c0d", size = 10430565, upload-time = "2026-02-19T22:32:41.824Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/0b/bb8457b56185ece1305c666dc895832946d24055be90692381c31d57466d/ruff-0.15.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a89056d831256099658b6bba4037ac6dd06f49d194199215befe2bb10457ea5e", size = 10820354, upload-time = "2026-02-19T22:32:07.366Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/c1/e0532d7f9c9e0b14c46f61b14afd563298b8b83f337b6789ddd987e46121/ruff-0.15.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e36dee3a64be0ebd23c86ffa3aa3fd3ac9a712ff295e192243f814a830b6bd87", size = 10170767, upload-time = "2026-02-19T22:32:13.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/e8/da1aa341d3af017a21c7a62fb5ec31d4e7ad0a93ab80e3a508316efbcb23/ruff-0.15.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9fb47b6d9764677f8c0a193c0943ce9a05d6763523f132325af8a858eadc2b9", size = 10529591, upload-time = "2026-02-19T22:32:02.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/74/184fbf38e9f3510231fbc5e437e808f0b48c42d1df9434b208821efcd8d6/ruff-0.15.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f376990f9d0d6442ea9014b19621d8f2aaf2b8e39fdbfc79220b7f0c596c9b80", size = 10260771, upload-time = "2026-02-19T22:32:36.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/ac/605c20b8e059a0bc4b42360414baa4892ff278cec1c91fff4be0dceedefd/ruff-0.15.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2dcc987551952d73cbf5c88d9fdee815618d497e4df86cd4c4824cc59d5dd75f", size = 11045791, upload-time = "2026-02-19T22:32:31.642Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/52/db6e419908f45a894924d410ac77d64bdd98ff86901d833364251bd08e22/ruff-0.15.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:42a47fd785cbe8c01b9ff45031af875d101b040ad8f4de7bbb716487c74c9a77", size = 11879271, upload-time = "2026-02-19T22:32:29.305Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/d8/7992b18f2008bdc9231d0f10b16df7dda964dbf639e2b8b4c1b4e91b83af/ruff-0.15.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cbe9f49354866e575b4c6943856989f966421870e85cd2ac94dccb0a9dcb2fea", size = 11303707, upload-time = "2026-02-19T22:32:22.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/02/849b46184bcfdd4b64cde61752cc9a146c54759ed036edd11857e9b8443b/ruff-0.15.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7a672c82b5f9887576087d97be5ce439f04bbaf548ee987b92d3a7dede41d3a", size = 11149151, upload-time = "2026-02-19T22:32:44.234Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/04/f5284e388bab60d1d3b99614a5a9aeb03e0f333847e2429bebd2aaa1feec/ruff-0.15.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ecc64f46f7019e2bcc3cdc05d4a7da958b629a5ab7033195e11a438403d956", size = 11091132, upload-time = "2026-02-19T22:32:24.691Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/ae/88d844a21110e14d92cf73d57363fab59b727ebeabe78009b9ccb23500af/ruff-0.15.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:8dcf243b15b561c655c1ef2f2b0050e5d50db37fe90115507f6ff37d865dc8b4", size = 10504717, upload-time = "2026-02-19T22:32:26.75Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/27/867076a6ada7f2b9c8292884ab44d08fd2ba71bd2b5364d4136f3cd537e1/ruff-0.15.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dab6941c862c05739774677c6273166d2510d254dac0695c0e3f5efa1b5585de", size = 10263122, upload-time = "2026-02-19T22:32:10.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/ef/faf9321d550f8ebf0c6373696e70d1758e20ccdc3951ad7af00c0956be7c/ruff-0.15.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b9164f57fc36058e9a6806eb92af185b0697c9fe4c7c52caa431c6554521e5c", size = 10735295, upload-time = "2026-02-19T22:32:39.227Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/55/e8089fec62e050ba84d71b70e7834b97709ca9b7aba10c1a0b196e493f97/ruff-0.15.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:80d24fcae24d42659db7e335b9e1531697a7102c19185b8dc4a028b952865fd8", size = 11241641, upload-time = "2026-02-19T22:32:34.617Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/01/1c30526460f4d23222d0fabd5888868262fd0e2b71a00570ca26483cd993/ruff-0.15.2-py3-none-win32.whl", hash = "sha256:fd5ff9e5f519a7e1bd99cbe8daa324010a74f5e2ebc97c6242c08f26f3714f6f", size = 10507885, upload-time = "2026-02-19T22:32:15.635Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/10/3d18e3bbdf8fc50bbb4ac3cc45970aa5a9753c5cb51bf9ed9a3cd8b79fa3/ruff-0.15.2-py3-none-win_amd64.whl", hash = "sha256:d20014e3dfa400f3ff84830dfb5755ece2de45ab62ecea4af6b7262d0fb4f7c5", size = 11623725, upload-time = "2026-02-19T22:32:04.947Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/78/097c0798b1dab9f8affe73da9642bb4500e098cb27fd8dc9724816ac747b/ruff-0.15.2-py3-none-win_arm64.whl", hash = "sha256:cabddc5822acdc8f7b5527b36ceac55cc51eec7b1946e60181de8fe83ca8876e", size = 10941649, upload-time = "2026-02-19T22:32:18.108Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -834,26 +834,26 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ty"
|
||||
version = "0.0.17"
|
||||
version = "0.0.18"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/66/c3/41ae6346443eedb65b96761abfab890a48ce2aa5a8a27af69c5c5d99064d/ty-0.0.17.tar.gz", hash = "sha256:847ed6c120913e280bf9b54d8eaa7a1049708acb8824ad234e71498e8ad09f97", size = 5167209, upload-time = "2026-02-13T13:26:36.835Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/74/15/9682700d8d60fdca7afa4febc83a2354b29cdcd56e66e19c92b521db3b39/ty-0.0.18.tar.gz", hash = "sha256:04ab7c3db5dcbcdac6ce62e48940d3a0124f377c05499d3f3e004e264ae94b83", size = 5214774, upload-time = "2026-02-20T21:51:31.173Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/01/0ef15c22a1c54b0f728ceff3f62d478dbf8b0dcf8ff7b80b954f79584f3e/ty-0.0.17-py3-none-linux_armv6l.whl", hash = "sha256:64a9a16555cc8867d35c2647c2f1afbd3cae55f68fd95283a574d1bb04fe93e0", size = 10192793, upload-time = "2026-02-13T13:27:13.943Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/2c/f4c322d9cded56edc016b1092c14b95cf58c8a33b4787316ea752bb9418e/ty-0.0.17-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:eb2dbd8acd5c5a55f4af0d479523e7c7265a88542efe73ed3d696eb1ba7b6454", size = 10051977, upload-time = "2026-02-13T13:26:57.741Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/a5/43746c1ff81e784f5fc303afc61fe5bcd85d0fcf3ef65cb2cef78c7486c7/ty-0.0.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f18f5fd927bc628deb9ea2df40f06b5f79c5ccf355db732025a3e8e7152801f6", size = 9564639, upload-time = "2026-02-13T13:26:42.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/b8/280b04e14a9c0474af574f929fba2398b5e1c123c1e7735893b4cd73d13c/ty-0.0.17-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5383814d1d7a5cc53b3b07661856bab04bb2aac7a677c8d33c55169acdaa83df", size = 10061204, upload-time = "2026-02-13T13:27:00.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/d7/493e1607d8dfe48288d8a768a2adc38ee27ef50e57f0af41ff273987cda0/ty-0.0.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9c20423b8744b484f93e7bf2ef8a9724bca2657873593f9f41d08bd9f83444c9", size = 10013116, upload-time = "2026-02-13T13:26:34.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/ef/22f3ed401520afac90dbdf1f9b8b7755d85b0d5c35c1cb35cf5bd11b59c2/ty-0.0.17-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e6f5b1aba97db9af86517b911674b02f5bc310750485dc47603a105bd0e83ddd", size = 10533623, upload-time = "2026-02-13T13:26:31.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/ce/744b15279a11ac7138832e3a55595706b4a8a209c9f878e3ab8e571d9032/ty-0.0.17-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:488bce1a9bea80b851a97cd34c4d2ffcd69593d6c3f54a72ae02e5c6e47f3d0c", size = 11069750, upload-time = "2026-02-13T13:26:48.638Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/be/1133c91f15a0e00d466c24f80df486d630d95d1b2af63296941f7473812f/ty-0.0.17-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8df66b91ec84239420985ec215e7f7549bfda2ac036a3b3c065f119d1c06825a", size = 10870862, upload-time = "2026-02-13T13:26:54.715Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/4a/a2ed209ef215b62b2d3246e07e833081e07d913adf7e0448fc204be443d6/ty-0.0.17-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:002139e807c53002790dfefe6e2f45ab0e04012e76db3d7c8286f96ec121af8f", size = 10628118, upload-time = "2026-02-13T13:26:45.439Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/0c/87476004cb5228e9719b98afffad82c3ef1f84334bde8527bcacba7b18cb/ty-0.0.17-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6c4e01f05ce82e5d489ab3900ca0899a56c4ccb52659453780c83e5b19e2b64c", size = 10038185, upload-time = "2026-02-13T13:27:02.693Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/4b/98f0b3ba9aef53c1f0305519536967a4aa793a69ed72677b0a625c5313ac/ty-0.0.17-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2b226dd1e99c0d2152d218c7e440150d1a47ce3c431871f0efa073bbf899e881", size = 10047644, upload-time = "2026-02-13T13:27:05.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/e0/06737bb80aa1a9103b8651d2eb691a7e53f1ed54111152be25f4a02745db/ty-0.0.17-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8b11f1da7859e0ad69e84b3c5ef9a7b055ceed376a432fad44231bdfc48061c2", size = 10231140, upload-time = "2026-02-13T13:27:10.844Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/79/e2a606bd8852383ba9abfdd578f4a227bd18504145381a10a5f886b4e751/ty-0.0.17-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c04e196809ff570559054d3e011425fd7c04161529eb551b3625654e5f2434cb", size = 10718344, upload-time = "2026-02-13T13:26:51.66Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/2d/2663984ac11de6d78f74432b8b14ba64d170b45194312852b7543cf7fd56/ty-0.0.17-py3-none-win32.whl", hash = "sha256:305b6ed150b2740d00a817b193373d21f0767e10f94ac47abfc3b2e5a5aec809", size = 9672932, upload-time = "2026-02-13T13:27:08.522Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/b5/39be78f30b31ee9f5a585969930c7248354db90494ff5e3d0756560fb731/ty-0.0.17-py3-none-win_amd64.whl", hash = "sha256:531828267527aee7a63e972f54e5eee21d9281b72baf18e5c2850c6b862add83", size = 10542138, upload-time = "2026-02-13T13:27:17.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/b7/f875c729c5d0079640c75bad2c7e5d43edc90f16ba242f28a11966df8f65/ty-0.0.17-py3-none-win_arm64.whl", hash = "sha256:de9810234c0c8d75073457e10a84825b9cd72e6629826b7f01c7a0b266ae25b1", size = 10023068, upload-time = "2026-02-13T13:26:39.637Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/d8/920460d4c22ea68fcdeb0b2fb53ea2aeb9c6d7875bde9278d84f2ac767b6/ty-0.0.18-py3-none-linux_armv6l.whl", hash = "sha256:4e5e91b0a79857316ef893c5068afc4b9872f9d257627d9bc8ac4d2715750d88", size = 10280825, upload-time = "2026-02-20T21:51:25.03Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/56/62587de582d3d20d78fcdddd0594a73822ac5a399a12ef512085eb7a4de6/ty-0.0.18-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ee0e578b3f8416e2d5416da9553b78fd33857868aa1384cb7fefeceee5ff102d", size = 10118324, upload-time = "2026-02-20T21:51:22.27Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/2d/dbdace8d432a0755a7417f659bfd5b8a4261938ecbdfd7b42f4c454f5aa9/ty-0.0.18-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3f7a0487d36b939546a91d141f7fc3dbea32fab4982f618d5b04dc9d5b6da21e", size = 9605861, upload-time = "2026-02-20T21:51:16.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/d9/de11c0280f778d5fc571393aada7fe9b8bc1dd6a738f2e2c45702b8b3150/ty-0.0.18-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5e2fa8d45f57ca487a470e4bf66319c09b561150e98ae2a6b1a97ef04c1a4eb", size = 10092701, upload-time = "2026-02-20T21:51:26.862Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/94/068d4d591d791041732171e7b63c37a54494b2e7d28e88d2167eaa9ad875/ty-0.0.18-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d75652e9e937f7044b1aca16091193e7ef11dac1c7ec952b7fb8292b7ba1f5f2", size = 10109203, upload-time = "2026-02-20T21:51:11.59Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/e4/526a4aa56dc0ca2569aaa16880a1ab105c3b416dd70e87e25a05688999f3/ty-0.0.18-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:563c868edceb8f6ddd5e91113c17d3676b028f0ed380bdb3829b06d9beb90e58", size = 10614200, upload-time = "2026-02-20T21:51:20.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/3d/b68ab20a34122a395880922587fbfc3adf090d22e0fb546d4d20fe8c2621/ty-0.0.18-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:502e2a1f948bec563a0454fc25b074bf5cf041744adba8794d024277e151d3b0", size = 11153232, upload-time = "2026-02-20T21:51:14.121Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/ea/678243c042343fcda7e6af36036c18676c355878dcdcd517639586d2cf9e/ty-0.0.18-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cc881dea97021a3aa29134a476937fd8054775c4177d01b94db27fcfb7aab65b", size = 10832934, upload-time = "2026-02-20T21:51:32.92Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/bd/7f8d647cef8b7b346c0163230a37e903c7461c7248574840b977045c77df/ty-0.0.18-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:421fcc3bc64cab56f48edb863c7c1c43649ec4d78ff71a1acb5366ad723b6021", size = 10700888, upload-time = "2026-02-20T21:51:09.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/06/cb3620dc48c5d335ba7876edfef636b2f4498eff4a262ff90033b9e88408/ty-0.0.18-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0fe5038a7136a0e638a2fb1ad06e3d3c4045314c6ba165c9c303b9aeb4623d6c", size = 10078965, upload-time = "2026-02-20T21:51:07.678Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/27/c77a5a84533fa3b685d592de7b4b108eb1f38851c40fac4e79cc56ec7350/ty-0.0.18-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d123600a52372677613a719bbb780adeb9b68f47fb5f25acb09171de390e0035", size = 10134659, upload-time = "2026-02-20T21:51:18.311Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/6e/60af6b88c73469e628ba5253a296da6984e0aa746206f3034c31f1a04ed1/ty-0.0.18-py3-none-musllinux_1_2_i686.whl", hash = "sha256:bb4bc11d32a1bf96a829bf6b9696545a30a196ac77bbc07cc8d3dfee35e03723", size = 10297494, upload-time = "2026-02-20T21:51:39.631Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/90/612dc0b68224c723faed6adac2bd3f930a750685db76dfe17e6b9e534a83/ty-0.0.18-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:dda2efbf374ba4cd704053d04e32f2f784e85c2ddc2400006b0f96f5f7e4b667", size = 10791944, upload-time = "2026-02-20T21:51:37.13Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/da/f4ada0fd08a9e4138fe3fd2bcd3797753593f423f19b1634a814b9b2a401/ty-0.0.18-py3-none-win32.whl", hash = "sha256:c5768607c94977dacddc2f459ace6a11a408a0f57888dd59abb62d28d4fee4f7", size = 9677964, upload-time = "2026-02-20T21:51:42.039Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/fa/090ed9746e5c59fc26d8f5f96dc8441825171f1f47752f1778dad690b08b/ty-0.0.18-py3-none-win_amd64.whl", hash = "sha256:b78d0fa1103d36fc2fce92f2092adace52a74654ab7884d54cdaec8eb5016a4d", size = 10636576, upload-time = "2026-02-20T21:51:29.159Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/4f/5dd60904c8105cda4d0be34d3a446c180933c76b84ae0742e58f02133713/ty-0.0.18-py3-none-win_arm64.whl", hash = "sha256:01770c3c82137c6b216aa3251478f0b197e181054ee92243772de553d3586398", size = 10095449, upload-time = "2026-02-20T21:51:34.914Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: test test_watch lint format
|
||||
.PHONY: test test_watch lint type format
|
||||
|
||||
######################
|
||||
# TESTING AND COVERAGE
|
||||
@@ -61,6 +61,9 @@ lint lint_diff lint_package lint_tests:
|
||||
[ "$(PYTHON_FILES)" = "" ] || mkdir -p $(MYPY_CACHE)
|
||||
[ "$(PYTHON_FILES)" = "" ] || uv run mypy $(PYTHON_FILES) --cache-dir $(MYPY_CACHE)
|
||||
|
||||
type:
|
||||
mkdir -p $(MYPY_CACHE) && uv run mypy $(PYTHON_FILES) --cache-dir $(MYPY_CACHE)
|
||||
|
||||
format format_diff:
|
||||
uv run ruff format $(PYTHON_FILES)
|
||||
uv run ruff check --select I --fix $(PYTHON_FILES)
|
||||
|
||||
@@ -13,7 +13,7 @@ license = "MIT"
|
||||
license-files = ['LICENSE']
|
||||
dependencies = [
|
||||
"langgraph-checkpoint>=2.1.2,<5.0.0",
|
||||
"orjson>=3.10.1",
|
||||
"orjson>=3.11.5",
|
||||
"psycopg>=3.2.0",
|
||||
"psycopg-pool>=3.2.0",
|
||||
]
|
||||
|
||||
Generated
+81
-80
@@ -259,7 +259,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-checkpoint"
|
||||
version = "4.0.0"
|
||||
version = "4.0.1"
|
||||
source = { editable = "../checkpoint" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -280,6 +280,7 @@ dev = [
|
||||
{ name = "numpy" },
|
||||
{ name = "pandas" },
|
||||
{ name = "pandas-stubs", specifier = ">=2.2.2.240807" },
|
||||
{ name = "pycryptodome", specifier = ">=3.23.0" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "pytest-mock" },
|
||||
@@ -346,7 +347,7 @@ test = [
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
|
||||
{ name = "orjson", specifier = ">=3.10.1" },
|
||||
{ name = "orjson", specifier = ">=3.11.5" },
|
||||
{ name = "psycopg", specifier = ">=3.2.0" },
|
||||
{ name = "psycopg-pool", specifier = ">=3.2.0" },
|
||||
]
|
||||
@@ -691,15 +692,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "psycopg"
|
||||
version = "3.3.2"
|
||||
version = "3.3.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e0/1a/7d9ef4fdc13ef7f15b934c393edc97a35c281bb7d3c3329fbfcbe915a7c2/psycopg-3.3.2.tar.gz", hash = "sha256:707a67975ee214d200511177a6a80e56e654754c9afca06a7194ea6bbfde9ca7", size = 165630, upload-time = "2025-12-06T17:34:53.899Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d3/b6/379d0a960f8f435ec78720462fd94c4863e7a31237cf81bf76d0af5883bf/psycopg-3.3.3.tar.gz", hash = "sha256:5e9a47458b3c1583326513b2556a2a9473a1001a56c9efe9e587245b43148dd9", size = 165624, upload-time = "2026-02-18T16:52:16.546Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/51/2779ccdf9305981a06b21a6b27e8547c948d85c41c76ff434192784a4c93/psycopg-3.3.2-py3-none-any.whl", hash = "sha256:3e94bc5f4690247d734599af56e51bae8e0db8e4311ea413f801fef82b14a99b", size = 212774, upload-time = "2025-12-06T17:31:41.414Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/5b/181e2e3becb7672b502f0ed7f16ed7352aca7c109cfb94cf3878a9186db9/psycopg-3.3.3-py3-none-any.whl", hash = "sha256:f96525a72bcfade6584ab17e89de415ff360748c766f0106959144dcbb38c698", size = 212768, upload-time = "2026-02-18T16:46:27.365Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -709,64 +710,64 @@ binary = [
|
||||
|
||||
[[package]]
|
||||
name = "psycopg-binary"
|
||||
version = "3.3.2"
|
||||
version = "3.3.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/25/d7/edfb0d9e56081246fd88490f99b1bafebd3588480cca601a4de0c41a3e08/psycopg_binary-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0768c5f32934bb52a5df098317eca9bdcf411de627c5dca2ee57662b64b54b41", size = 4597785, upload-time = "2025-12-06T17:31:44.867Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/45/8458201d9573dd851263a05cefddd4bfd31e8b3c6434b3e38d62aea9f15a/psycopg_binary-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:09b3014013f05cd89828640d3a1db5f829cc24ad8fa81b6e42b2c04685a0c9d4", size = 4664440, upload-time = "2025-12-06T17:31:49.1Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/33/484260d87456cfe88dc219c1919026f11949b9d1de8a6371ddbe027d4d60/psycopg_binary-3.3.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3789d452a9d17a841c7f4f97bbcba51a21f957ea35641a4c98507520e6b6a068", size = 5478355, upload-time = "2025-12-06T17:31:52.657Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/b2/18c91630c30c83f534c2bfa75fb533293fc9c3ab31bb7f2bf1cd9579c53b/psycopg_binary-3.3.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:44e89938d36acc4495735af70a886d206a5bfdc80258f95b69b52f68b2968d9e", size = 5152398, upload-time = "2025-12-06T17:31:56.092Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/14/7c705e1934107196d9dca2040cf34bce2ca26de62520e43073d2673052d4/psycopg_binary-3.3.2-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90ed9da805e52985b0202aed4f352842c907c6b4fc6c7c109c6e646c32e2f43b", size = 6748982, upload-time = "2025-12-06T17:32:00.611Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/18/80197c47798926f79e563af02a71d1abecab88cf45ddf8dc960700598da7/psycopg_binary-3.3.2-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c3a9ccdfee4ae59cf9bf1822777e763bc097ed208f4901e21537fca1070e1391", size = 4991214, upload-time = "2025-12-06T17:32:03.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/2e/e88e2f678f5d1a968d87e57b30915061c1157e916b8aaa9b0b78bca95e25/psycopg_binary-3.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:de9173f8cc0efd88ac2a89b3b6c287a9a0011cdc2f53b2a12c28d6fd55f9f81c", size = 4517421, upload-time = "2025-12-06T17:32:07.287Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/9e/d56813b24370723bcd62bf73871aee4d5fca0536f3476c4c4d5b037e3c7f/psycopg_binary-3.3.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:0611f4822674f3269e507a307236efb62ae5a828fcfc923ac85fe22ca19fd7c8", size = 4206124, upload-time = "2025-12-06T17:32:10.374Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/81/5a11a898969edf0ee43d0613a6dfd689a0aa12d418c69e148a8ff153fbc7/psycopg_binary-3.3.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:522b79c7db547767ca923e441c19b97a2157f2f494272a119c854bba4804e186", size = 3937067, upload-time = "2025-12-06T17:32:13.852Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/33/a6180ff1e747a0395876d985e8e295c9d7cbe956a2d66f165e7c67cffe55/psycopg_binary-3.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1ea41c0229f3f5a3844ad0857a83a9f869aa7b840448fa0c200e6bcf85d33d19", size = 4243731, upload-time = "2025-12-06T17:32:16.803Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/5b/9c1b6fbc900d5b525946ed9a477865c5016a5306080c0557248bb04f1a5b/psycopg_binary-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:8ea05b499278790a8fa0ff9854ab0de2542aca02d661ddff94e830df971ff640", size = 3546403, upload-time = "2025-12-06T17:32:19.621Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/d9/49640360fc090d27afc4655021544aa71d5393ebae124ffa53a04474b493/psycopg_binary-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:94503b79f7da0b65c80d0dbb2f81dd78b300319ec2435d5e6dcf9622160bc2fa", size = 4597890, upload-time = "2025-12-06T17:32:23.087Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/cf/99634bbccc8af0dd86df4bce705eea5540d06bb7f5ab3067446ae9ffdae4/psycopg_binary-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:07a5f030e0902ec3e27d0506ceb01238c0aecbc73ecd7fa0ee55f86134600b5b", size = 4664396, upload-time = "2025-12-06T17:32:26.421Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/db/6035dff6d5c6dfca3a4ab0d2ac62ede623646e327e9f99e21e0cf08976c6/psycopg_binary-3.3.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e09d0d93d35c134704a2cb2b15f81ffc8174fd602f3e08f7b1a3d8896156cf0", size = 5478743, upload-time = "2025-12-06T17:32:29.901Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/0f/fc06bbc8e87f09458d2ce04a59cd90565e54e8efca33e0802daee6d2b0e6/psycopg_binary-3.3.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:649c1d33bedda431e0c1df646985fbbeb9274afa964e1aef4be053c0f23a2924", size = 5151820, upload-time = "2025-12-06T17:32:33.562Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/ab/bcc0397c96a0ad29463e33ed03285826e0fabc43595c195f419d9291ee70/psycopg_binary-3.3.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5774272f754605059521ff037a86e680342e3847498b0aa86b0f3560c70963c", size = 6747711, upload-time = "2025-12-06T17:32:38.074Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/eb/7450bc75c31d5be5f7a6d02d26beef6989a4ca6f5efdec65eea6cf612d0e/psycopg_binary-3.3.2-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d391b70c9cc23f6e1142729772a011f364199d2c5ddc0d596f5f43316fbf982d", size = 4991626, upload-time = "2025-12-06T17:32:41.373Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/85/65f14453804c82a7fba31cd1a984b90349c0f327b809102c4b99115c0930/psycopg_binary-3.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f3f601f32244a677c7b029ec39412db2772ad04a28bc2cbb4b1f0931ed0ffad7", size = 4516760, upload-time = "2025-12-06T17:32:44.921Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/8c/3105f00a91d73d9a443932f95156eae8159d5d9cb68a9d2cf512710d484f/psycopg_binary-3.3.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0ae60e910531cfcc364a8f615a7941cac89efeb3f0fffe0c4824a6d11461eef7", size = 4204028, upload-time = "2025-12-06T17:32:48.355Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/dd/74f64a383342ef7c22d1eb2768ed86411c7f877ed2580cd33c17f436fe3c/psycopg_binary-3.3.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c43a773dd1a481dbb2fe64576aa303d80f328cce0eae5e3e4894947c41d1da7", size = 3935780, upload-time = "2025-12-06T17:32:51.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/30/f3f207d1c292949a26cdea6727c9c325b4ee41e04bf2736a4afbe45eb61f/psycopg_binary-3.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5a327327f1188b3fbecac41bf1973a60b86b2eb237db10dc945bd3dc97ec39e4", size = 4243239, upload-time = "2025-12-06T17:32:54.924Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/08/8f1b5d6231338bf7bc46f635c4d4965facec52e1c9a7952ca8a70cb57dc0/psycopg_binary-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:136c43f185244893a527540307167f5d3ef4e08786508afe45d6f146228f5aa9", size = 3548102, upload-time = "2025-12-06T17:32:57.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/1e/8614b01c549dd7e385dacdcd83fe194f6b3acb255a53cc67154ee6bf00e7/psycopg_binary-3.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a9387ab615f929e71ef0f4a8a51e986fa06236ccfa9f3ec98a88f60fbf230634", size = 4579832, upload-time = "2025-12-06T17:33:01.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/97/0bb093570fae2f4454d42c1ae6000f15934391867402f680254e4a7def54/psycopg_binary-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3ff7489df5e06c12d1829544eaec64970fe27fe300f7cf04c8495fe682064688", size = 4658786, upload-time = "2025-12-06T17:33:05.022Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/20/1d9383e3f2038826900a14137b0647d755f67551aab316e1021443105ed5/psycopg_binary-3.3.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:9742580ecc8e1ac45164e98d32ca6df90da509c2d3ff26be245d94c430f92db4", size = 5454896, upload-time = "2025-12-06T17:33:09.023Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/62/513c80ad8bbb545e364f7737bf2492d34a4c05eef4f7b5c16428dc42260d/psycopg_binary-3.3.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d45acedcaa58619355f18e0f42af542fcad3fd84ace4b8355d3a5dea23318578", size = 5132731, upload-time = "2025-12-06T17:33:12.519Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/28/ddf5f5905f088024bccb19857949467407c693389a14feb527d6171d8215/psycopg_binary-3.3.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d88f32ff8c47cb7f4e7e7a9d1747dcee6f3baa19ed9afa9e5694fd2fb32b61ed", size = 6724495, upload-time = "2025-12-06T17:33:16.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/93/a1157ebcc650960b264542b547f7914d87a42ff0cc15a7584b29d5807e6b/psycopg_binary-3.3.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:59d0163c4617a2c577cb34afbed93d7a45b8c8364e54b2bd2020ff25d5f5f860", size = 4964979, upload-time = "2025-12-06T17:33:20.179Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/27/65939ba6798f9c5be4a5d9cd2061ebaf0851798525c6811d347821c8132d/psycopg_binary-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e750afe74e6c17b2c7046d2c3e3173b5a3f6080084671c8aa327215323df155b", size = 4493648, upload-time = "2025-12-06T17:33:23.464Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/c4/5e9e4b9b1c1e27026e43387b0ba4aaf3537c7806465dd3f1d5bde631752a/psycopg_binary-3.3.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f26f113013c4dcfbfe9ced57b5bad2035dda1a7349f64bf726021968f9bccad3", size = 4173392, upload-time = "2025-12-06T17:33:26.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/81/cf43fb76993190cee9af1cbcfe28afb47b1928bdf45a252001017e5af26e/psycopg_binary-3.3.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8309ee4569dced5e81df5aa2dcd48c7340c8dee603a66430f042dfbd2878edca", size = 3909241, upload-time = "2025-12-06T17:33:30.092Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/20/c6377a0d17434674351627489deca493ea0b137c522b99c81d3a106372c8/psycopg_binary-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c6464150e25b68ae3cb04c4e57496ea11ebfaae4d98126aea2f4702dd43e3c12", size = 4219746, upload-time = "2025-12-06T17:33:33.097Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/32/716c57b28eefe02a57a4c9d5bf956849597f5ea476c7010397199e56cfde/psycopg_binary-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:716a586f99bbe4f710dc58b40069fcb33c7627e95cc6fc936f73c9235e07f9cf", size = 3537494, upload-time = "2025-12-06T17:33:35.82Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/73/7ca7cb22b9ac7393fb5de7d28ca97e8347c375c8498b3bff2c99c1f38038/psycopg_binary-3.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fc5a189e89cbfff174588665bb18d28d2d0428366cc9dae5864afcaa2e57380b", size = 4579068, upload-time = "2025-12-06T17:33:39.303Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/42/0cf38ff6c62c792fc5b55398a853a77663210ebd51ed6f0c4a05b06f95a6/psycopg_binary-3.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:083c2e182be433f290dc2c516fd72b9b47054fcd305cce791e0a50d9e93e06f2", size = 4657520, upload-time = "2025-12-06T17:33:42.536Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/60/df846bc84cbf2231e01b0fff48b09841fe486fa177665e50f4995b1bfa44/psycopg_binary-3.3.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:ac230e3643d1c436a2dfb59ca84357dfc6862c9f372fc5dbd96bafecae581f9f", size = 5452086, upload-time = "2025-12-06T17:33:46.54Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/85/30c846a00db86b1b53fd5bfd4b4edfbd0c00de8f2c75dd105610bd7568fc/psycopg_binary-3.3.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d8c899a540f6c7585cee53cddc929dd4d2db90fd828e37f5d4017b63acbc1a5d", size = 5131125, upload-time = "2025-12-06T17:33:50.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/15/9968732013373f36f8a2a3fb76104dffc8efd9db78709caa5ae1a87b1f80/psycopg_binary-3.3.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50ff10ab8c0abdb5a5451b9315538865b50ba64c907742a1385fdf5f5772b73e", size = 6722914, upload-time = "2025-12-06T17:33:54.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/ba/29e361fe02143ac5ff5a1ca3e45697344cfbebe2eaf8c4e7eec164bff9a0/psycopg_binary-3.3.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:23d2594af848c1fd3d874a9364bef50730124e72df7bb145a20cb45e728c50ed", size = 4966081, upload-time = "2025-12-06T17:33:58.477Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/45/1be90c8f1a1a237046903e91202fb06708745c179f220b361d6333ed7641/psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ea4fe6b4ead3bbbe27244ea224fcd1f53cb119afc38b71a2f3ce570149a03e30", size = 4493332, upload-time = "2025-12-06T17:34:02.011Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/b5/bbdc07d5f0a5e90c617abd624368182aa131485e18038b2c6c85fc054aed/psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:742ce48cde825b8e52fb1a658253d6d1ff66d152081cbc76aa45e2986534858d", size = 4170781, upload-time = "2025-12-06T17:34:05.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/2a/0d45e4f4da2bd78c3237ffa03475ef3751f69a81919c54a6e610eb1a7c96/psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e22bf6b54df994aff37ab52695d635f1ef73155e781eee1f5fa75bc08b58c8da", size = 3910544, upload-time = "2025-12-06T17:34:08.251Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/62/a8e0f092f4dbef9a94b032fb71e214cf0a375010692fbe7493a766339e47/psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8db9034cde3bcdafc66980f0130813f5c5d19e74b3f2a19fb3cfbc25ad113121", size = 4220070, upload-time = "2025-12-06T17:34:11.392Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/e6/5fc8d8aff8afa114bb4a94a0341b9309311e8bf3ab32d816032f8b984d4e/psycopg_binary-3.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:df65174c7cf6b05ea273ce955927d3270b3a6e27b0b12762b009ce6082b8d3fc", size = 3540922, upload-time = "2025-12-06T17:34:14.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/75/ad18c0b97b852aba286d06befb398cc6d383e9dfd0a518369af275a5a526/psycopg_binary-3.3.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9ca24062cd9b2270e4d77576042e9cc2b1d543f09da5aba1f1a3d016cea28390", size = 4596371, upload-time = "2025-12-06T17:34:18.007Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/79/91649d94c8d89f84af5da7c9d474bfba35b08eb8f492ca3422b08f0a6427/psycopg_binary-3.3.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c749770da0947bc972e512f35366dd4950c0e34afad89e60b9787a37e97cb443", size = 4675139, upload-time = "2025-12-06T17:34:21.374Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/ac/b26e004880f054549ec9396594e1ffe435810b0673e428e619ed722e4244/psycopg_binary-3.3.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:03b7cd73fb8c45d272a34ae7249713e32492891492681e3cf11dff9531cf37e9", size = 5456120, upload-time = "2025-12-06T17:34:25.102Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/8d/410681dccd6f2999fb115cc248521ec50dd2b0aba66ae8de7e81efdebbee/psycopg_binary-3.3.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:43b130e3b6edcb5ee856c7167ccb8561b473308c870ed83978ae478613764f1c", size = 5133484, upload-time = "2025-12-06T17:34:28.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/30/ebbab99ea2cfa099d7b11b742ce13415d44f800555bfa4ad2911dc645b71/psycopg_binary-3.3.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c1feba5a8c617922321aef945865334e468337b8fc5c73074f5e63143013b5a", size = 6731818, upload-time = "2025-12-06T17:34:33.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/02/d260646253b7ad805d60e0de47f9b811d6544078452579466a098598b6f4/psycopg_binary-3.3.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cabb2a554d9a0a6bf84037d86ca91782f087dfff2a61298d0b00c19c0bc43f6d", size = 4983859, upload-time = "2025-12-06T17:34:36.457Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/8d/e778d7bad1a7910aa36281f092bd85c5702f508fd9bb0ea2020ffbb6585c/psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:74bc306c4b4df35b09bc8cecf806b271e1c5d708f7900145e4e54a2e5dedfed0", size = 4516388, upload-time = "2025-12-06T17:34:40.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/f1/64e82098722e2ab3521797584caf515284be09c1e08a872551b6edbb0074/psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d79b0093f0fbf7a962d6a46ae292dc056c65d16a8ee9361f3cfbafd4c197ab14", size = 4192382, upload-time = "2025-12-06T17:34:43.279Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/d0/c20f4e668e89494972e551c31be2a0016e3f50d552d7ae9ac07086407599/psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1586e220be05547c77afc326741dd41cc7fba38a81f9931f616ae98865439678", size = 3928660, upload-time = "2025-12-06T17:34:46.757Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/e1/99746c171de22539fd5eb1c9ca21dc805b54cfae502d7451d237d1dbc349/psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:458696a5fa5dad5b6fb5d5862c22454434ce4fe1cf66ca6c0de5f904cbc1ae3e", size = 4239169, upload-time = "2025-12-06T17:34:49.751Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/f7/212343c1c9cfac35fd943c527af85e9091d633176e2a407a0797856ff7b9/psycopg_binary-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:04bb2de4ba69d6f8395b446ede795e8884c040ec71d01dd07ac2b2d18d4153d1", size = 3642122, upload-time = "2025-12-06T17:34:52.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/d8/a763308a41e2ecfb6256ba0877d340c2f2b124c8b2746401863d96fa2c7a/psycopg_binary-3.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b3385b58b2fe408a13d084c14b8dcf468cd36cbbe774408250facc128f9fa75c", size = 4609758, upload-time = "2026-02-18T16:46:33.132Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/a9/f8a683e85400c1208685e7c895abc049dc13aa0b6ea989e6adf0a3681fe0/psycopg_binary-3.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1bef235a50a80f6aba05147002bc354559657cb6386dbd04d8e1c97d1d7cbe84", size = 4676740, upload-time = "2026-02-18T16:46:42.904Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/7d/03512c4aaac8a58fc3b1221f38293aa517a1950d10ef8646c72c49addc7d/psycopg_binary-3.3.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:97c839717bf8c8df3f6d983a20949c4fb22e2a34ee172e3e427ede363feda27b", size = 5496335, upload-time = "2026-02-18T16:46:51.517Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/bc/23319b4b1c2c0b810d225e1b6f16efbb16150074fc0ea96bfcabdf59ee09/psycopg_binary-3.3.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:48e500cf1c0984dacf1f28ea482c3cdbb4c2288d51c336c04bc64198ab21fc51", size = 5172032, upload-time = "2026-02-18T16:47:00.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/c8/6d61dc0a56654c558a37b2d9b2094e470aa12621305cc7935fd769122e32/psycopg_binary-3.3.3-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb36a08859b9432d94ea6b26ec41a2f98f83f14868c91321d0c1e11f672eeae7", size = 6763107, upload-time = "2026-02-18T16:47:11.784Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/b5/e2a3c90aa1059f5b5f593379caad7be3cc3c2ce1ddfc7730e39854e174fe/psycopg_binary-3.3.3-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dde92cfde09293fb63b3f547919ba7d73bd2654573c03502b3263dd0218e44e", size = 5006494, upload-time = "2026-02-18T16:47:17.062Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/3e/bf126e0a1f864e191b7f3eeea667ee2ce13d582b036255fb8b12946d1f7a/psycopg_binary-3.3.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:78c9ce98caaf82ac8484d269791c1b403d7598633e0e4e2fa1097baae244e2f1", size = 4533850, upload-time = "2026-02-18T16:47:21.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/d8/bb5e8d395deb945629aa0c65d12ab90ec3bfcbdf56be89e2a84d001864c9/psycopg_binary-3.3.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d593612758d0041cb13cb0003f7f8d3fabb7ad9319e651e78afae49b1cf5860e", size = 4223316, upload-time = "2026-02-18T16:47:25.82Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/70/33eef61b0f0fd41ebf93b9699f44067313a45016827f67b3c8cc41f0a7ab/psycopg_binary-3.3.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:f24e8e17035200a465c178e9ea945527ad0738118694184c450f1192a452ff25", size = 3954515, upload-time = "2026-02-18T16:47:30.434Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/db/27c2b3b9698e713e83e11e8540daa27516f9e90390ec21a41091cb15fcaf/psycopg_binary-3.3.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e7b607f0e14f2a4cf7e78a05ebd13df6144acfba87cb90842e70d3f125d9f53f", size = 4260274, upload-time = "2026-02-18T16:47:36.128Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/3b/71e5d603059bf5474215f573a3e2d357a4e95672b26e04d41674400d4862/psycopg_binary-3.3.3-cp310-cp310-win_amd64.whl", hash = "sha256:b27d3a23c79fa59557d2cc63a7e8bb4c7e022c018558eda36f9d7c4e6b99a6e0", size = 3557375, upload-time = "2026-02-18T16:47:42.799Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/c0/b389119dd754483d316805260f3e73cdcad97925839107cc7a296f6132b1/psycopg_binary-3.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a89bb9ee11177b2995d87186b1d9fa892d8ea725e85eab28c6525e4cc14ee048", size = 4609740, upload-time = "2026-02-18T16:47:51.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/e3/9976eef20f61840285174d360da4c820a311ab39d6b82fa09fbb545be825/psycopg_binary-3.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9f7d0cf072c6fbac3795b08c98ef9ea013f11db609659dcfc6b1f6cc31f9e181", size = 4676837, upload-time = "2026-02-18T16:47:55.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/f2/d28ba2f7404fd7f68d41e8a11df86313bd646258244cb12a8dd83b868a97/psycopg_binary-3.3.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:90eecd93073922f085967f3ed3a98ba8c325cbbc8c1a204e300282abd2369e13", size = 5497070, upload-time = "2026-02-18T16:47:59.929Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/2f/6c5c54b815edeb30a281cfcea96dc93b3bb6be939aea022f00cab7aa1420/psycopg_binary-3.3.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dac7ee2f88b4d7bb12837989ca354c38d400eeb21bce3b73dac02622f0a3c8d6", size = 5172410, upload-time = "2026-02-18T16:48:05.665Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/75/8206c7008b57de03c1ada46bd3110cc3743f3fd9ed52031c4601401d766d/psycopg_binary-3.3.3-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b62cf8784eb6d35beaee1056d54caf94ec6ecf2b7552395e305518ab61eb8fd2", size = 6763408, upload-time = "2026-02-18T16:48:13.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/5a/ea1641a1e6c8c8b3454b0fcb43c3045133a8b703e6e824fae134088e63bd/psycopg_binary-3.3.3-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a39f34c9b18e8f6794cca17bfbcd64572ca2482318db644268049f8c738f35a6", size = 5006255, upload-time = "2026-02-18T16:48:22.176Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/fb/538df099bf55ae1637d52d7ccb6b9620b535a40f4c733897ac2b7bb9e14c/psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:883d68d48ca9ff3cb3d10c5fdebea02c79b48eecacdddbf7cce6e7cdbdc216b8", size = 4532694, upload-time = "2026-02-18T16:48:27.338Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/d1/00780c0e187ea3c13dfc53bd7060654b2232cd30df562aac91a5f1c545ac/psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:cab7bc3d288d37a80aa8c0820033250c95e40b1c2b5c57cf59827b19c2a8b69d", size = 4222833, upload-time = "2026-02-18T16:48:31.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/34/a07f1ff713c51d64dc9f19f2c32be80299a2055d5d109d5853662b922cb4/psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:56c767007ca959ca32f796b42379fc7e1ae2ed085d29f20b05b3fc394f3715cc", size = 3952818, upload-time = "2026-02-18T16:48:35.869Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/67/d33f268a7759b4445f3c9b5a181039b01af8c8263c865c1be7a6444d4749/psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:da2f331a01af232259a21573a01338530c6016dcfad74626c01330535bcd8628", size = 4258061, upload-time = "2026-02-18T16:48:41.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/3b/0d8d2c5e8e29ccc07d28c8af38445d9d9abcd238d590186cac82ee71fc84/psycopg_binary-3.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:19f93235ece6dbfc4036b5e4f6d8b13f0b8f2b3eeb8b0bd2936d406991bcdd40", size = 3558915, upload-time = "2026-02-18T16:48:46.679Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/15/021be5c0cbc5b7c1ab46e91cc3434eb42569f79a0592e67b8d25e66d844d/psycopg_binary-3.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6698dbab5bcef8fdb570fc9d35fd9ac52041771bfcfe6fd0fc5f5c4e36f1e99d", size = 4591170, upload-time = "2026-02-18T16:48:55.594Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/54/a60211c346c9a2f8c6b272b5f2bbe21f6e11800ce7f61e99ba75cf8b63e1/psycopg_binary-3.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:329ff393441e75f10b673ae99ab45276887993d49e65f141da20d915c05aafd8", size = 4670009, upload-time = "2026-02-18T16:49:03.608Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/53/ac7c18671347c553362aadbf65f92786eef9540676ca24114cc02f5be405/psycopg_binary-3.3.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:eb072949b8ebf4082ae24289a2b0fd724da9adc8f22743409d6fd718ddb379df", size = 5469735, upload-time = "2026-02-18T16:49:10.128Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/c3/4f4e040902b82a344eff1c736cde2f2720f127fe939c7e7565706f96dd44/psycopg_binary-3.3.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:263a24f39f26e19ed7fc982d7859a36f17841b05bebad3eb47bb9cd2dd785351", size = 5152919, upload-time = "2026-02-18T16:49:16.335Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/e7/d929679c6a5c212bcf738806c7c89f5b3d0919f2e1685a0e08d6ff877945/psycopg_binary-3.3.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5152d50798c2fa5bd9b68ec68eb68a1b71b95126c1d70adaa1a08cd5eefdc23d", size = 6738785, upload-time = "2026-02-18T16:49:22.687Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/b0/09703aeb69a9443d232d7b5318d58742e8ca51ff79f90ffe6b88f1db45e7/psycopg_binary-3.3.3-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d6a1e56dd267848edb824dbeb08cf5bac649e02ee0b03ba883ba3f4f0bd54f2", size = 4979008, upload-time = "2026-02-18T16:49:27.313Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/a6/e662558b793c6e13a7473b970fee327d635270e41eded3090ef14045a6a5/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73eaaf4bb04709f545606c1db2f65f4000e8a04cdbf3e00d165a23004692093e", size = 4508255, upload-time = "2026-02-18T16:49:31.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/7f/0f8b2e1d5e0093921b6f324a948a5c740c1447fbb45e97acaf50241d0f39/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:162e5675efb4704192411eaf8e00d07f7960b679cd3306e7efb120bb8d9456cc", size = 4189166, upload-time = "2026-02-18T16:49:35.801Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/ec/ce2e91c33bc8d10b00c87e2f6b0fb570641a6a60042d6a9ae35658a3a797/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:fab6b5e37715885c69f5d091f6ff229be71e235f272ebaa35158d5a46fd548a0", size = 3924544, upload-time = "2026-02-18T16:49:41.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/2f/7718141485f73a924205af60041c392938852aa447a94c8cbd222ff389a1/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a4aab31bd6d1057f287c96c0effca3a25584eb9cc702f282ecb96ded7814e830", size = 4235297, upload-time = "2026-02-18T16:49:46.726Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/f9/1add717e2643a003bbde31b1b220172e64fbc0cb09f06429820c9173f7fc/psycopg_binary-3.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:59aa31fe11a0e1d1bcc2ce37ed35fe2ac84cd65bb9036d049b1a1c39064d0f14", size = 3547659, upload-time = "2026-02-18T16:49:52.999Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/0a/cac9fdf1df16a269ba0e5f0f06cac61f826c94cadb39df028cdfe19d3a33/psycopg_binary-3.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05f32239aec25c5fb15f7948cffdc2dc0dac098e48b80a140e4ba32b572a2e7d", size = 4590414, upload-time = "2026-02-18T16:50:01.441Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/c0/d8f8508fbf440edbc0099b1abff33003cd80c9e66eb3a1e78834e3fb4fb9/psycopg_binary-3.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c84f9d214f2d1de2fafebc17fa68ac3f6561a59e291553dfc45ad299f4898c1", size = 4669021, upload-time = "2026-02-18T16:50:08.803Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/05/097016b77e343b4568feddf12c72171fc513acef9a4214d21b9478569068/psycopg_binary-3.3.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e77957d2ba17cada11be09a5066d93026cdb61ada7c8893101d7fe1c6e1f3925", size = 5467453, upload-time = "2026-02-18T16:50:14.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/23/73244e5feb55b5ca109cede6e97f32ef45189f0fdac4c80d75c99862729d/psycopg_binary-3.3.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:42961609ac07c232a427da7c87a468d3c82fee6762c220f38e37cfdacb2b178d", size = 5151135, upload-time = "2026-02-18T16:50:24.82Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/49/5309473b9803b207682095201d8708bbc7842ddf3f192488a69204e36455/psycopg_binary-3.3.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae07a3114313dd91fce686cab2f4c44af094398519af0e0f854bc707e1aeedf1", size = 6737315, upload-time = "2026-02-18T16:50:35.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/5d/03abe74ef34d460b33c4d9662bf6ec1dd38888324323c1a1752133c10377/psycopg_binary-3.3.3-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d257c58d7b36a621dcce1d01476ad8b60f12d80eb1406aee4cf796f88b2ae482", size = 4979783, upload-time = "2026-02-18T16:50:42.067Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/6c/3fbf8e604e15f2f3752900434046c00c90bb8764305a1b81112bff30ba24/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:07c7211f9327d522c9c47560cae00a4ecf6687f4e02d779d035dd3177b41cb12", size = 4509023, upload-time = "2026-02-18T16:50:50.116Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/6b/1a06b43b7c7af756c80b67eac8bfaa51d77e68635a8a8d246e4f0bb7604a/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:8e7e9eca9b363dbedeceeadd8be97149d2499081f3c52d141d7cd1f395a91f83", size = 4185874, upload-time = "2026-02-18T16:50:55.97Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/d3/bf49e3dcaadba510170c8d111e5e69e5ae3f981c1554c5bb71c75ce354bb/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:cb85b1d5702877c16f28d7b92ba030c1f49ebcc9b87d03d8c10bf45a2f1c7508", size = 3925668, upload-time = "2026-02-18T16:51:03.299Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/92/0aac830ed6a944fe334404e1687a074e4215630725753f0e3e9a9a595b62/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4d4606c84d04b80f9138d72f1e28c6c02dc5ae0c7b8f3f8aaf89c681ce1cd1b1", size = 4234973, upload-time = "2026-02-18T16:51:09.097Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/96/102244653ee5a143ece5afe33f00f52fe64e389dfce8dbc87580c6d70d3d/psycopg_binary-3.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:74eae563166ebf74e8d950ff359be037b85723d99ca83f57d9b244a871d6c13b", size = 3551342, upload-time = "2026-02-18T16:51:13.892Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/71/7a57e5b12275fe7e7d84d54113f0226080423a869118419c9106c083a21c/psycopg_binary-3.3.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:497852c5eaf1f0c2d88ab74a64a8097c099deac0c71de1cbcf18659a8a04a4b2", size = 4607368, upload-time = "2026-02-18T16:51:19.295Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/04/cb834f120f2b2c10d4003515ef9ca9d688115b9431735e3936ae48549af8/psycopg_binary-3.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:258d1ea53464d29768bf25930f43291949f4c7becc706f6e220c515a63a24edd", size = 4687047, upload-time = "2026-02-18T16:51:23.84Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/e9/47a69692d3da9704468041aa5ed3ad6fc7f6bb1a5ae788d261a26bbca6c7/psycopg_binary-3.3.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:111c59897a452196116db12e7f608da472fbff000693a21040e35fc978b23430", size = 5487096, upload-time = "2026-02-18T16:51:29.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/b6/0e0dd6a2f802864a4ae3dbadf4ec620f05e3904c7842b326aafc43e5f464/psycopg_binary-3.3.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:17bb6600e2455993946385249a3c3d0af52cd70c1c1cdbf712e9d696d0b0bf1b", size = 5168720, upload-time = "2026-02-18T16:51:36.499Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/0d/977af38ac19a6b55d22dff508bd743fd7c1901e1b73657e7937c7cccb0a3/psycopg_binary-3.3.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:642050398583d61c9856210568eb09a8e4f2fe8224bf3be21b67a370e677eead", size = 6762076, upload-time = "2026-02-18T16:51:43.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/40/912a39d48322cf86895c0eaf2d5b95cb899402443faefd4b09abbba6b6e1/psycopg_binary-3.3.3-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:533efe6dc3a7cba5e2a84e38970786bb966306863e45f3db152007e9f48638a6", size = 4997623, upload-time = "2026-02-18T16:51:47.707Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/0c/c14d0e259c65dc7be854d926993f151077887391d5a081118907a9d89603/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5958dbf28b77ce2033482f6cb9ef04d43f5d8f4b7636e6963d5626f000efb23e", size = 4532096, upload-time = "2026-02-18T16:51:51.421Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/21/8b7c50a194cfca6ea0fd4d1f276158307785775426e90700ab2eba5cd623/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a6af77b6626ce92b5817bf294b4d45ec1a6161dba80fc2d82cdffdd6814fd023", size = 4208884, upload-time = "2026-02-18T16:51:57.336Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/2c/a4981bf42cf30ebba0424971d7ce70a222ae9b82594c42fc3f2105d7b525/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:47f06fcbe8542b4d96d7392c476a74ada521c5aebdb41c3c0155f6595fc14c8d", size = 3944542, upload-time = "2026-02-18T16:52:04.266Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/e9/b7c29b56aa0b85a4e0c4d89db691c1ceef08f46a356369144430c155a2f5/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e7800e6c6b5dc4b0ca7cc7370f770f53ac83886b76afda0848065a674231e856", size = 4254339, upload-time = "2026-02-18T16:52:10.444Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/5a/291d89f44d3820fffb7a04ebc8f3ef5dda4f542f44a5daea0c55a84abf45/psycopg_binary-3.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:165f22ab5a9513a3d7425ffb7fcc7955ed8ccaeef6d37e369d6cc1dff1582383", size = 3652796, upload-time = "2026-02-18T16:52:14.02Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1073,27 +1074,27 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.1"
|
||||
version = "0.15.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/04/dc/4e6ac71b511b141cf626357a3946679abeba4cf67bc7cc5a17920f31e10d/ruff-0.15.1.tar.gz", hash = "sha256:c590fe13fb57c97141ae975c03a1aedb3d3156030cabd740d6ff0b0d601e203f", size = 4540855, upload-time = "2026-02-12T23:09:09.998Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/04/eab13a954e763b0606f460443fcbf6bb5a0faf06890ea3754ff16523dce5/ruff-0.15.2.tar.gz", hash = "sha256:14b965afee0969e68bb871eba625343b8673375f457af4abe98553e8bbb98342", size = 4558148, upload-time = "2026-02-19T22:32:20.271Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/23/bf/e6e4324238c17f9d9120a9d60aa99a7daaa21204c07fcd84e2ef03bb5fd1/ruff-0.15.1-py3-none-linux_armv6l.whl", hash = "sha256:b101ed7cf4615bda6ffe65bdb59f964e9f4a0d3f85cbf0e54f0ab76d7b90228a", size = 10367819, upload-time = "2026-02-12T23:09:03.598Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/ea/c8f89d32e7912269d38c58f3649e453ac32c528f93bb7f4219258be2e7ed/ruff-0.15.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:939c995e9277e63ea632cc8d3fae17aa758526f49a9a850d2e7e758bfef46602", size = 10798618, upload-time = "2026-02-12T23:09:22.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/0f/1d0d88bc862624247d82c20c10d4c0f6bb2f346559d8af281674cf327f15/ruff-0.15.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1d83466455fdefe60b8d9c8df81d3c1bbb2115cede53549d3b522ce2bc703899", size = 10148518, upload-time = "2026-02-12T23:08:58.339Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/c8/291c49cefaa4a9248e986256df2ade7add79388fe179e0691be06fae6f37/ruff-0.15.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9457e3c3291024866222b96108ab2d8265b477e5b1534c7ddb1810904858d16", size = 10518811, upload-time = "2026-02-12T23:09:31.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/1a/f5707440e5ae43ffa5365cac8bbb91e9665f4a883f560893829cf16a606b/ruff-0.15.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:92c92b003e9d4f7fbd33b1867bb15a1b785b1735069108dfc23821ba045b29bc", size = 10196169, upload-time = "2026-02-12T23:09:17.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/ff/26ddc8c4da04c8fd3ee65a89c9fb99eaa5c30394269d424461467be2271f/ruff-0.15.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fe5c41ab43e3a06778844c586251eb5a510f67125427625f9eb2b9526535779", size = 10990491, upload-time = "2026-02-12T23:09:25.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/00/50920cb385b89413f7cdb4bb9bc8fc59c1b0f30028d8bccc294189a54955/ruff-0.15.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66a6dd6df4d80dc382c6484f8ce1bcceb55c32e9f27a8b94c32f6c7331bf14fb", size = 11843280, upload-time = "2026-02-12T23:09:19.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/6d/2f5cad8380caf5632a15460c323ae326f1e1a2b5b90a6ee7519017a017ca/ruff-0.15.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a4a42cbb8af0bda9bcd7606b064d7c0bc311a88d141d02f78920be6acb5aa83", size = 11274336, upload-time = "2026-02-12T23:09:14.907Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/1d/5f56cae1d6c40b8a318513599b35ea4b075d7dc1cd1d04449578c29d1d75/ruff-0.15.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ab064052c31dddada35079901592dfba2e05f5b1e43af3954aafcbc1096a5b2", size = 11137288, upload-time = "2026-02-12T23:09:07.475Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/20/6f8d7d8f768c93b0382b33b9306b3b999918816da46537d5a61635514635/ruff-0.15.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5631c940fe9fe91f817a4c2ea4e81f47bee3ca4aa646134a24374f3c19ad9454", size = 11070681, upload-time = "2026-02-12T23:08:55.43Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/67/d640ac76069f64cdea59dba02af2e00b1fa30e2103c7f8d049c0cff4cafd/ruff-0.15.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68138a4ba184b4691ccdc39f7795c66b3c68160c586519e7e8444cf5a53e1b4c", size = 10486401, upload-time = "2026-02-12T23:09:27.927Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/3d/e1429f64a3ff89297497916b88c32a5cc88eeca7e9c787072d0e7f1d3e1e/ruff-0.15.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:518f9af03bfc33c03bdb4cb63fabc935341bb7f54af500f92ac309ecfbba6330", size = 10197452, upload-time = "2026-02-12T23:09:12.147Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/83/e2c3bade17dad63bf1e1c2ffaf11490603b760be149e1419b07049b36ef2/ruff-0.15.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:da79f4d6a826caaea95de0237a67e33b81e6ec2e25fc7e1993a4015dffca7c61", size = 10693900, upload-time = "2026-02-12T23:09:34.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/27/fdc0e11a813e6338e0706e8b39bb7a1d61ea5b36873b351acee7e524a72a/ruff-0.15.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3dd86dccb83cd7d4dcfac303ffc277e6048600dfc22e38158afa208e8bf94a1f", size = 11227302, upload-time = "2026-02-12T23:09:36.536Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/58/ac864a75067dcbd3b95be5ab4eb2b601d7fbc3d3d736a27e391a4f92a5c1/ruff-0.15.1-py3-none-win32.whl", hash = "sha256:660975d9cb49b5d5278b12b03bb9951d554543a90b74ed5d366b20e2c57c2098", size = 10462555, upload-time = "2026-02-12T23:09:29.899Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/5e/d4ccc8a27ecdb78116feac4935dfc39d1304536f4296168f91ed3ec00cd2/ruff-0.15.1-py3-none-win_amd64.whl", hash = "sha256:c820fef9dd5d4172a6570e5721704a96c6679b80cf7be41659ed439653f62336", size = 11599956, upload-time = "2026-02-12T23:09:01.157Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/07/5bda6a85b220c64c65686bc85bd0bbb23b29c62b3a9f9433fa55f17cda93/ruff-0.15.1-py3-none-win_arm64.whl", hash = "sha256:5ff7d5f0f88567850f45081fac8f4ec212be8d0b963e385c3f7d0d2eb4899416", size = 10874604, upload-time = "2026-02-12T23:09:05.515Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/70/3a4dc6d09b13cb3e695f28307e5d889b2e1a66b7af9c5e257e796695b0e6/ruff-0.15.2-py3-none-linux_armv6l.whl", hash = "sha256:120691a6fdae2f16d65435648160f5b81a9625288f75544dc40637436b5d3c0d", size = 10430565, upload-time = "2026-02-19T22:32:41.824Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/0b/bb8457b56185ece1305c666dc895832946d24055be90692381c31d57466d/ruff-0.15.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a89056d831256099658b6bba4037ac6dd06f49d194199215befe2bb10457ea5e", size = 10820354, upload-time = "2026-02-19T22:32:07.366Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/c1/e0532d7f9c9e0b14c46f61b14afd563298b8b83f337b6789ddd987e46121/ruff-0.15.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e36dee3a64be0ebd23c86ffa3aa3fd3ac9a712ff295e192243f814a830b6bd87", size = 10170767, upload-time = "2026-02-19T22:32:13.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/e8/da1aa341d3af017a21c7a62fb5ec31d4e7ad0a93ab80e3a508316efbcb23/ruff-0.15.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9fb47b6d9764677f8c0a193c0943ce9a05d6763523f132325af8a858eadc2b9", size = 10529591, upload-time = "2026-02-19T22:32:02.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/74/184fbf38e9f3510231fbc5e437e808f0b48c42d1df9434b208821efcd8d6/ruff-0.15.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f376990f9d0d6442ea9014b19621d8f2aaf2b8e39fdbfc79220b7f0c596c9b80", size = 10260771, upload-time = "2026-02-19T22:32:36.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/ac/605c20b8e059a0bc4b42360414baa4892ff278cec1c91fff4be0dceedefd/ruff-0.15.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2dcc987551952d73cbf5c88d9fdee815618d497e4df86cd4c4824cc59d5dd75f", size = 11045791, upload-time = "2026-02-19T22:32:31.642Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/52/db6e419908f45a894924d410ac77d64bdd98ff86901d833364251bd08e22/ruff-0.15.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:42a47fd785cbe8c01b9ff45031af875d101b040ad8f4de7bbb716487c74c9a77", size = 11879271, upload-time = "2026-02-19T22:32:29.305Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/d8/7992b18f2008bdc9231d0f10b16df7dda964dbf639e2b8b4c1b4e91b83af/ruff-0.15.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cbe9f49354866e575b4c6943856989f966421870e85cd2ac94dccb0a9dcb2fea", size = 11303707, upload-time = "2026-02-19T22:32:22.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/02/849b46184bcfdd4b64cde61752cc9a146c54759ed036edd11857e9b8443b/ruff-0.15.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7a672c82b5f9887576087d97be5ce439f04bbaf548ee987b92d3a7dede41d3a", size = 11149151, upload-time = "2026-02-19T22:32:44.234Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/04/f5284e388bab60d1d3b99614a5a9aeb03e0f333847e2429bebd2aaa1feec/ruff-0.15.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ecc64f46f7019e2bcc3cdc05d4a7da958b629a5ab7033195e11a438403d956", size = 11091132, upload-time = "2026-02-19T22:32:24.691Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/ae/88d844a21110e14d92cf73d57363fab59b727ebeabe78009b9ccb23500af/ruff-0.15.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:8dcf243b15b561c655c1ef2f2b0050e5d50db37fe90115507f6ff37d865dc8b4", size = 10504717, upload-time = "2026-02-19T22:32:26.75Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/27/867076a6ada7f2b9c8292884ab44d08fd2ba71bd2b5364d4136f3cd537e1/ruff-0.15.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dab6941c862c05739774677c6273166d2510d254dac0695c0e3f5efa1b5585de", size = 10263122, upload-time = "2026-02-19T22:32:10.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/ef/faf9321d550f8ebf0c6373696e70d1758e20ccdc3951ad7af00c0956be7c/ruff-0.15.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b9164f57fc36058e9a6806eb92af185b0697c9fe4c7c52caa431c6554521e5c", size = 10735295, upload-time = "2026-02-19T22:32:39.227Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/55/e8089fec62e050ba84d71b70e7834b97709ca9b7aba10c1a0b196e493f97/ruff-0.15.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:80d24fcae24d42659db7e335b9e1531697a7102c19185b8dc4a028b952865fd8", size = 11241641, upload-time = "2026-02-19T22:32:34.617Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/01/1c30526460f4d23222d0fabd5888868262fd0e2b71a00570ca26483cd993/ruff-0.15.2-py3-none-win32.whl", hash = "sha256:fd5ff9e5f519a7e1bd99cbe8daa324010a74f5e2ebc97c6242c08f26f3714f6f", size = 10507885, upload-time = "2026-02-19T22:32:15.635Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/10/3d18e3bbdf8fc50bbb4ac3cc45970aa5a9753c5cb51bf9ed9a3cd8b79fa3/ruff-0.15.2-py3-none-win_amd64.whl", hash = "sha256:d20014e3dfa400f3ff84830dfb5755ece2de45ab62ecea4af6b7262d0fb4f7c5", size = 11623725, upload-time = "2026-02-19T22:32:04.947Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/78/097c0798b1dab9f8affe73da9642bb4500e098cb27fd8dc9724816ac747b/ruff-0.15.2-py3-none-win_arm64.whl", hash = "sha256:cabddc5822acdc8f7b5527b36ceac55cc51eec7b1946e60181de8fe83ca8876e", size = 10941649, upload-time = "2026-02-19T22:32:18.108Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: test test_watch lint format
|
||||
.PHONY: test test_watch lint type format
|
||||
|
||||
######################
|
||||
# TESTING AND COVERAGE
|
||||
@@ -32,6 +32,9 @@ lint lint_diff lint_package lint_tests:
|
||||
[ "$(PYTHON_FILES)" = "" ] || mkdir -p $(MYPY_CACHE)
|
||||
[ "$(PYTHON_FILES)" = "" ] || uv run mypy $(PYTHON_FILES) --cache-dir $(MYPY_CACHE)
|
||||
|
||||
type:
|
||||
mkdir -p $(MYPY_CACHE) && uv run mypy $(PYTHON_FILES) --cache-dir $(MYPY_CACHE)
|
||||
|
||||
format format_diff:
|
||||
uv run ruff format $(PYTHON_FILES)
|
||||
uv run ruff check --select I --fix $(PYTHON_FILES)
|
||||
|
||||
Generated
+21
-20
@@ -268,7 +268,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-checkpoint"
|
||||
version = "4.0.0"
|
||||
version = "4.0.1"
|
||||
source = { editable = "../checkpoint" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -289,6 +289,7 @@ dev = [
|
||||
{ name = "numpy" },
|
||||
{ name = "pandas" },
|
||||
{ name = "pandas-stubs", specifier = ">=2.2.2.240807" },
|
||||
{ name = "pycryptodome", specifier = ">=3.23.0" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "pytest-mock" },
|
||||
@@ -997,27 +998,27 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.1"
|
||||
version = "0.15.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/04/dc/4e6ac71b511b141cf626357a3946679abeba4cf67bc7cc5a17920f31e10d/ruff-0.15.1.tar.gz", hash = "sha256:c590fe13fb57c97141ae975c03a1aedb3d3156030cabd740d6ff0b0d601e203f", size = 4540855, upload-time = "2026-02-12T23:09:09.998Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/04/eab13a954e763b0606f460443fcbf6bb5a0faf06890ea3754ff16523dce5/ruff-0.15.2.tar.gz", hash = "sha256:14b965afee0969e68bb871eba625343b8673375f457af4abe98553e8bbb98342", size = 4558148, upload-time = "2026-02-19T22:32:20.271Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/23/bf/e6e4324238c17f9d9120a9d60aa99a7daaa21204c07fcd84e2ef03bb5fd1/ruff-0.15.1-py3-none-linux_armv6l.whl", hash = "sha256:b101ed7cf4615bda6ffe65bdb59f964e9f4a0d3f85cbf0e54f0ab76d7b90228a", size = 10367819, upload-time = "2026-02-12T23:09:03.598Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/ea/c8f89d32e7912269d38c58f3649e453ac32c528f93bb7f4219258be2e7ed/ruff-0.15.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:939c995e9277e63ea632cc8d3fae17aa758526f49a9a850d2e7e758bfef46602", size = 10798618, upload-time = "2026-02-12T23:09:22.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/0f/1d0d88bc862624247d82c20c10d4c0f6bb2f346559d8af281674cf327f15/ruff-0.15.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1d83466455fdefe60b8d9c8df81d3c1bbb2115cede53549d3b522ce2bc703899", size = 10148518, upload-time = "2026-02-12T23:08:58.339Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/c8/291c49cefaa4a9248e986256df2ade7add79388fe179e0691be06fae6f37/ruff-0.15.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9457e3c3291024866222b96108ab2d8265b477e5b1534c7ddb1810904858d16", size = 10518811, upload-time = "2026-02-12T23:09:31.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/1a/f5707440e5ae43ffa5365cac8bbb91e9665f4a883f560893829cf16a606b/ruff-0.15.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:92c92b003e9d4f7fbd33b1867bb15a1b785b1735069108dfc23821ba045b29bc", size = 10196169, upload-time = "2026-02-12T23:09:17.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/ff/26ddc8c4da04c8fd3ee65a89c9fb99eaa5c30394269d424461467be2271f/ruff-0.15.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fe5c41ab43e3a06778844c586251eb5a510f67125427625f9eb2b9526535779", size = 10990491, upload-time = "2026-02-12T23:09:25.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/00/50920cb385b89413f7cdb4bb9bc8fc59c1b0f30028d8bccc294189a54955/ruff-0.15.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66a6dd6df4d80dc382c6484f8ce1bcceb55c32e9f27a8b94c32f6c7331bf14fb", size = 11843280, upload-time = "2026-02-12T23:09:19.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/6d/2f5cad8380caf5632a15460c323ae326f1e1a2b5b90a6ee7519017a017ca/ruff-0.15.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a4a42cbb8af0bda9bcd7606b064d7c0bc311a88d141d02f78920be6acb5aa83", size = 11274336, upload-time = "2026-02-12T23:09:14.907Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/1d/5f56cae1d6c40b8a318513599b35ea4b075d7dc1cd1d04449578c29d1d75/ruff-0.15.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ab064052c31dddada35079901592dfba2e05f5b1e43af3954aafcbc1096a5b2", size = 11137288, upload-time = "2026-02-12T23:09:07.475Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/20/6f8d7d8f768c93b0382b33b9306b3b999918816da46537d5a61635514635/ruff-0.15.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5631c940fe9fe91f817a4c2ea4e81f47bee3ca4aa646134a24374f3c19ad9454", size = 11070681, upload-time = "2026-02-12T23:08:55.43Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/67/d640ac76069f64cdea59dba02af2e00b1fa30e2103c7f8d049c0cff4cafd/ruff-0.15.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68138a4ba184b4691ccdc39f7795c66b3c68160c586519e7e8444cf5a53e1b4c", size = 10486401, upload-time = "2026-02-12T23:09:27.927Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/3d/e1429f64a3ff89297497916b88c32a5cc88eeca7e9c787072d0e7f1d3e1e/ruff-0.15.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:518f9af03bfc33c03bdb4cb63fabc935341bb7f54af500f92ac309ecfbba6330", size = 10197452, upload-time = "2026-02-12T23:09:12.147Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/83/e2c3bade17dad63bf1e1c2ffaf11490603b760be149e1419b07049b36ef2/ruff-0.15.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:da79f4d6a826caaea95de0237a67e33b81e6ec2e25fc7e1993a4015dffca7c61", size = 10693900, upload-time = "2026-02-12T23:09:34.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/27/fdc0e11a813e6338e0706e8b39bb7a1d61ea5b36873b351acee7e524a72a/ruff-0.15.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3dd86dccb83cd7d4dcfac303ffc277e6048600dfc22e38158afa208e8bf94a1f", size = 11227302, upload-time = "2026-02-12T23:09:36.536Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/58/ac864a75067dcbd3b95be5ab4eb2b601d7fbc3d3d736a27e391a4f92a5c1/ruff-0.15.1-py3-none-win32.whl", hash = "sha256:660975d9cb49b5d5278b12b03bb9951d554543a90b74ed5d366b20e2c57c2098", size = 10462555, upload-time = "2026-02-12T23:09:29.899Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/5e/d4ccc8a27ecdb78116feac4935dfc39d1304536f4296168f91ed3ec00cd2/ruff-0.15.1-py3-none-win_amd64.whl", hash = "sha256:c820fef9dd5d4172a6570e5721704a96c6679b80cf7be41659ed439653f62336", size = 11599956, upload-time = "2026-02-12T23:09:01.157Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/07/5bda6a85b220c64c65686bc85bd0bbb23b29c62b3a9f9433fa55f17cda93/ruff-0.15.1-py3-none-win_arm64.whl", hash = "sha256:5ff7d5f0f88567850f45081fac8f4ec212be8d0b963e385c3f7d0d2eb4899416", size = 10874604, upload-time = "2026-02-12T23:09:05.515Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/70/3a4dc6d09b13cb3e695f28307e5d889b2e1a66b7af9c5e257e796695b0e6/ruff-0.15.2-py3-none-linux_armv6l.whl", hash = "sha256:120691a6fdae2f16d65435648160f5b81a9625288f75544dc40637436b5d3c0d", size = 10430565, upload-time = "2026-02-19T22:32:41.824Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/0b/bb8457b56185ece1305c666dc895832946d24055be90692381c31d57466d/ruff-0.15.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a89056d831256099658b6bba4037ac6dd06f49d194199215befe2bb10457ea5e", size = 10820354, upload-time = "2026-02-19T22:32:07.366Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/c1/e0532d7f9c9e0b14c46f61b14afd563298b8b83f337b6789ddd987e46121/ruff-0.15.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e36dee3a64be0ebd23c86ffa3aa3fd3ac9a712ff295e192243f814a830b6bd87", size = 10170767, upload-time = "2026-02-19T22:32:13.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/e8/da1aa341d3af017a21c7a62fb5ec31d4e7ad0a93ab80e3a508316efbcb23/ruff-0.15.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9fb47b6d9764677f8c0a193c0943ce9a05d6763523f132325af8a858eadc2b9", size = 10529591, upload-time = "2026-02-19T22:32:02.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/74/184fbf38e9f3510231fbc5e437e808f0b48c42d1df9434b208821efcd8d6/ruff-0.15.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f376990f9d0d6442ea9014b19621d8f2aaf2b8e39fdbfc79220b7f0c596c9b80", size = 10260771, upload-time = "2026-02-19T22:32:36.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/ac/605c20b8e059a0bc4b42360414baa4892ff278cec1c91fff4be0dceedefd/ruff-0.15.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2dcc987551952d73cbf5c88d9fdee815618d497e4df86cd4c4824cc59d5dd75f", size = 11045791, upload-time = "2026-02-19T22:32:31.642Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/52/db6e419908f45a894924d410ac77d64bdd98ff86901d833364251bd08e22/ruff-0.15.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:42a47fd785cbe8c01b9ff45031af875d101b040ad8f4de7bbb716487c74c9a77", size = 11879271, upload-time = "2026-02-19T22:32:29.305Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/d8/7992b18f2008bdc9231d0f10b16df7dda964dbf639e2b8b4c1b4e91b83af/ruff-0.15.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cbe9f49354866e575b4c6943856989f966421870e85cd2ac94dccb0a9dcb2fea", size = 11303707, upload-time = "2026-02-19T22:32:22.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/02/849b46184bcfdd4b64cde61752cc9a146c54759ed036edd11857e9b8443b/ruff-0.15.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7a672c82b5f9887576087d97be5ce439f04bbaf548ee987b92d3a7dede41d3a", size = 11149151, upload-time = "2026-02-19T22:32:44.234Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/04/f5284e388bab60d1d3b99614a5a9aeb03e0f333847e2429bebd2aaa1feec/ruff-0.15.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ecc64f46f7019e2bcc3cdc05d4a7da958b629a5ab7033195e11a438403d956", size = 11091132, upload-time = "2026-02-19T22:32:24.691Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/ae/88d844a21110e14d92cf73d57363fab59b727ebeabe78009b9ccb23500af/ruff-0.15.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:8dcf243b15b561c655c1ef2f2b0050e5d50db37fe90115507f6ff37d865dc8b4", size = 10504717, upload-time = "2026-02-19T22:32:26.75Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/27/867076a6ada7f2b9c8292884ab44d08fd2ba71bd2b5364d4136f3cd537e1/ruff-0.15.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dab6941c862c05739774677c6273166d2510d254dac0695c0e3f5efa1b5585de", size = 10263122, upload-time = "2026-02-19T22:32:10.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/ef/faf9321d550f8ebf0c6373696e70d1758e20ccdc3951ad7af00c0956be7c/ruff-0.15.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b9164f57fc36058e9a6806eb92af185b0697c9fe4c7c52caa431c6554521e5c", size = 10735295, upload-time = "2026-02-19T22:32:39.227Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/55/e8089fec62e050ba84d71b70e7834b97709ca9b7aba10c1a0b196e493f97/ruff-0.15.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:80d24fcae24d42659db7e335b9e1531697a7102c19185b8dc4a028b952865fd8", size = 11241641, upload-time = "2026-02-19T22:32:34.617Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/01/1c30526460f4d23222d0fabd5888868262fd0e2b71a00570ca26483cd993/ruff-0.15.2-py3-none-win32.whl", hash = "sha256:fd5ff9e5f519a7e1bd99cbe8daa324010a74f5e2ebc97c6242c08f26f3714f6f", size = 10507885, upload-time = "2026-02-19T22:32:15.635Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/10/3d18e3bbdf8fc50bbb4ac3cc45970aa5a9753c5cb51bf9ed9a3cd8b79fa3/ruff-0.15.2-py3-none-win_amd64.whl", hash = "sha256:d20014e3dfa400f3ff84830dfb5755ece2de45ab62ecea4af6b7262d0fb4f7c5", size = 11623725, upload-time = "2026-02-19T22:32:04.947Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/78/097c0798b1dab9f8affe73da9642bb4500e098cb27fd8dc9724816ac747b/ruff-0.15.2-py3-none-win_arm64.whl", hash = "sha256:cabddc5822acdc8f7b5527b36ceac55cc51eec7b1946e60181de8fe83ca8876e", size = 10941649, upload-time = "2026-02-19T22:32:18.108Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: test test_watch lint format
|
||||
.PHONY: test test_watch lint type format
|
||||
|
||||
######################
|
||||
# TESTING AND COVERAGE
|
||||
@@ -32,6 +32,9 @@ lint lint_diff lint_package lint_tests:
|
||||
[ "$(PYTHON_FILES)" = "" ] || mkdir -p $(MYPY_CACHE)
|
||||
[ "$(PYTHON_FILES)" = "" ] || uv run mypy $(PYTHON_FILES) --cache-dir $(MYPY_CACHE)
|
||||
|
||||
type:
|
||||
mkdir -p $(MYPY_CACHE) && uv run mypy $(PYTHON_FILES) --cache-dir $(MYPY_CACHE)
|
||||
|
||||
format format_diff:
|
||||
uv run ruff format $(PYTHON_FILES)
|
||||
uv run ruff check --select I --fix $(PYTHON_FILES)
|
||||
uv run ruff check --fix $(PYTHON_FILES)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator, Iterator, Mapping, Sequence
|
||||
import copy
|
||||
import logging
|
||||
from collections.abc import AsyncIterator, Collection, Iterator, Mapping, Sequence
|
||||
from typing import ( # noqa: UP035
|
||||
Any,
|
||||
Generic,
|
||||
@@ -14,6 +16,7 @@ from langchain_core.runnables import RunnableConfig
|
||||
|
||||
from langgraph.checkpoint.base.id import uuid6
|
||||
from langgraph.checkpoint.serde.base import SerializerProtocol, maybe_add_typed_methods
|
||||
from langgraph.checkpoint.serde.encrypted import EncryptedSerializer
|
||||
from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
|
||||
from langgraph.checkpoint.serde.types import (
|
||||
ERROR,
|
||||
@@ -25,6 +28,7 @@ from langgraph.checkpoint.serde.types import (
|
||||
|
||||
V = TypeVar("V", int, float, str)
|
||||
PendingWrite = tuple[str, str, Any]
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Marked as total=False to allow for future expansion.
|
||||
@@ -474,6 +478,37 @@ class BaseCheckpointSaver(Generic[V]):
|
||||
else:
|
||||
return current + 1
|
||||
|
||||
def with_allowlist(
|
||||
self, extra_allowlist: Collection[tuple[str, ...]]
|
||||
) -> BaseCheckpointSaver[V]:
|
||||
"""Return a shallow clone with a derived msgpack allowlist."""
|
||||
serde = _with_msgpack_allowlist(self.serde, extra_allowlist)
|
||||
if serde is self.serde:
|
||||
return self
|
||||
clone = copy.copy(self)
|
||||
clone.serde = maybe_add_typed_methods(serde)
|
||||
return clone
|
||||
|
||||
|
||||
def _with_msgpack_allowlist(
|
||||
serde: SerializerProtocol, extra_allowlist: Collection[tuple[str, ...]]
|
||||
) -> SerializerProtocol:
|
||||
if isinstance(serde, JsonPlusSerializer):
|
||||
return serde.with_msgpack_allowlist(extra_allowlist)
|
||||
if isinstance(serde, EncryptedSerializer):
|
||||
inner = serde.serde
|
||||
if isinstance(inner, JsonPlusSerializer):
|
||||
updated_inner = inner.with_msgpack_allowlist(extra_allowlist)
|
||||
if updated_inner is inner:
|
||||
return serde
|
||||
return EncryptedSerializer(serde.cipher, updated_inner)
|
||||
logger.warning(
|
||||
"Serializer %s does not support msgpack allowlist. "
|
||||
"Strict msgpack deserialization will not be enforced.",
|
||||
type(serde).__name__,
|
||||
)
|
||||
return serde
|
||||
|
||||
|
||||
class EmptyChannelError(Exception):
|
||||
"""Raised when attempting to get the value of a channel that hasn't been updated
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import os
|
||||
from collections.abc import Iterable
|
||||
from typing import cast
|
||||
|
||||
STRICT_MSGPACK_ENABLED = os.getenv("LANGGRAPH_STRICT_MSGPACK", "false").lower() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
)
|
||||
|
||||
|
||||
_SENTINEL = cast(None, object())
|
||||
|
||||
SAFE_MSGPACK_TYPES: frozenset[tuple[str, ...]] = frozenset(
|
||||
{
|
||||
# datetime types
|
||||
("datetime", "datetime"),
|
||||
("datetime", "date"),
|
||||
("datetime", "time"),
|
||||
("datetime", "timedelta"),
|
||||
("datetime", "timezone"),
|
||||
# uuid
|
||||
("uuid", "UUID"),
|
||||
# numeric
|
||||
("decimal", "Decimal"),
|
||||
# collections
|
||||
("builtins", "set"),
|
||||
("builtins", "frozenset"),
|
||||
("collections", "deque"),
|
||||
# ip addresses
|
||||
("ipaddress", "IPv4Address"),
|
||||
("ipaddress", "IPv4Interface"),
|
||||
("ipaddress", "IPv4Network"),
|
||||
("ipaddress", "IPv6Address"),
|
||||
("ipaddress", "IPv6Interface"),
|
||||
("ipaddress", "IPv6Network"),
|
||||
# pathlib
|
||||
("pathlib", "Path"),
|
||||
("pathlib", "PosixPath"),
|
||||
("pathlib", "WindowsPath"),
|
||||
# pathlib in Python 3.13+
|
||||
("pathlib._local", "Path"),
|
||||
("pathlib._local", "PosixPath"),
|
||||
("pathlib._local", "WindowsPath"),
|
||||
# zoneinfo
|
||||
("zoneinfo", "ZoneInfo"),
|
||||
# regex
|
||||
("re", "compile"),
|
||||
# langchain-core messages (safe container types used by graph state)
|
||||
("langchain_core.messages.base", "BaseMessage"),
|
||||
("langchain_core.messages.base", "BaseMessageChunk"),
|
||||
("langchain_core.messages.human", "HumanMessage"),
|
||||
("langchain_core.messages.human", "HumanMessageChunk"),
|
||||
("langchain_core.messages.ai", "AIMessage"),
|
||||
("langchain_core.messages.ai", "AIMessageChunk"),
|
||||
("langchain_core.messages.system", "SystemMessage"),
|
||||
("langchain_core.messages.system", "SystemMessageChunk"),
|
||||
("langchain_core.messages.chat", "ChatMessage"),
|
||||
("langchain_core.messages.chat", "ChatMessageChunk"),
|
||||
("langchain_core.messages.tool", "ToolMessage"),
|
||||
("langchain_core.messages.tool", "ToolMessageChunk"),
|
||||
("langchain_core.messages.function", "FunctionMessage"),
|
||||
("langchain_core.messages.function", "FunctionMessageChunk"),
|
||||
("langchain_core.messages.modifier", "RemoveMessage"),
|
||||
# langchain-core document model
|
||||
("langchain_core.documents.base", "Document"),
|
||||
# langgraph
|
||||
("langgraph.types", "Send"),
|
||||
("langgraph.types", "Interrupt"),
|
||||
("langgraph.types", "Command"),
|
||||
("langgraph.types", "StateSnapshot"),
|
||||
("langgraph.types", "PregelTask"),
|
||||
("langgraph.types", "Overwrite"),
|
||||
("langgraph.store.base", "Item"),
|
||||
("langgraph.store.base", "GetOp"),
|
||||
}
|
||||
)
|
||||
|
||||
# Allowed (module, name, method) triples for EXT_METHOD_SINGLE_ARG.
|
||||
# Only these specific method invocations are permitted during deserialization.
|
||||
# This is separate from SAFE_MSGPACK_TYPES which only governs construction.
|
||||
SAFE_MSGPACK_METHODS: frozenset[tuple[str, str, str]] = frozenset(
|
||||
{
|
||||
("datetime", "datetime", "fromisoformat"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
AllowedMsgpackModules = Iterable[tuple[str, ...] | type]
|
||||
@@ -41,7 +41,7 @@ class EncryptedSerializer(SerializerProtocol):
|
||||
) -> "EncryptedSerializer":
|
||||
"""Create an `EncryptedSerializer` using AES encryption."""
|
||||
try:
|
||||
from Crypto.Cipher import AES # type: ignore
|
||||
from Crypto.Cipher import AES
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"Pycryptodome is not installed. Please install it with `pip install pycryptodome`."
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from threading import Lock
|
||||
from typing import TypedDict
|
||||
|
||||
from typing_extensions import NotRequired
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SerdeEvent(TypedDict):
|
||||
kind: str
|
||||
module: str
|
||||
name: str
|
||||
method: NotRequired[str]
|
||||
|
||||
|
||||
SerdeEventListener = Callable[[SerdeEvent], None]
|
||||
|
||||
_listeners: list[SerdeEventListener] = []
|
||||
_listeners_lock = Lock()
|
||||
|
||||
|
||||
def register_serde_event_listener(listener: SerdeEventListener) -> Callable[[], None]:
|
||||
"""Register a listener for serde allowlist events."""
|
||||
with _listeners_lock:
|
||||
_listeners.append(listener)
|
||||
|
||||
def unregister() -> None:
|
||||
with _listeners_lock:
|
||||
try:
|
||||
_listeners.remove(listener)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return unregister
|
||||
|
||||
|
||||
def emit_serde_event(event: SerdeEvent) -> None:
|
||||
"""Emit a serde event to all listeners.
|
||||
|
||||
Listener failures are isolated and logged.
|
||||
"""
|
||||
with _listeners_lock:
|
||||
listeners = tuple(_listeners)
|
||||
for listener in listeners:
|
||||
try:
|
||||
listener(event)
|
||||
except Exception:
|
||||
logger.warning("Serde listener failed", exc_info=True)
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import dataclasses
|
||||
import decimal
|
||||
import importlib
|
||||
@@ -10,7 +11,7 @@ import pickle
|
||||
import re
|
||||
import sys
|
||||
from collections import deque
|
||||
from collections.abc import Callable, Sequence
|
||||
from collections.abc import Callable, Iterable, Sequence
|
||||
from datetime import date, datetime, time, timedelta, timezone
|
||||
from enum import Enum
|
||||
from inspect import isclass
|
||||
@@ -22,17 +23,25 @@ from ipaddress import (
|
||||
IPv6Interface,
|
||||
IPv6Network,
|
||||
)
|
||||
from typing import Any, Literal
|
||||
from typing import TYPE_CHECKING, Any, Literal, cast
|
||||
from uuid import UUID
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import ormsgpack
|
||||
from langchain_core.load.load import Reviver
|
||||
|
||||
from langgraph.checkpoint.serde import _msgpack as _lg_msgpack
|
||||
from langgraph.checkpoint.serde.base import SerializerProtocol
|
||||
from langgraph.checkpoint.serde.event_hooks import emit_serde_event
|
||||
from langgraph.checkpoint.serde.types import SendProtocol
|
||||
from langgraph.store.base import Item
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from langgraph.checkpoint.serde._msgpack import (
|
||||
AllowedMsgpackModules,
|
||||
)
|
||||
from langgraph.checkpoint.serde.types import SendProtocol
|
||||
|
||||
LC_REVIVER = Reviver()
|
||||
EMPTY_BYTES = b""
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -53,21 +62,62 @@ class JsonPlusSerializer(SerializerProtocol):
|
||||
self,
|
||||
*,
|
||||
pickle_fallback: bool = False,
|
||||
allowed_json_modules: Sequence[tuple[str, ...]] | Literal[True] | None = None,
|
||||
allowed_json_modules: Iterable[tuple[str, ...]] | Literal[True] | None = None,
|
||||
allowed_msgpack_modules: (
|
||||
AllowedMsgpackModules | Literal[True] | None
|
||||
) = _lg_msgpack._SENTINEL,
|
||||
__unpack_ext_hook__: Callable[[int, bytes], Any] | None = None,
|
||||
) -> None:
|
||||
if allowed_msgpack_modules is _lg_msgpack._SENTINEL:
|
||||
if _lg_msgpack.STRICT_MSGPACK_ENABLED:
|
||||
allowed_msgpack_modules = None
|
||||
else:
|
||||
allowed_msgpack_modules = True
|
||||
self.pickle_fallback = pickle_fallback
|
||||
self._allowed_modules = (
|
||||
{mod_and_name for mod_and_name in allowed_json_modules}
|
||||
if allowed_json_modules and allowed_json_modules is not True
|
||||
else (allowed_json_modules if allowed_json_modules is True else None)
|
||||
self._allowed_json_modules: set[tuple[str, ...]] | Literal[True] | None = (
|
||||
_normalize_allowlist(allowed_json_modules)
|
||||
)
|
||||
self._allowed_msgpack_modules = _normalize_allowlist(allowed_msgpack_modules)
|
||||
|
||||
self._custom_unpack_ext_hook = __unpack_ext_hook__ is not None
|
||||
self._unpack_ext_hook = (
|
||||
__unpack_ext_hook__
|
||||
if __unpack_ext_hook__ is not None
|
||||
else _msgpack_ext_hook
|
||||
else _create_msgpack_ext_hook(self._allowed_msgpack_modules)
|
||||
)
|
||||
|
||||
def with_msgpack_allowlist(
|
||||
self, extra_allowlist: Iterable[tuple[str, ...] | type]
|
||||
) -> JsonPlusSerializer:
|
||||
"""Return a new serializer with a merged msgpack allowlist."""
|
||||
base_allowlist = self._allowed_msgpack_modules
|
||||
if base_allowlist is True or base_allowlist is False:
|
||||
return self
|
||||
elif base_allowlist:
|
||||
base_allowlist = set(base_allowlist)
|
||||
else:
|
||||
base_allowlist = set()
|
||||
extra = _normalize_module_keys(tuple(extra_allowlist))
|
||||
merged = base_allowlist | extra
|
||||
if merged == base_allowlist:
|
||||
return self
|
||||
allowed_msgpack_modules: AllowedMsgpackModules | Literal[True] | None
|
||||
if merged:
|
||||
allowed_msgpack_modules = tuple(merged)
|
||||
elif isinstance(self._allowed_msgpack_modules, set):
|
||||
allowed_msgpack_modules = tuple(self._allowed_msgpack_modules)
|
||||
else:
|
||||
allowed_msgpack_modules = self._allowed_msgpack_modules
|
||||
|
||||
clone = copy.copy(self)
|
||||
clone._allowed_json_modules = _normalize_allowlist(self._allowed_json_modules)
|
||||
clone._allowed_msgpack_modules = _normalize_allowlist(allowed_msgpack_modules)
|
||||
if not clone._custom_unpack_ext_hook:
|
||||
clone._unpack_ext_hook = _create_msgpack_ext_hook(
|
||||
clone._allowed_msgpack_modules
|
||||
)
|
||||
return clone
|
||||
|
||||
def _encode_constructor_args(
|
||||
self,
|
||||
constructor: Callable | type[Any],
|
||||
@@ -90,7 +140,7 @@ class JsonPlusSerializer(SerializerProtocol):
|
||||
return out
|
||||
|
||||
def _reviver(self, value: dict[str, Any]) -> Any:
|
||||
if self._allowed_modules and (
|
||||
if self._allowed_json_modules and (
|
||||
value.get("lc", None) == 2
|
||||
and value.get("type", None) == "constructor"
|
||||
and value.get("id", None) is not None
|
||||
@@ -107,7 +157,7 @@ class JsonPlusSerializer(SerializerProtocol):
|
||||
return LC_REVIVER(value)
|
||||
|
||||
def _revive_lc2(self, value: dict[str, Any]) -> Any:
|
||||
self._check_allowed_modules(value)
|
||||
self._check_allowed_json_modules(value)
|
||||
|
||||
[*module, name] = value["id"]
|
||||
try:
|
||||
@@ -139,7 +189,7 @@ class JsonPlusSerializer(SerializerProtocol):
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _check_allowed_modules(self, value: dict[str, Any]) -> None:
|
||||
def _check_allowed_json_modules(self, value: dict[str, Any]) -> None:
|
||||
needed = tuple(value["id"])
|
||||
method = value.get("method")
|
||||
if isinstance(method, list):
|
||||
@@ -150,7 +200,7 @@ class JsonPlusSerializer(SerializerProtocol):
|
||||
method_display = "<init>"
|
||||
|
||||
dotted = ".".join(needed)
|
||||
if not self._allowed_modules:
|
||||
if not self._allowed_json_modules:
|
||||
raise InvalidModuleError(
|
||||
f"Refused to deserialize JSON constructor: {dotted} (method: {method_display}). "
|
||||
"No allowed_json_modules configured.\n\n"
|
||||
@@ -161,9 +211,9 @@ class JsonPlusSerializer(SerializerProtocol):
|
||||
"or plain-JSON representations revived without import-time side effects."
|
||||
)
|
||||
|
||||
if self._allowed_modules is True:
|
||||
if self._allowed_json_modules is True:
|
||||
return
|
||||
if needed in self._allowed_modules:
|
||||
if needed in self._allowed_json_modules:
|
||||
return
|
||||
|
||||
raise InvalidModuleError(
|
||||
@@ -448,92 +498,196 @@ def _msgpack_default(obj: Any) -> str | ormsgpack.Ext:
|
||||
raise TypeError(f"Object of type {obj.__class__.__name__} is not serializable")
|
||||
|
||||
|
||||
def _msgpack_ext_hook(code: int, data: bytes) -> Any:
|
||||
if code == EXT_CONSTRUCTOR_SINGLE_ARG:
|
||||
try:
|
||||
tup = ormsgpack.unpackb(
|
||||
data, ext_hook=_msgpack_ext_hook, option=ormsgpack.OPT_NON_STR_KEYS
|
||||
)
|
||||
# module, name, arg
|
||||
return getattr(importlib.import_module(tup[0]), tup[1])(tup[2])
|
||||
except Exception:
|
||||
return
|
||||
elif code == EXT_CONSTRUCTOR_POS_ARGS:
|
||||
try:
|
||||
tup = ormsgpack.unpackb(
|
||||
data, ext_hook=_msgpack_ext_hook, option=ormsgpack.OPT_NON_STR_KEYS
|
||||
)
|
||||
# module, name, args
|
||||
return getattr(importlib.import_module(tup[0]), tup[1])(*tup[2])
|
||||
except Exception:
|
||||
return
|
||||
elif code == EXT_CONSTRUCTOR_KW_ARGS:
|
||||
try:
|
||||
tup = ormsgpack.unpackb(
|
||||
data, ext_hook=_msgpack_ext_hook, option=ormsgpack.OPT_NON_STR_KEYS
|
||||
)
|
||||
# module, name, args
|
||||
return getattr(importlib.import_module(tup[0]), tup[1])(**tup[2])
|
||||
except Exception:
|
||||
return
|
||||
elif code == EXT_METHOD_SINGLE_ARG:
|
||||
try:
|
||||
tup = ormsgpack.unpackb(
|
||||
data, ext_hook=_msgpack_ext_hook, option=ormsgpack.OPT_NON_STR_KEYS
|
||||
)
|
||||
# module, name, arg, method
|
||||
return getattr(getattr(importlib.import_module(tup[0]), tup[1]), tup[3])(
|
||||
tup[2]
|
||||
)
|
||||
except Exception:
|
||||
return
|
||||
elif code == EXT_PYDANTIC_V1:
|
||||
try:
|
||||
tup = ormsgpack.unpackb(
|
||||
data, ext_hook=_msgpack_ext_hook, option=ormsgpack.OPT_NON_STR_KEYS
|
||||
)
|
||||
# module, name, kwargs
|
||||
cls = getattr(importlib.import_module(tup[0]), tup[1])
|
||||
try:
|
||||
return cls(**tup[2])
|
||||
except Exception:
|
||||
return cls.construct(**tup[2])
|
||||
except Exception:
|
||||
# for pydantic objects we can't find/reconstruct
|
||||
# let's return the kwargs dict instead
|
||||
try:
|
||||
return tup[2]
|
||||
except NameError:
|
||||
return
|
||||
elif code == EXT_PYDANTIC_V2:
|
||||
try:
|
||||
tup = ormsgpack.unpackb(
|
||||
data, ext_hook=_msgpack_ext_hook, option=ormsgpack.OPT_NON_STR_KEYS
|
||||
)
|
||||
# module, name, kwargs, method
|
||||
cls = getattr(importlib.import_module(tup[0]), tup[1])
|
||||
try:
|
||||
return cls(**tup[2])
|
||||
except Exception:
|
||||
return cls.model_construct(**tup[2])
|
||||
except Exception:
|
||||
# for pydantic objects we can't find/reconstruct
|
||||
# let's return the kwargs dict instead
|
||||
try:
|
||||
return tup[2]
|
||||
except NameError:
|
||||
return
|
||||
elif code == EXT_NUMPY_ARRAY:
|
||||
try:
|
||||
import numpy as _np
|
||||
def _create_msgpack_ext_hook(
|
||||
allowed_modules: set[tuple[str, ...]] | Literal[True] | None,
|
||||
) -> Callable[[int, bytes], Any]:
|
||||
"""Create msgpack ext hook with allowlist.
|
||||
|
||||
dtype_str, shape, order, buf = ormsgpack.unpackb(
|
||||
data, ext_hook=_msgpack_ext_hook, option=ormsgpack.OPT_NON_STR_KEYS
|
||||
Args:
|
||||
allowed_modules: Set of (module, name) tuples that are allowed to be
|
||||
deserialized, or True to allow all with warnings for unregistered types, or None to only allow safe types.
|
||||
|
||||
Returns:
|
||||
An ext_hook function for use with ormsgpack.unpackb.
|
||||
"""
|
||||
|
||||
def _check_allowed(module: str, name: str) -> bool:
|
||||
"""Check if type is allowed. Returns True if allowed, False if blocked."""
|
||||
key = (module, name)
|
||||
|
||||
if key in _lg_msgpack.SAFE_MSGPACK_TYPES:
|
||||
return True
|
||||
|
||||
if allowed_modules is True:
|
||||
# default is to warn but allow unregistered types
|
||||
emit_serde_event(
|
||||
{
|
||||
"kind": "msgpack_unregistered_allowed",
|
||||
"module": module,
|
||||
"name": name,
|
||||
}
|
||||
)
|
||||
arr = _np.frombuffer(buf, dtype=_np.dtype(dtype_str))
|
||||
return arr.reshape(shape, order=order)
|
||||
except Exception:
|
||||
return
|
||||
logger.warning(
|
||||
"Deserializing unregistered type %s.%s from checkpoint. "
|
||||
"This will be blocked in a future version. "
|
||||
"Add to allowed_msgpack_modules to silence: [(%r, %r)]",
|
||||
module,
|
||||
name,
|
||||
module,
|
||||
name,
|
||||
)
|
||||
return True
|
||||
if allowed_modules is not None:
|
||||
if key in allowed_modules:
|
||||
return True
|
||||
# strict mode blocks unregistered types
|
||||
emit_serde_event(
|
||||
{
|
||||
"kind": "msgpack_blocked",
|
||||
"module": module,
|
||||
"name": name,
|
||||
}
|
||||
)
|
||||
logger.warning(
|
||||
"Blocked deserialization of %s.%s - not in allowed_msgpack_modules. "
|
||||
"Add to allowed_msgpack_modules to allow: [(%r, %r)]",
|
||||
module,
|
||||
name,
|
||||
module,
|
||||
name,
|
||||
)
|
||||
return False
|
||||
|
||||
def _check_allowed_method(module: str, name: str, method: str) -> bool:
|
||||
"""Check if a method invocation is allowed."""
|
||||
key = (module, name, method)
|
||||
if key in _lg_msgpack.SAFE_MSGPACK_METHODS:
|
||||
return True
|
||||
emit_serde_event(
|
||||
{
|
||||
"kind": "msgpack_method_blocked",
|
||||
"module": module,
|
||||
"name": name,
|
||||
"method": method,
|
||||
}
|
||||
)
|
||||
logger.warning(
|
||||
"Blocked deserialization of method call %s.%s.%s - "
|
||||
"not in allowed methods set.",
|
||||
module,
|
||||
name,
|
||||
method,
|
||||
)
|
||||
return False
|
||||
|
||||
def ext_hook(code: int, data: bytes) -> Any:
|
||||
if code == EXT_CONSTRUCTOR_SINGLE_ARG:
|
||||
try:
|
||||
tup = ormsgpack.unpackb(
|
||||
data, ext_hook=ext_hook, option=ormsgpack.OPT_NON_STR_KEYS
|
||||
)
|
||||
if not _check_allowed(tup[0], tup[1]):
|
||||
# We default to returning the raw data. If the user
|
||||
# is using this in the context of a pydantic state, etc., then
|
||||
# it would be validated upon construction.
|
||||
return tup[2]
|
||||
# module, name, arg
|
||||
return getattr(importlib.import_module(tup[0]), tup[1])(tup[2])
|
||||
except Exception:
|
||||
return None
|
||||
elif code == EXT_CONSTRUCTOR_POS_ARGS:
|
||||
try:
|
||||
tup = ormsgpack.unpackb(
|
||||
data, ext_hook=ext_hook, option=ormsgpack.OPT_NON_STR_KEYS
|
||||
)
|
||||
if not _check_allowed(tup[0], tup[1]):
|
||||
return tup[2]
|
||||
# module, name, args
|
||||
return getattr(importlib.import_module(tup[0]), tup[1])(*tup[2])
|
||||
except Exception:
|
||||
return None
|
||||
elif code == EXT_CONSTRUCTOR_KW_ARGS:
|
||||
try:
|
||||
tup = ormsgpack.unpackb(
|
||||
data, ext_hook=ext_hook, option=ormsgpack.OPT_NON_STR_KEYS
|
||||
)
|
||||
if not _check_allowed(tup[0], tup[1]):
|
||||
return tup[2]
|
||||
# module, name, kwargs
|
||||
return getattr(importlib.import_module(tup[0]), tup[1])(**tup[2])
|
||||
except Exception:
|
||||
return None
|
||||
elif code == EXT_METHOD_SINGLE_ARG:
|
||||
try:
|
||||
tup = ormsgpack.unpackb(
|
||||
data, ext_hook=ext_hook, option=ormsgpack.OPT_NON_STR_KEYS
|
||||
)
|
||||
if not _check_allowed_method(tup[0], tup[1], tup[3]):
|
||||
return tup[2]
|
||||
# module, name, arg, method
|
||||
return getattr(
|
||||
getattr(importlib.import_module(tup[0]), tup[1]), tup[3]
|
||||
)(tup[2])
|
||||
except Exception:
|
||||
return None
|
||||
elif code == EXT_PYDANTIC_V1:
|
||||
try:
|
||||
tup = ormsgpack.unpackb(
|
||||
data, ext_hook=ext_hook, option=ormsgpack.OPT_NON_STR_KEYS
|
||||
)
|
||||
if not _check_allowed(tup[0], tup[1]):
|
||||
return tup[2]
|
||||
# module, name, kwargs
|
||||
cls = getattr(importlib.import_module(tup[0]), tup[1])
|
||||
try:
|
||||
return cls(**tup[2])
|
||||
except Exception:
|
||||
return cls.construct(**tup[2])
|
||||
except Exception:
|
||||
# for pydantic objects we can't find/reconstruct
|
||||
# let's return the kwargs dict instead
|
||||
try:
|
||||
return tup[2]
|
||||
except NameError:
|
||||
return None
|
||||
elif code == EXT_PYDANTIC_V2:
|
||||
try:
|
||||
tup = ormsgpack.unpackb(
|
||||
data, ext_hook=ext_hook, option=ormsgpack.OPT_NON_STR_KEYS
|
||||
)
|
||||
if not _check_allowed(tup[0], tup[1]):
|
||||
return tup[2]
|
||||
# module, name, kwargs, method
|
||||
cls = getattr(importlib.import_module(tup[0]), tup[1])
|
||||
try:
|
||||
return cls(**tup[2])
|
||||
except Exception:
|
||||
return cls.model_construct(**tup[2])
|
||||
except Exception:
|
||||
# for pydantic objects we can't find/reconstruct
|
||||
# let's return the kwargs dict instead
|
||||
try:
|
||||
return tup[2]
|
||||
except NameError:
|
||||
return None
|
||||
elif code == EXT_NUMPY_ARRAY:
|
||||
try:
|
||||
import numpy as _np
|
||||
|
||||
dtype_str, shape, order, buf = ormsgpack.unpackb(
|
||||
data, ext_hook=ext_hook, option=ormsgpack.OPT_NON_STR_KEYS
|
||||
)
|
||||
arr = _np.frombuffer(buf, dtype=_np.dtype(dtype_str))
|
||||
return arr.reshape(shape, order=order)
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
return ext_hook
|
||||
|
||||
|
||||
# Aliasing in case anyone imported it directly
|
||||
_msgpack_ext_hook = _create_msgpack_ext_hook(allowed_modules=None)
|
||||
|
||||
|
||||
def _msgpack_ext_hook_to_json(code: int, data: bytes) -> Any:
|
||||
@@ -648,3 +802,26 @@ _option = (
|
||||
|
||||
def _msgpack_enc(data: Any) -> bytes:
|
||||
return ormsgpack.packb(data, default=_msgpack_default, option=_option)
|
||||
|
||||
|
||||
def _normalize_allowlist(
|
||||
allowlist: AllowedMsgpackModules | Literal[True] | None,
|
||||
) -> set[tuple[str, ...]] | Literal[True] | None:
|
||||
if allowlist is True:
|
||||
return allowlist
|
||||
elif allowlist:
|
||||
return _normalize_module_keys(allowlist)
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_module_keys(
|
||||
modules: AllowedMsgpackModules,
|
||||
) -> set[tuple[str, ...]]:
|
||||
normalized: set[tuple[str, ...]] = set()
|
||||
for module in modules:
|
||||
if isclass(module):
|
||||
normalized.add((module.__module__, module.__name__))
|
||||
else:
|
||||
normalized.add(cast(tuple[str, ...], module))
|
||||
return normalized
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "langgraph-checkpoint"
|
||||
version = "4.0.0"
|
||||
version = "4.0.1"
|
||||
description = "Library with base interfaces for LangGraph checkpoint savers."
|
||||
authors = []
|
||||
requires-python = ">=3.10"
|
||||
@@ -42,6 +42,7 @@ lint = [
|
||||
dev = [
|
||||
{include-group = "test"},
|
||||
{include-group = "lint"},
|
||||
"pycryptodome>=3.23.0",
|
||||
]
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
"""Tests for EncryptedSerializer with msgpack allowlist functionality.
|
||||
|
||||
These tests mirror the msgpack allowlist tests in test_jsonplus.py but run them
|
||||
through the EncryptedSerializer to ensure the allowlist behavior is preserved
|
||||
when encryption is enabled.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import pathlib
|
||||
import re
|
||||
import uuid
|
||||
from collections import deque
|
||||
from datetime import date, datetime, time, timezone
|
||||
from decimal import Decimal
|
||||
from ipaddress import IPv4Address
|
||||
from typing import Literal, cast
|
||||
|
||||
import ormsgpack
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from langgraph.checkpoint.base import BaseCheckpointSaver, _with_msgpack_allowlist
|
||||
from langgraph.checkpoint.serde import _msgpack as _lg_msgpack
|
||||
from langgraph.checkpoint.serde.base import CipherProtocol
|
||||
from langgraph.checkpoint.serde.encrypted import EncryptedSerializer
|
||||
from langgraph.checkpoint.serde.jsonplus import (
|
||||
EXT_METHOD_SINGLE_ARG,
|
||||
JsonPlusSerializer,
|
||||
_msgpack_enc,
|
||||
)
|
||||
|
||||
|
||||
class InnerPydantic(BaseModel):
|
||||
hello: str
|
||||
|
||||
|
||||
class MyPydantic(BaseModel):
|
||||
foo: str
|
||||
bar: int
|
||||
inner: InnerPydantic
|
||||
|
||||
|
||||
class AnotherPydantic(BaseModel):
|
||||
foo: str
|
||||
|
||||
|
||||
class _PassthroughCipher(CipherProtocol):
|
||||
def encrypt(self, plaintext: bytes) -> tuple[str, bytes]:
|
||||
return "passthrough", plaintext
|
||||
|
||||
def decrypt(self, ciphername: str, ciphertext: bytes) -> bytes:
|
||||
assert ciphername == "passthrough"
|
||||
return ciphertext
|
||||
|
||||
|
||||
def _make_encrypted_serde(
|
||||
allowed_msgpack_modules: (
|
||||
_lg_msgpack.AllowedMsgpackModules | Literal[True] | None | object
|
||||
) = _lg_msgpack._SENTINEL,
|
||||
) -> EncryptedSerializer:
|
||||
"""Create an EncryptedSerializer with AES encryption for testing."""
|
||||
inner = JsonPlusSerializer(
|
||||
allowed_msgpack_modules=cast(
|
||||
_lg_msgpack.AllowedMsgpackModules | Literal[True] | None,
|
||||
allowed_msgpack_modules,
|
||||
)
|
||||
)
|
||||
return EncryptedSerializer.from_pycryptodome_aes(
|
||||
serde=inner, key=b"1234567890123456"
|
||||
)
|
||||
|
||||
|
||||
def test_msgpack_method_pathlib_blocked_encrypted_strict(
|
||||
tmp_path: pathlib.Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
target = tmp_path / "secret.txt"
|
||||
target.write_text("secret")
|
||||
payload = ormsgpack.packb(
|
||||
ormsgpack.Ext(
|
||||
EXT_METHOD_SINGLE_ARG,
|
||||
_msgpack_enc(("pathlib", "Path", target, "read_text")),
|
||||
),
|
||||
option=ormsgpack.OPT_NON_STR_KEYS,
|
||||
)
|
||||
serde = EncryptedSerializer(
|
||||
_PassthroughCipher(),
|
||||
JsonPlusSerializer(allowed_msgpack_modules=None),
|
||||
)
|
||||
|
||||
caplog.set_level(logging.WARNING, logger="langgraph.checkpoint.serde.jsonplus")
|
||||
caplog.clear()
|
||||
result = serde.loads_typed(("msgpack+passthrough", payload))
|
||||
|
||||
assert result == target
|
||||
assert "blocked deserialization of method call pathlib.path.read_text" in (
|
||||
caplog.text.lower()
|
||||
)
|
||||
|
||||
|
||||
class TestEncryptedSerializerMsgpackAllowlist:
|
||||
"""Test msgpack allowlist behavior through EncryptedSerializer."""
|
||||
|
||||
def test_safe_types_no_warning(self, caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""Test safe types deserialize without warnings through encryption."""
|
||||
serde = _make_encrypted_serde()
|
||||
|
||||
safe_objects = [
|
||||
datetime.now(),
|
||||
date.today(),
|
||||
time(12, 30),
|
||||
timezone.utc,
|
||||
uuid.uuid4(),
|
||||
Decimal("123.45"),
|
||||
{1, 2, 3},
|
||||
frozenset([1, 2, 3]),
|
||||
deque([1, 2, 3]),
|
||||
IPv4Address("192.168.1.1"),
|
||||
pathlib.Path("/tmp/test"),
|
||||
]
|
||||
|
||||
for obj in safe_objects:
|
||||
caplog.clear()
|
||||
dumped = serde.dumps_typed(obj)
|
||||
# Verify encryption is happening
|
||||
assert "+aes" in dumped[0], f"Expected encryption for {type(obj)}"
|
||||
result = serde.loads_typed(dumped)
|
||||
assert "unregistered type" not in caplog.text.lower(), (
|
||||
f"Unexpected warning for {type(obj)}"
|
||||
)
|
||||
assert result is not None
|
||||
|
||||
def test_pydantic_warns_by_default(self, caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""Pydantic models not in allowlist should log warning but still deserialize."""
|
||||
current = _lg_msgpack.STRICT_MSGPACK_ENABLED
|
||||
_lg_msgpack.STRICT_MSGPACK_ENABLED = False
|
||||
serde = _make_encrypted_serde()
|
||||
|
||||
obj = MyPydantic(foo="test", bar=42, inner=InnerPydantic(hello="world"))
|
||||
|
||||
caplog.clear()
|
||||
dumped = serde.dumps_typed(obj)
|
||||
assert "+aes" in dumped[0]
|
||||
result = serde.loads_typed(dumped)
|
||||
|
||||
assert "unregistered type" in caplog.text.lower()
|
||||
assert "allowed_msgpack_modules" in caplog.text
|
||||
assert result == obj
|
||||
_lg_msgpack.STRICT_MSGPACK_ENABLED = current
|
||||
|
||||
def test_strict_mode_blocks_unregistered(
|
||||
self, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Strict mode should block unregistered types through encryption."""
|
||||
serde = _make_encrypted_serde(allowed_msgpack_modules=None)
|
||||
|
||||
obj = MyPydantic(foo="test", bar=42, inner=InnerPydantic(hello="world"))
|
||||
|
||||
caplog.clear()
|
||||
dumped = serde.dumps_typed(obj)
|
||||
assert "+aes" in dumped[0]
|
||||
result = serde.loads_typed(dumped)
|
||||
|
||||
assert "blocked" in caplog.text.lower()
|
||||
expected = obj.model_dump()
|
||||
assert result == expected
|
||||
|
||||
def test_allowlist_silences_warning(self, caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""Types in allowed_msgpack_modules should deserialize without warnings."""
|
||||
serde = _make_encrypted_serde(
|
||||
allowed_msgpack_modules=[
|
||||
("tests.test_encrypted", "MyPydantic"),
|
||||
("tests.test_encrypted", "InnerPydantic"),
|
||||
]
|
||||
)
|
||||
|
||||
obj = MyPydantic(foo="test", bar=42, inner=InnerPydantic(hello="world"))
|
||||
|
||||
caplog.clear()
|
||||
dumped = serde.dumps_typed(obj)
|
||||
assert "+aes" in dumped[0]
|
||||
result = serde.loads_typed(dumped)
|
||||
|
||||
assert "unregistered type" not in caplog.text.lower()
|
||||
assert "blocked" not in caplog.text.lower()
|
||||
assert result == obj
|
||||
|
||||
def test_allowlist_blocks_non_listed(
|
||||
self, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Allowlists should block unregistered types even through encryption."""
|
||||
serde = _make_encrypted_serde(
|
||||
allowed_msgpack_modules=[("tests.test_encrypted", "MyPydantic")]
|
||||
)
|
||||
|
||||
obj = AnotherPydantic(foo="nope")
|
||||
|
||||
caplog.clear()
|
||||
dumped = serde.dumps_typed(obj)
|
||||
assert "+aes" in dumped[0]
|
||||
result = serde.loads_typed(dumped)
|
||||
|
||||
assert "blocked" in caplog.text.lower()
|
||||
expected = obj.model_dump()
|
||||
assert result == expected
|
||||
|
||||
def test_safe_types_value_equality(self, caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""Verify safe types are correctly restored with proper values through encryption."""
|
||||
serde = _make_encrypted_serde(allowed_msgpack_modules=None)
|
||||
|
||||
test_cases = [
|
||||
datetime(2024, 1, 15, 12, 30, 45, 123456),
|
||||
date(2024, 6, 15),
|
||||
time(14, 30, 0),
|
||||
uuid.UUID("12345678-1234-5678-1234-567812345678"),
|
||||
Decimal("123.456789"),
|
||||
{1, 2, 3, 4, 5},
|
||||
frozenset(["a", "b", "c"]),
|
||||
deque([1, 2, 3]),
|
||||
IPv4Address("10.0.0.1"),
|
||||
pathlib.Path("/some/test/path"),
|
||||
re.compile(r"\d+", re.MULTILINE),
|
||||
]
|
||||
|
||||
for obj in test_cases:
|
||||
caplog.clear()
|
||||
dumped = serde.dumps_typed(obj)
|
||||
assert "+aes" in dumped[0], f"Expected encryption for {type(obj)}"
|
||||
result = serde.loads_typed(dumped)
|
||||
|
||||
assert "blocked" not in caplog.text.lower(), f"Blocked for {type(obj)}"
|
||||
if isinstance(obj, re.Pattern):
|
||||
assert result.pattern == obj.pattern
|
||||
assert result.flags == obj.flags
|
||||
else:
|
||||
assert result == obj, (
|
||||
f"Value mismatch for {type(obj)}: {result} != {obj}"
|
||||
)
|
||||
|
||||
def test_regex_safe_type(self, caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""re.compile patterns should deserialize without warnings as a safe type."""
|
||||
serde = _make_encrypted_serde(allowed_msgpack_modules=None)
|
||||
pattern = re.compile(r"foo.*bar", re.IGNORECASE | re.DOTALL)
|
||||
|
||||
caplog.clear()
|
||||
dumped = serde.dumps_typed(pattern)
|
||||
assert "+aes" in dumped[0]
|
||||
result = serde.loads_typed(dumped)
|
||||
|
||||
assert "blocked" not in caplog.text.lower()
|
||||
assert "unregistered" not in caplog.text.lower()
|
||||
assert result.pattern == pattern.pattern
|
||||
assert result.flags == pattern.flags
|
||||
|
||||
|
||||
class TestWithMsgpackAllowlistEncrypted:
|
||||
"""Test _with_msgpack_allowlist function with EncryptedSerializer."""
|
||||
|
||||
def test_propagates_allowlist_to_inner_serde(self) -> None:
|
||||
"""_with_msgpack_allowlist should propagate allowlist to inner JsonPlusSerializer."""
|
||||
inner = JsonPlusSerializer(allowed_msgpack_modules=None)
|
||||
encrypted = EncryptedSerializer.from_pycryptodome_aes(
|
||||
serde=inner, key=b"1234567890123456"
|
||||
)
|
||||
|
||||
extra = [("my.module", "MyClass")]
|
||||
result = _with_msgpack_allowlist(encrypted, extra)
|
||||
|
||||
# Should return a new EncryptedSerializer
|
||||
assert isinstance(result, EncryptedSerializer)
|
||||
assert result is not encrypted
|
||||
# Inner serde should have the allowlist
|
||||
assert isinstance(result.serde, JsonPlusSerializer)
|
||||
assert isinstance(result.serde._allowed_msgpack_modules, set)
|
||||
assert ("my.module", "MyClass") in result.serde._allowed_msgpack_modules
|
||||
|
||||
def test_preserves_cipher(self) -> None:
|
||||
"""_with_msgpack_allowlist should preserve the cipher from the original."""
|
||||
inner = JsonPlusSerializer(allowed_msgpack_modules=None)
|
||||
encrypted = EncryptedSerializer.from_pycryptodome_aes(
|
||||
serde=inner, key=b"1234567890123456"
|
||||
)
|
||||
|
||||
result = _with_msgpack_allowlist(encrypted, [("my.module", "MyClass")])
|
||||
|
||||
assert isinstance(result, EncryptedSerializer)
|
||||
# Should use the same cipher
|
||||
assert result.cipher is encrypted.cipher
|
||||
|
||||
def test_returns_same_if_not_jsonplus_inner(self) -> None:
|
||||
"""_with_msgpack_allowlist should return same serde if inner is not JsonPlusSerializer."""
|
||||
|
||||
class DummyInnerSerde:
|
||||
def dumps_typed(self, obj: object) -> tuple[str, bytes]:
|
||||
return ("dummy", b"")
|
||||
|
||||
def loads_typed(self, data: tuple[str, bytes]) -> None:
|
||||
return None
|
||||
|
||||
from langgraph.checkpoint.serde.base import CipherProtocol
|
||||
|
||||
class DummyCipher(CipherProtocol):
|
||||
def encrypt(self, plaintext: bytes) -> tuple[str, bytes]:
|
||||
return "dummy", plaintext
|
||||
|
||||
def decrypt(self, ciphername: str, ciphertext: bytes) -> bytes:
|
||||
return ciphertext
|
||||
|
||||
encrypted = EncryptedSerializer(DummyCipher(), DummyInnerSerde())
|
||||
result = _with_msgpack_allowlist(encrypted, [("my.module", "MyClass")])
|
||||
|
||||
assert result is encrypted
|
||||
|
||||
def test_warns_if_allowlist_unsupported(
|
||||
self, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
class DummySerde:
|
||||
def dumps_typed(self, obj: object) -> tuple[str, bytes]:
|
||||
return ("dummy", b"")
|
||||
|
||||
def loads_typed(self, data: tuple[str, bytes]) -> object:
|
||||
return data
|
||||
|
||||
serde = DummySerde()
|
||||
caplog.set_level(logging.WARNING, logger="langgraph.checkpoint.base")
|
||||
caplog.clear()
|
||||
|
||||
result = _with_msgpack_allowlist(serde, [("my.module", "MyClass")])
|
||||
|
||||
assert result is serde
|
||||
assert "does not support msgpack allowlist" in caplog.text.lower()
|
||||
|
||||
def test_noop_allowlist_returns_same_encrypted_instance(self) -> None:
|
||||
inner = JsonPlusSerializer(allowed_msgpack_modules=None)
|
||||
encrypted = EncryptedSerializer.from_pycryptodome_aes(
|
||||
serde=inner, key=b"1234567890123456"
|
||||
)
|
||||
|
||||
result = _with_msgpack_allowlist(encrypted, ())
|
||||
|
||||
assert result is encrypted
|
||||
|
||||
def test_functional_roundtrip_with_allowlist(
|
||||
self, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""End-to-end test: allowlist applied via _with_msgpack_allowlist works."""
|
||||
inner = JsonPlusSerializer(allowed_msgpack_modules=None)
|
||||
encrypted = EncryptedSerializer.from_pycryptodome_aes(
|
||||
serde=inner, key=b"1234567890123456"
|
||||
)
|
||||
|
||||
# Apply allowlist for MyPydantic
|
||||
updated = _with_msgpack_allowlist(
|
||||
encrypted,
|
||||
[
|
||||
("tests.test_encrypted", "MyPydantic"),
|
||||
("tests.test_encrypted", "InnerPydantic"),
|
||||
],
|
||||
)
|
||||
|
||||
obj = MyPydantic(foo="test", bar=42, inner=InnerPydantic(hello="world"))
|
||||
|
||||
caplog.clear()
|
||||
dumped = updated.dumps_typed(obj)
|
||||
assert "+aes" in dumped[0]
|
||||
result = updated.loads_typed(dumped)
|
||||
|
||||
# Should deserialize without blocking
|
||||
assert "blocked" not in caplog.text.lower()
|
||||
assert result == obj
|
||||
|
||||
def test_original_still_blocks_after_with_allowlist(
|
||||
self, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Original serde should still block after _with_msgpack_allowlist creates a new one."""
|
||||
inner = JsonPlusSerializer(allowed_msgpack_modules=None)
|
||||
encrypted = EncryptedSerializer.from_pycryptodome_aes(
|
||||
serde=inner, key=b"1234567890123456"
|
||||
)
|
||||
|
||||
# Apply allowlist - this should create a NEW serde
|
||||
_with_msgpack_allowlist(
|
||||
encrypted,
|
||||
[("tests.test_encrypted", "MyPydantic")],
|
||||
)
|
||||
|
||||
# Original should still block
|
||||
obj = MyPydantic(foo="test", bar=42, inner=InnerPydantic(hello="world"))
|
||||
|
||||
caplog.clear()
|
||||
dumped = encrypted.dumps_typed(obj)
|
||||
result = encrypted.loads_typed(dumped)
|
||||
|
||||
assert "blocked" in caplog.text.lower()
|
||||
assert result == obj.model_dump()
|
||||
|
||||
|
||||
class TestEncryptedSerializerUnencryptedFallback:
|
||||
"""Test that EncryptedSerializer handles unencrypted data correctly."""
|
||||
|
||||
def test_loads_unencrypted_data(self) -> None:
|
||||
"""EncryptedSerializer should handle unencrypted data for backwards compat."""
|
||||
plain = JsonPlusSerializer(allowed_msgpack_modules=None)
|
||||
encrypted = _make_encrypted_serde(allowed_msgpack_modules=None)
|
||||
|
||||
obj = {"key": "value", "number": 42}
|
||||
|
||||
# Serialize with plain serde
|
||||
dumped = plain.dumps_typed(obj)
|
||||
assert "+aes" not in dumped[0]
|
||||
|
||||
# Should still deserialize with encrypted serde
|
||||
result = encrypted.loads_typed(dumped)
|
||||
assert result == obj
|
||||
|
||||
|
||||
def test_with_allowlist_uses_copy_protocol() -> None:
|
||||
class CopyAwareSaver(BaseCheckpointSaver[str]):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(serde=JsonPlusSerializer(allowed_msgpack_modules=None))
|
||||
self.copy_was_used = False
|
||||
|
||||
def __copy__(self) -> object:
|
||||
clone = object.__new__(self.__class__)
|
||||
clone.__dict__ = self.__dict__.copy()
|
||||
clone.copy_was_used = True
|
||||
return clone
|
||||
|
||||
saver = CopyAwareSaver()
|
||||
|
||||
updated = saver.with_allowlist([("tests.test_encrypted", "MyPydantic")])
|
||||
|
||||
assert isinstance(updated, CopyAwareSaver)
|
||||
assert updated is not saver
|
||||
assert updated.copy_was_used is True
|
||||
assert saver.copy_was_used is False
|
||||
@@ -1,5 +1,6 @@
|
||||
import dataclasses
|
||||
import json
|
||||
import logging
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
@@ -13,15 +14,26 @@ from zoneinfo import ZoneInfo
|
||||
|
||||
import dataclasses_json
|
||||
import numpy as np
|
||||
import ormsgpack
|
||||
import pandas as pd
|
||||
import pytest
|
||||
from langchain_core.documents.base import Document
|
||||
from langchain_core.messages import HumanMessage
|
||||
from pydantic import BaseModel, SecretStr
|
||||
from pydantic.v1 import BaseModel as BaseModelV1
|
||||
from pydantic.v1 import SecretStr as SecretStrV1
|
||||
|
||||
from langgraph.checkpoint.serde import _msgpack as _lg_msgpack
|
||||
from langgraph.checkpoint.serde._msgpack import AllowedMsgpackModules
|
||||
from langgraph.checkpoint.serde.event_hooks import (
|
||||
SerdeEvent,
|
||||
register_serde_event_listener,
|
||||
)
|
||||
from langgraph.checkpoint.serde.jsonplus import (
|
||||
EXT_METHOD_SINGLE_ARG,
|
||||
InvalidModuleError,
|
||||
JsonPlusSerializer,
|
||||
_msgpack_enc,
|
||||
_msgpack_ext_hook_to_json,
|
||||
)
|
||||
from langgraph.store.base import Item
|
||||
@@ -37,6 +49,10 @@ class MyPydantic(BaseModel):
|
||||
inner: InnerPydantic
|
||||
|
||||
|
||||
class AnotherPydantic(BaseModel):
|
||||
foo: str
|
||||
|
||||
|
||||
class InnerPydanticV1(BaseModelV1):
|
||||
hello: str
|
||||
|
||||
@@ -138,7 +154,27 @@ def test_serde_jsonplus() -> None:
|
||||
)
|
||||
to_serialize["my_secret_str_v1"] = SecretStrV1("meow")
|
||||
|
||||
serde = JsonPlusSerializer()
|
||||
allowed_msgpack_modules: AllowedMsgpackModules = [
|
||||
InnerDataclass,
|
||||
MyDataclass,
|
||||
MyDataclassWSlots,
|
||||
MyEnum,
|
||||
InnerPydantic,
|
||||
MyPydantic,
|
||||
# Testing that it supports both.
|
||||
(Person.__module__, Person.__name__),
|
||||
(SecretStr.__module__, SecretStr.__name__),
|
||||
]
|
||||
if sys.version_info < (3, 14):
|
||||
allowed_msgpack_modules.extend( # type: ignore
|
||||
[
|
||||
(InnerPydanticV1.__module__, InnerPydanticV1.__name__),
|
||||
(MyPydanticV1.__module__, MyPydanticV1.__name__),
|
||||
(SecretStrV1.__module__, SecretStrV1.__name__),
|
||||
]
|
||||
)
|
||||
|
||||
serde = JsonPlusSerializer(allowed_msgpack_modules=allowed_msgpack_modules)
|
||||
|
||||
dumped = serde.dumps_typed(to_serialize)
|
||||
|
||||
@@ -512,5 +548,438 @@ def test_serde_jsonplus_pandas_series(series: pd.Series) -> None:
|
||||
|
||||
assert dumped[0] == "pickle"
|
||||
result = serde.loads_typed(dumped)
|
||||
|
||||
assert result.equals(series)
|
||||
|
||||
|
||||
def test_msgpack_safe_types_no_warning(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""Test safe types deserialize without warnings."""
|
||||
|
||||
serde = JsonPlusSerializer()
|
||||
|
||||
safe_objects = [
|
||||
datetime.now(),
|
||||
date.today(),
|
||||
time(12, 30),
|
||||
timezone.utc,
|
||||
uuid.uuid4(),
|
||||
Decimal("123.45"),
|
||||
{1, 2, 3},
|
||||
frozenset([1, 2, 3]),
|
||||
deque([1, 2, 3]),
|
||||
IPv4Address("192.168.1.1"),
|
||||
pathlib.Path("/tmp/test"),
|
||||
]
|
||||
|
||||
for obj in safe_objects:
|
||||
caplog.clear()
|
||||
dumped = serde.dumps_typed(obj)
|
||||
result = serde.loads_typed(dumped)
|
||||
assert "unregistered type" not in caplog.text.lower(), (
|
||||
f"Unexpected warning for {type(obj)}"
|
||||
)
|
||||
assert result is not None
|
||||
|
||||
|
||||
def test_msgpack_pydantic_warns_by_default(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""Pydantic models not in allowlist should log warning but still deserialize."""
|
||||
current = _lg_msgpack.STRICT_MSGPACK_ENABLED
|
||||
_lg_msgpack.STRICT_MSGPACK_ENABLED = False
|
||||
serde = JsonPlusSerializer()
|
||||
|
||||
obj = MyPydantic(foo="test", bar=42, inner=InnerPydantic(hello="world"))
|
||||
|
||||
caplog.clear()
|
||||
dumped = serde.dumps_typed(obj)
|
||||
result = serde.loads_typed(dumped)
|
||||
|
||||
assert "unregistered type" in caplog.text.lower()
|
||||
assert "allowed_msgpack_modules" in caplog.text
|
||||
assert result == obj
|
||||
_lg_msgpack.STRICT_MSGPACK_ENABLED = current
|
||||
|
||||
|
||||
def test_msgpack_env_strict_default(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Strict msgpack env should default to blocking unregistered types."""
|
||||
current = _lg_msgpack.STRICT_MSGPACK_ENABLED
|
||||
_lg_msgpack.STRICT_MSGPACK_ENABLED = True
|
||||
serde = JsonPlusSerializer()
|
||||
|
||||
obj = MyPydantic(foo="test", bar=42, inner=InnerPydantic(hello="world"))
|
||||
|
||||
caplog.clear()
|
||||
dumped = serde.dumps_typed(obj)
|
||||
result = serde.loads_typed(dumped)
|
||||
|
||||
assert "blocked" in caplog.text.lower()
|
||||
assert result == obj.model_dump()
|
||||
_lg_msgpack.STRICT_MSGPACK_ENABLED = current
|
||||
|
||||
|
||||
def test_msgpack_allowlist_silences_warning(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""Types in allowed_msgpack_modules should deserialize without warnings."""
|
||||
|
||||
serde = JsonPlusSerializer(
|
||||
allowed_msgpack_modules=[
|
||||
("tests.test_jsonplus", "MyPydantic"),
|
||||
("tests.test_jsonplus", "InnerPydantic"),
|
||||
]
|
||||
)
|
||||
|
||||
obj = MyPydantic(foo="test", bar=42, inner=InnerPydantic(hello="world"))
|
||||
|
||||
caplog.clear()
|
||||
dumped = serde.dumps_typed(obj)
|
||||
result = serde.loads_typed(dumped)
|
||||
|
||||
assert "unregistered type" not in caplog.text.lower()
|
||||
assert result == obj
|
||||
|
||||
|
||||
def test_msgpack_none_blocks_unregistered(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""allowed_msgpack_modules=None should block unregistered types."""
|
||||
|
||||
serde = JsonPlusSerializer(allowed_msgpack_modules=None)
|
||||
|
||||
obj = MyPydantic(foo="test", bar=42, inner=InnerPydantic(hello="world"))
|
||||
|
||||
caplog.clear()
|
||||
dumped = serde.dumps_typed(obj)
|
||||
result = serde.loads_typed(dumped)
|
||||
|
||||
assert "blocked" in caplog.text.lower()
|
||||
expected = obj.model_dump()
|
||||
assert result == expected
|
||||
|
||||
|
||||
def test_msgpack_allowlist_blocks_non_listed(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Allowlists should block unregistered types even if msgpack is enabled."""
|
||||
|
||||
serde = JsonPlusSerializer(
|
||||
allowed_msgpack_modules=[("tests.test_jsonplus", "MyPydantic")]
|
||||
)
|
||||
|
||||
obj = AnotherPydantic(foo="nope")
|
||||
|
||||
caplog.clear()
|
||||
dumped = serde.dumps_typed(obj)
|
||||
result = serde.loads_typed(dumped)
|
||||
|
||||
assert "blocked" in caplog.text.lower()
|
||||
expected = obj.model_dump()
|
||||
# It's not allowed, so we just leave it as a dict
|
||||
assert result == expected
|
||||
|
||||
|
||||
def test_msgpack_blocked_emits_event() -> None:
|
||||
events: list[SerdeEvent] = []
|
||||
unregister = register_serde_event_listener(events.append)
|
||||
try:
|
||||
serde = JsonPlusSerializer(allowed_msgpack_modules=None)
|
||||
obj = AnotherPydantic(foo="nope")
|
||||
serde.loads_typed(serde.dumps_typed(obj))
|
||||
finally:
|
||||
unregister()
|
||||
|
||||
assert {
|
||||
"kind": "msgpack_blocked",
|
||||
"module": "tests.test_jsonplus",
|
||||
"name": "AnotherPydantic",
|
||||
} in events
|
||||
|
||||
|
||||
def test_msgpack_unregistered_allowed_emits_event() -> None:
|
||||
events: list[SerdeEvent] = []
|
||||
unregister = register_serde_event_listener(events.append)
|
||||
try:
|
||||
serde = JsonPlusSerializer(allowed_msgpack_modules=True)
|
||||
obj = AnotherPydantic(foo="ok")
|
||||
serde.loads_typed(serde.dumps_typed(obj))
|
||||
finally:
|
||||
unregister()
|
||||
|
||||
assert {
|
||||
"kind": "msgpack_unregistered_allowed",
|
||||
"module": "tests.test_jsonplus",
|
||||
"name": "AnotherPydantic",
|
||||
} in events
|
||||
|
||||
|
||||
def test_msgpack_strict_allows_safe_types(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Safe types should still deserialize in strict mode without warnings."""
|
||||
|
||||
serde = JsonPlusSerializer(allowed_msgpack_modules=None)
|
||||
safe = uuid.uuid4()
|
||||
|
||||
caplog.clear()
|
||||
dumped = serde.dumps_typed(safe)
|
||||
result = serde.loads_typed(dumped)
|
||||
|
||||
assert "blocked" not in caplog.text.lower()
|
||||
assert result == safe
|
||||
|
||||
|
||||
def test_msgpack_strict_allows_core_langchain_messages(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
serde = JsonPlusSerializer(allowed_msgpack_modules=None)
|
||||
msg = HumanMessage(content="hello")
|
||||
|
||||
caplog.clear()
|
||||
result = serde.loads_typed(serde.dumps_typed(msg))
|
||||
|
||||
assert "blocked" not in caplog.text.lower()
|
||||
assert "unregistered" not in caplog.text.lower()
|
||||
assert isinstance(result, HumanMessage)
|
||||
assert result == msg
|
||||
|
||||
|
||||
def test_msgpack_strict_allows_langchain_document(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
serde = JsonPlusSerializer(allowed_msgpack_modules=None)
|
||||
doc = Document(page_content="hello", metadata={"k": "v"})
|
||||
|
||||
caplog.clear()
|
||||
result = serde.loads_typed(serde.dumps_typed(doc))
|
||||
|
||||
assert "blocked" not in caplog.text.lower()
|
||||
assert "unregistered" not in caplog.text.lower()
|
||||
assert isinstance(result, Document)
|
||||
assert result == doc
|
||||
|
||||
|
||||
def test_msgpack_regex_safe_type(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""re.compile patterns should deserialize without warnings as a safe type."""
|
||||
|
||||
serde = JsonPlusSerializer(allowed_msgpack_modules=None)
|
||||
pattern = re.compile(r"foo.*bar", re.IGNORECASE | re.DOTALL)
|
||||
|
||||
caplog.clear()
|
||||
dumped = serde.dumps_typed(pattern)
|
||||
result = serde.loads_typed(dumped)
|
||||
|
||||
assert "blocked" not in caplog.text.lower()
|
||||
assert "unregistered" not in caplog.text.lower()
|
||||
assert result.pattern == pattern.pattern
|
||||
assert result.flags == pattern.flags
|
||||
|
||||
|
||||
def test_msgpack_method_pathlib_blocked_in_strict(
|
||||
tmp_path: pathlib.Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
target = tmp_path / "secret.txt"
|
||||
target.write_text("secret")
|
||||
serde = JsonPlusSerializer(allowed_msgpack_modules=None)
|
||||
payload = ormsgpack.packb(
|
||||
ormsgpack.Ext(
|
||||
EXT_METHOD_SINGLE_ARG,
|
||||
_msgpack_enc(("pathlib", "Path", target, "read_text")),
|
||||
),
|
||||
option=ormsgpack.OPT_NON_STR_KEYS,
|
||||
)
|
||||
|
||||
caplog.set_level(logging.WARNING, logger="langgraph.checkpoint.serde.jsonplus")
|
||||
caplog.clear()
|
||||
result = serde.loads_typed(("msgpack", payload))
|
||||
|
||||
assert result == target
|
||||
assert "blocked deserialization of method call pathlib.path.read_text" in (
|
||||
caplog.text.lower()
|
||||
)
|
||||
|
||||
|
||||
def test_msgpack_method_pathlib_blocked_default_mode(
|
||||
tmp_path: pathlib.Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
target = tmp_path / "secret.txt"
|
||||
target.write_text("secret")
|
||||
serde = JsonPlusSerializer(allowed_msgpack_modules=True)
|
||||
payload = ormsgpack.packb(
|
||||
ormsgpack.Ext(
|
||||
EXT_METHOD_SINGLE_ARG,
|
||||
_msgpack_enc(("pathlib", "Path", target, "read_text")),
|
||||
),
|
||||
option=ormsgpack.OPT_NON_STR_KEYS,
|
||||
)
|
||||
|
||||
caplog.set_level(logging.WARNING, logger="langgraph.checkpoint.serde.jsonplus")
|
||||
caplog.clear()
|
||||
result = serde.loads_typed(("msgpack", payload))
|
||||
|
||||
assert result == target
|
||||
assert "blocked deserialization of method call pathlib.path.read_text" in (
|
||||
caplog.text.lower()
|
||||
)
|
||||
|
||||
|
||||
def test_msgpack_regex_still_works_strict(caplog: pytest.LogCaptureFixture) -> None:
|
||||
serde = JsonPlusSerializer(allowed_msgpack_modules=None)
|
||||
pattern = re.compile(r"pattern", re.IGNORECASE | re.MULTILINE)
|
||||
|
||||
caplog.clear()
|
||||
result = serde.loads_typed(serde.dumps_typed(pattern))
|
||||
|
||||
assert "blocked" not in caplog.text.lower()
|
||||
assert result.pattern == pattern.pattern
|
||||
assert result.flags == pattern.flags
|
||||
|
||||
|
||||
def test_msgpack_path_constructor_still_works() -> None:
|
||||
serde = JsonPlusSerializer(allowed_msgpack_modules=None)
|
||||
path_obj = pathlib.Path("/tmp/foo")
|
||||
|
||||
result = serde.loads_typed(serde.dumps_typed(path_obj))
|
||||
|
||||
assert result == path_obj
|
||||
|
||||
|
||||
def test_with_msgpack_allowlist_noop_returns_same_instance() -> None:
|
||||
serde = JsonPlusSerializer(allowed_msgpack_modules=None)
|
||||
|
||||
result = serde.with_msgpack_allowlist(())
|
||||
|
||||
assert result is serde
|
||||
|
||||
|
||||
def test_with_msgpack_allowlist_supports_subclass_without_init_kwargs() -> None:
|
||||
class CustomSerializer(JsonPlusSerializer):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(allowed_msgpack_modules=None)
|
||||
|
||||
serde = CustomSerializer()
|
||||
result = serde.with_msgpack_allowlist([MyDataclass])
|
||||
|
||||
assert isinstance(result, CustomSerializer)
|
||||
assert result is not serde
|
||||
assert serde._allowed_msgpack_modules is None
|
||||
assert result._allowed_msgpack_modules == {
|
||||
(MyDataclass.__module__, MyDataclass.__name__)
|
||||
}
|
||||
|
||||
|
||||
def test_with_msgpack_allowlist_rebuilds_default_unpack_hook() -> None:
|
||||
serde = JsonPlusSerializer(allowed_msgpack_modules=None)
|
||||
original_hook = serde._unpack_ext_hook
|
||||
|
||||
result = serde.with_msgpack_allowlist([MyDataclass])
|
||||
|
||||
assert result._unpack_ext_hook is not original_hook
|
||||
|
||||
|
||||
def test_with_msgpack_allowlist_preserves_custom_unpack_hook() -> None:
|
||||
def custom_hook(code: int, data: bytes) -> None:
|
||||
return None
|
||||
|
||||
serde = JsonPlusSerializer(
|
||||
allowed_msgpack_modules=None, __unpack_ext_hook__=custom_hook
|
||||
)
|
||||
result = serde.with_msgpack_allowlist([MyDataclass])
|
||||
|
||||
assert result._unpack_ext_hook is custom_hook
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.version_info >= (3, 14), reason="pydantic v1 not on 3.14+")
|
||||
def test_msgpack_pydantic_v1_allowlist(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""Pydantic v1 models in allowlist should deserialize without warnings."""
|
||||
|
||||
serde = JsonPlusSerializer(
|
||||
allowed_msgpack_modules=[
|
||||
("tests.test_jsonplus", "MyPydanticV1"),
|
||||
("tests.test_jsonplus", "InnerPydanticV1"),
|
||||
]
|
||||
)
|
||||
|
||||
obj = MyPydanticV1(foo="test", bar=42, inner=InnerPydanticV1(hello="world"))
|
||||
|
||||
caplog.clear()
|
||||
dumped = serde.dumps_typed(obj)
|
||||
result = serde.loads_typed(dumped)
|
||||
|
||||
assert "unregistered type" not in caplog.text.lower()
|
||||
assert "blocked" not in caplog.text.lower()
|
||||
assert result == obj
|
||||
|
||||
|
||||
def test_msgpack_dataclass_allowlist(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""Dataclasses in allowlist should deserialize without warnings."""
|
||||
|
||||
serde = JsonPlusSerializer(
|
||||
allowed_msgpack_modules=[
|
||||
("tests.test_jsonplus", "MyDataclass"),
|
||||
("tests.test_jsonplus", "InnerDataclass"),
|
||||
]
|
||||
)
|
||||
|
||||
obj = MyDataclass(foo="test", bar=42, inner=InnerDataclass(hello="world"))
|
||||
|
||||
caplog.clear()
|
||||
dumped = serde.dumps_typed(obj)
|
||||
result = serde.loads_typed(dumped)
|
||||
|
||||
assert "unregistered type" not in caplog.text.lower()
|
||||
assert "blocked" not in caplog.text.lower()
|
||||
assert result == obj
|
||||
|
||||
|
||||
def test_msgpack_safe_types_value_equality(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""Verify safe types are correctly restored with proper values."""
|
||||
|
||||
serde = JsonPlusSerializer(allowed_msgpack_modules=None)
|
||||
|
||||
test_cases = [
|
||||
datetime(2024, 1, 15, 12, 30, 45, 123456),
|
||||
date(2024, 6, 15),
|
||||
time(14, 30, 0),
|
||||
uuid.UUID("12345678-1234-5678-1234-567812345678"),
|
||||
Decimal("123.456789"),
|
||||
{1, 2, 3, 4, 5},
|
||||
frozenset(["a", "b", "c"]),
|
||||
deque([1, 2, 3]),
|
||||
IPv4Address("10.0.0.1"),
|
||||
pathlib.Path("/some/test/path"),
|
||||
re.compile(r"\d+", re.MULTILINE),
|
||||
]
|
||||
|
||||
for obj in test_cases:
|
||||
caplog.clear()
|
||||
dumped = serde.dumps_typed(obj)
|
||||
result = serde.loads_typed(dumped)
|
||||
|
||||
assert "blocked" not in caplog.text.lower(), f"Blocked for {type(obj)}"
|
||||
# For regex patterns, compare pattern and flags
|
||||
if isinstance(obj, re.Pattern):
|
||||
assert result.pattern == obj.pattern
|
||||
assert result.flags == obj.flags
|
||||
else:
|
||||
assert result == obj, f"Value mismatch for {type(obj)}: {result} != {obj}"
|
||||
|
||||
|
||||
def test_msgpack_nested_pydantic_serializes_as_dict(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Nested Pydantic models are serialized via model_dump() as dicts.
|
||||
|
||||
This means nested models don't go through the ext hook and don't need
|
||||
to be in the allowlist - only the outer type does.
|
||||
"""
|
||||
|
||||
# Only allow outer type - inner is serialized as dict via model_dump()
|
||||
serde = JsonPlusSerializer(
|
||||
allowed_msgpack_modules=[("tests.test_jsonplus", "MyPydantic")]
|
||||
)
|
||||
|
||||
obj = MyPydantic(foo="test", bar=42, inner=InnerPydantic(hello="world"))
|
||||
|
||||
caplog.clear()
|
||||
dumped = serde.dumps_typed(obj)
|
||||
result = serde.loads_typed(dumped)
|
||||
|
||||
# No blocking should occur - inner is serialized as dict, not ext
|
||||
assert "blocked" not in caplog.text.lower()
|
||||
assert result == obj
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from pydantic import BaseModel
|
||||
|
||||
from langgraph.checkpoint.base import (
|
||||
Checkpoint,
|
||||
@@ -10,6 +12,11 @@ from langgraph.checkpoint.base import (
|
||||
empty_checkpoint,
|
||||
)
|
||||
from langgraph.checkpoint.memory import InMemorySaver
|
||||
from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
|
||||
|
||||
|
||||
class MemoryPydantic(BaseModel):
|
||||
foo: str
|
||||
|
||||
|
||||
class TestMemorySaver:
|
||||
@@ -199,3 +206,105 @@ async def test_memory_saver() -> None:
|
||||
|
||||
with memory_saver as sync_memory_saver:
|
||||
assert sync_memory_saver is memory_saver
|
||||
|
||||
|
||||
def test_memory_saver_warns_on_unregistered_msgpack(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
serde = JsonPlusSerializer()
|
||||
memory_saver = InMemorySaver(serde=serde)
|
||||
obj = MemoryPydantic(foo="bar")
|
||||
|
||||
checkpoint = empty_checkpoint()
|
||||
checkpoint["channel_values"] = {"foo": obj}
|
||||
checkpoint["channel_versions"] = {"foo": 1}
|
||||
|
||||
config: RunnableConfig = {
|
||||
"configurable": {"thread_id": "thread-1", "checkpoint_ns": ""}
|
||||
}
|
||||
|
||||
caplog.set_level(logging.WARNING, logger="langgraph.checkpoint.serde.jsonplus")
|
||||
new_config = memory_saver.put(config, checkpoint, {}, {"foo": 1})
|
||||
result = memory_saver.get_tuple(new_config)
|
||||
|
||||
assert result is not None
|
||||
assert "unregistered type" in caplog.text.lower()
|
||||
assert result.checkpoint["channel_values"]["foo"] == obj
|
||||
|
||||
|
||||
def test_memory_saver_allowlist_silences_warning(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
serde = JsonPlusSerializer(
|
||||
allowed_msgpack_modules=[("tests.test_memory", "MemoryPydantic")]
|
||||
)
|
||||
memory_saver = InMemorySaver(serde=serde)
|
||||
obj = MemoryPydantic(foo="bar")
|
||||
|
||||
checkpoint = empty_checkpoint()
|
||||
checkpoint["channel_values"] = {"foo": obj}
|
||||
checkpoint["channel_versions"] = {"foo": 1}
|
||||
|
||||
config: RunnableConfig = {
|
||||
"configurable": {"thread_id": "thread-1", "checkpoint_ns": ""}
|
||||
}
|
||||
|
||||
caplog.set_level(logging.WARNING, logger="langgraph.checkpoint.serde.jsonplus")
|
||||
new_config = memory_saver.put(config, checkpoint, {}, {"foo": 1})
|
||||
result = memory_saver.get_tuple(new_config)
|
||||
|
||||
assert result is not None
|
||||
assert "unregistered type" not in caplog.text.lower()
|
||||
assert result.checkpoint["channel_values"]["foo"] == obj
|
||||
|
||||
|
||||
def test_memory_saver_strict_blocks_unregistered(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
serde = JsonPlusSerializer(allowed_msgpack_modules=None)
|
||||
memory_saver = InMemorySaver(serde=serde)
|
||||
obj = MemoryPydantic(foo="bar")
|
||||
|
||||
checkpoint = empty_checkpoint()
|
||||
checkpoint["channel_values"] = {"foo": obj}
|
||||
checkpoint["channel_versions"] = {"foo": 1}
|
||||
|
||||
config: RunnableConfig = {
|
||||
"configurable": {"thread_id": "thread-1", "checkpoint_ns": ""}
|
||||
}
|
||||
|
||||
caplog.set_level(logging.WARNING, logger="langgraph.checkpoint.serde.jsonplus")
|
||||
new_config = memory_saver.put(config, checkpoint, {}, {"foo": 1})
|
||||
result = memory_saver.get_tuple(new_config)
|
||||
|
||||
assert result is not None
|
||||
assert "blocked" in caplog.text.lower()
|
||||
expected = obj.model_dump() if hasattr(obj, "model_dump") else obj.dict()
|
||||
assert result.checkpoint["channel_values"]["foo"] == expected
|
||||
|
||||
|
||||
def test_memory_saver_with_allowlist_proxy_isolated() -> None:
|
||||
serde = JsonPlusSerializer(allowed_msgpack_modules=None)
|
||||
memory_saver = InMemorySaver(serde=serde)
|
||||
proxy = memory_saver.with_allowlist([("tests.test_memory", "MemoryPydantic")])
|
||||
|
||||
obj = MemoryPydantic(foo="bar")
|
||||
|
||||
checkpoint = empty_checkpoint()
|
||||
checkpoint["channel_values"] = {"foo": obj}
|
||||
checkpoint["channel_versions"] = {"foo": 1}
|
||||
|
||||
config: RunnableConfig = {
|
||||
"configurable": {"thread_id": "thread-1", "checkpoint_ns": ""}
|
||||
}
|
||||
|
||||
new_config = proxy.put(config, checkpoint, {}, {"foo": 1})
|
||||
|
||||
proxied = proxy.get_tuple(new_config)
|
||||
assert proxied is not None
|
||||
assert proxied.checkpoint["channel_values"]["foo"] == obj
|
||||
|
||||
direct = memory_saver.get_tuple(new_config)
|
||||
assert direct is not None
|
||||
expected = obj.model_dump() if hasattr(obj, "model_dump") else obj.dict()
|
||||
assert direct.checkpoint["channel_values"]["foo"] == expected
|
||||
|
||||
Generated
+63
-26
@@ -267,7 +267,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langchain-core"
|
||||
version = "1.2.12"
|
||||
version = "1.2.14"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jsonpatch" },
|
||||
@@ -279,14 +279,14 @@ dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "uuid-utils" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2a/1d/08e935d1532fcc90981f6e5bb6825914c9227ea7a962c62b1e18619b49e7/langchain_core-1.2.12.tar.gz", hash = "sha256:4d7fa6643d7ab06fc1905a9b7dcbe96a6f3c181046b56edf9c0c17ecd412d9e9", size = 831329, upload-time = "2026-02-12T20:53:15.01Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3f/ff/c5e3da8eca8a18719b300ef6c29e28208ee4e9da7f9749022b96292b6541/langchain_core-1.2.14.tar.gz", hash = "sha256:09549d838a2672781da3a9502f3b9c300863284b77b27e2a6dac4e6e650acfed", size = 833399, upload-time = "2026-02-19T14:22:33.514Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/a5/678ab0e5cc57794f20ae5ed12c1442506ef1108c9434f950aebc6044e5a3/langchain_core-1.2.12-py3-none-any.whl", hash = "sha256:66ca17a2a9cb007ab29021968e6adfcf4228067151dc2bd6ebfff265ffaf92f5", size = 500132, upload-time = "2026-02-12T20:53:13.806Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/41/fe6ae9065b866b1397adbfc98db5e1648e8dcd78126b8e1266fcbe2d6395/langchain_core-1.2.14-py3-none-any.whl", hash = "sha256:b349ca28c057ac1f9b5280ea091bddb057db24d0f1c3c89bbb590713e1715838", size = 501411, upload-time = "2026-02-19T14:22:32.013Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-checkpoint"
|
||||
version = "4.0.0"
|
||||
version = "4.0.1"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -302,6 +302,7 @@ dev = [
|
||||
{ name = "numpy", version = "2.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
{ name = "pandas" },
|
||||
{ name = "pandas-stubs" },
|
||||
{ name = "pycryptodome" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "pytest-mock" },
|
||||
@@ -341,6 +342,7 @@ dev = [
|
||||
{ name = "numpy" },
|
||||
{ name = "pandas" },
|
||||
{ name = "pandas-stubs", specifier = ">=2.2.2.240807" },
|
||||
{ name = "pycryptodome", specifier = ">=3.23.0" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "pytest-mock" },
|
||||
@@ -912,6 +914,41 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycryptodome"
|
||||
version = "3.23.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/12/e33935a0709c07de084d7d58d330ec3f4daf7910a18e77937affdb728452/pycryptodome-3.23.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ddb95b49df036ddd264a0ad246d1be5b672000f12d6961ea2c267083a5e19379", size = 1623886, upload-time = "2025-05-17T17:21:20.614Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/0b/aa8f9419f25870889bebf0b26b223c6986652bdf071f000623df11212c90/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e95564beb8782abfd9e431c974e14563a794a4944c29d6d3b7b5ea042110b4", size = 1672151, upload-time = "2025-05-17T17:21:22.666Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/5e/63f5cbde2342b7f70a39e591dbe75d9809d6338ce0b07c10406f1a140cdc/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14e15c081e912c4b0d75632acd8382dfce45b258667aa3c67caf7a4d4c13f630", size = 1664461, upload-time = "2025-05-17T17:21:25.225Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/92/608fbdad566ebe499297a86aae5f2a5263818ceeecd16733006f1600403c/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7fc76bf273353dc7e5207d172b83f569540fc9a28d63171061c42e361d22353", size = 1702440, upload-time = "2025-05-17T17:21:27.991Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/92/2eadd1341abd2989cce2e2740b4423608ee2014acb8110438244ee97d7ff/pycryptodome-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:45c69ad715ca1a94f778215a11e66b7ff989d792a4d63b68dc586a1da1392ff5", size = 1803005, upload-time = "2025-05-17T17:21:31.37Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.12.5"
|
||||
@@ -1198,14 +1235,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "redis"
|
||||
version = "7.1.1"
|
||||
version = "7.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "async-timeout", marker = "python_full_version < '3.11.3'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f7/80/2971931d27651affa88a44c0ad7b8c4a19dc29c998abb20b23868d319b59/redis-7.1.1.tar.gz", hash = "sha256:a2814b2bda15b39dad11391cc48edac4697214a8a5a4bd10abe936ab4892eb43", size = 4800064, upload-time = "2026-02-09T18:39:40.292Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9f/32/6fac13a11e73e1bc67a2ae821a72bfe4c2d8c4c48f0267e4a952be0f1bae/redis-7.2.0.tar.gz", hash = "sha256:4dd5bf4bd4ae80510267f14185a15cba2a38666b941aff68cccf0256b51c1f26", size = 4901247, upload-time = "2026-02-16T17:16:22.797Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/29/55/1de1d812ba1481fa4b37fb03b4eec0fcb71b6a0d44c04ea3482eb017600f/redis-7.1.1-py3-none-any.whl", hash = "sha256:f77817f16071c2950492c67d40b771fa493eb3fccc630a424a10976dbb794b7a", size = 356057, upload-time = "2026-02-09T18:39:38.602Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/cf/f6180b67f99688d83e15c84c5beda831d1d341e95872d224f87ccafafe61/redis-7.2.0-py3-none-any.whl", hash = "sha256:01f591f8598e483f1842d429e8ae3a820804566f1c73dca1b80e23af9fba0497", size = 394898, upload-time = "2026-02-16T17:16:20.693Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1237,27 +1274,27 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.1"
|
||||
version = "0.15.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/04/dc/4e6ac71b511b141cf626357a3946679abeba4cf67bc7cc5a17920f31e10d/ruff-0.15.1.tar.gz", hash = "sha256:c590fe13fb57c97141ae975c03a1aedb3d3156030cabd740d6ff0b0d601e203f", size = 4540855, upload-time = "2026-02-12T23:09:09.998Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/04/eab13a954e763b0606f460443fcbf6bb5a0faf06890ea3754ff16523dce5/ruff-0.15.2.tar.gz", hash = "sha256:14b965afee0969e68bb871eba625343b8673375f457af4abe98553e8bbb98342", size = 4558148, upload-time = "2026-02-19T22:32:20.271Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/23/bf/e6e4324238c17f9d9120a9d60aa99a7daaa21204c07fcd84e2ef03bb5fd1/ruff-0.15.1-py3-none-linux_armv6l.whl", hash = "sha256:b101ed7cf4615bda6ffe65bdb59f964e9f4a0d3f85cbf0e54f0ab76d7b90228a", size = 10367819, upload-time = "2026-02-12T23:09:03.598Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/ea/c8f89d32e7912269d38c58f3649e453ac32c528f93bb7f4219258be2e7ed/ruff-0.15.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:939c995e9277e63ea632cc8d3fae17aa758526f49a9a850d2e7e758bfef46602", size = 10798618, upload-time = "2026-02-12T23:09:22.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/0f/1d0d88bc862624247d82c20c10d4c0f6bb2f346559d8af281674cf327f15/ruff-0.15.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1d83466455fdefe60b8d9c8df81d3c1bbb2115cede53549d3b522ce2bc703899", size = 10148518, upload-time = "2026-02-12T23:08:58.339Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/c8/291c49cefaa4a9248e986256df2ade7add79388fe179e0691be06fae6f37/ruff-0.15.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9457e3c3291024866222b96108ab2d8265b477e5b1534c7ddb1810904858d16", size = 10518811, upload-time = "2026-02-12T23:09:31.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/1a/f5707440e5ae43ffa5365cac8bbb91e9665f4a883f560893829cf16a606b/ruff-0.15.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:92c92b003e9d4f7fbd33b1867bb15a1b785b1735069108dfc23821ba045b29bc", size = 10196169, upload-time = "2026-02-12T23:09:17.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/ff/26ddc8c4da04c8fd3ee65a89c9fb99eaa5c30394269d424461467be2271f/ruff-0.15.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fe5c41ab43e3a06778844c586251eb5a510f67125427625f9eb2b9526535779", size = 10990491, upload-time = "2026-02-12T23:09:25.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/00/50920cb385b89413f7cdb4bb9bc8fc59c1b0f30028d8bccc294189a54955/ruff-0.15.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66a6dd6df4d80dc382c6484f8ce1bcceb55c32e9f27a8b94c32f6c7331bf14fb", size = 11843280, upload-time = "2026-02-12T23:09:19.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/6d/2f5cad8380caf5632a15460c323ae326f1e1a2b5b90a6ee7519017a017ca/ruff-0.15.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a4a42cbb8af0bda9bcd7606b064d7c0bc311a88d141d02f78920be6acb5aa83", size = 11274336, upload-time = "2026-02-12T23:09:14.907Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/1d/5f56cae1d6c40b8a318513599b35ea4b075d7dc1cd1d04449578c29d1d75/ruff-0.15.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ab064052c31dddada35079901592dfba2e05f5b1e43af3954aafcbc1096a5b2", size = 11137288, upload-time = "2026-02-12T23:09:07.475Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/20/6f8d7d8f768c93b0382b33b9306b3b999918816da46537d5a61635514635/ruff-0.15.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5631c940fe9fe91f817a4c2ea4e81f47bee3ca4aa646134a24374f3c19ad9454", size = 11070681, upload-time = "2026-02-12T23:08:55.43Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/67/d640ac76069f64cdea59dba02af2e00b1fa30e2103c7f8d049c0cff4cafd/ruff-0.15.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68138a4ba184b4691ccdc39f7795c66b3c68160c586519e7e8444cf5a53e1b4c", size = 10486401, upload-time = "2026-02-12T23:09:27.927Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/3d/e1429f64a3ff89297497916b88c32a5cc88eeca7e9c787072d0e7f1d3e1e/ruff-0.15.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:518f9af03bfc33c03bdb4cb63fabc935341bb7f54af500f92ac309ecfbba6330", size = 10197452, upload-time = "2026-02-12T23:09:12.147Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/83/e2c3bade17dad63bf1e1c2ffaf11490603b760be149e1419b07049b36ef2/ruff-0.15.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:da79f4d6a826caaea95de0237a67e33b81e6ec2e25fc7e1993a4015dffca7c61", size = 10693900, upload-time = "2026-02-12T23:09:34.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/27/fdc0e11a813e6338e0706e8b39bb7a1d61ea5b36873b351acee7e524a72a/ruff-0.15.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3dd86dccb83cd7d4dcfac303ffc277e6048600dfc22e38158afa208e8bf94a1f", size = 11227302, upload-time = "2026-02-12T23:09:36.536Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/58/ac864a75067dcbd3b95be5ab4eb2b601d7fbc3d3d736a27e391a4f92a5c1/ruff-0.15.1-py3-none-win32.whl", hash = "sha256:660975d9cb49b5d5278b12b03bb9951d554543a90b74ed5d366b20e2c57c2098", size = 10462555, upload-time = "2026-02-12T23:09:29.899Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/5e/d4ccc8a27ecdb78116feac4935dfc39d1304536f4296168f91ed3ec00cd2/ruff-0.15.1-py3-none-win_amd64.whl", hash = "sha256:c820fef9dd5d4172a6570e5721704a96c6679b80cf7be41659ed439653f62336", size = 11599956, upload-time = "2026-02-12T23:09:01.157Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/07/5bda6a85b220c64c65686bc85bd0bbb23b29c62b3a9f9433fa55f17cda93/ruff-0.15.1-py3-none-win_arm64.whl", hash = "sha256:5ff7d5f0f88567850f45081fac8f4ec212be8d0b963e385c3f7d0d2eb4899416", size = 10874604, upload-time = "2026-02-12T23:09:05.515Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/70/3a4dc6d09b13cb3e695f28307e5d889b2e1a66b7af9c5e257e796695b0e6/ruff-0.15.2-py3-none-linux_armv6l.whl", hash = "sha256:120691a6fdae2f16d65435648160f5b81a9625288f75544dc40637436b5d3c0d", size = 10430565, upload-time = "2026-02-19T22:32:41.824Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/0b/bb8457b56185ece1305c666dc895832946d24055be90692381c31d57466d/ruff-0.15.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a89056d831256099658b6bba4037ac6dd06f49d194199215befe2bb10457ea5e", size = 10820354, upload-time = "2026-02-19T22:32:07.366Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/c1/e0532d7f9c9e0b14c46f61b14afd563298b8b83f337b6789ddd987e46121/ruff-0.15.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e36dee3a64be0ebd23c86ffa3aa3fd3ac9a712ff295e192243f814a830b6bd87", size = 10170767, upload-time = "2026-02-19T22:32:13.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/e8/da1aa341d3af017a21c7a62fb5ec31d4e7ad0a93ab80e3a508316efbcb23/ruff-0.15.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9fb47b6d9764677f8c0a193c0943ce9a05d6763523f132325af8a858eadc2b9", size = 10529591, upload-time = "2026-02-19T22:32:02.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/74/184fbf38e9f3510231fbc5e437e808f0b48c42d1df9434b208821efcd8d6/ruff-0.15.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f376990f9d0d6442ea9014b19621d8f2aaf2b8e39fdbfc79220b7f0c596c9b80", size = 10260771, upload-time = "2026-02-19T22:32:36.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/ac/605c20b8e059a0bc4b42360414baa4892ff278cec1c91fff4be0dceedefd/ruff-0.15.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2dcc987551952d73cbf5c88d9fdee815618d497e4df86cd4c4824cc59d5dd75f", size = 11045791, upload-time = "2026-02-19T22:32:31.642Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/52/db6e419908f45a894924d410ac77d64bdd98ff86901d833364251bd08e22/ruff-0.15.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:42a47fd785cbe8c01b9ff45031af875d101b040ad8f4de7bbb716487c74c9a77", size = 11879271, upload-time = "2026-02-19T22:32:29.305Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/d8/7992b18f2008bdc9231d0f10b16df7dda964dbf639e2b8b4c1b4e91b83af/ruff-0.15.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cbe9f49354866e575b4c6943856989f966421870e85cd2ac94dccb0a9dcb2fea", size = 11303707, upload-time = "2026-02-19T22:32:22.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/02/849b46184bcfdd4b64cde61752cc9a146c54759ed036edd11857e9b8443b/ruff-0.15.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7a672c82b5f9887576087d97be5ce439f04bbaf548ee987b92d3a7dede41d3a", size = 11149151, upload-time = "2026-02-19T22:32:44.234Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/04/f5284e388bab60d1d3b99614a5a9aeb03e0f333847e2429bebd2aaa1feec/ruff-0.15.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ecc64f46f7019e2bcc3cdc05d4a7da958b629a5ab7033195e11a438403d956", size = 11091132, upload-time = "2026-02-19T22:32:24.691Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/ae/88d844a21110e14d92cf73d57363fab59b727ebeabe78009b9ccb23500af/ruff-0.15.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:8dcf243b15b561c655c1ef2f2b0050e5d50db37fe90115507f6ff37d865dc8b4", size = 10504717, upload-time = "2026-02-19T22:32:26.75Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/27/867076a6ada7f2b9c8292884ab44d08fd2ba71bd2b5364d4136f3cd537e1/ruff-0.15.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dab6941c862c05739774677c6273166d2510d254dac0695c0e3f5efa1b5585de", size = 10263122, upload-time = "2026-02-19T22:32:10.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/ef/faf9321d550f8ebf0c6373696e70d1758e20ccdc3951ad7af00c0956be7c/ruff-0.15.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b9164f57fc36058e9a6806eb92af185b0697c9fe4c7c52caa431c6554521e5c", size = 10735295, upload-time = "2026-02-19T22:32:39.227Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/55/e8089fec62e050ba84d71b70e7834b97709ca9b7aba10c1a0b196e493f97/ruff-0.15.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:80d24fcae24d42659db7e335b9e1531697a7102c19185b8dc4a028b952865fd8", size = 11241641, upload-time = "2026-02-19T22:32:34.617Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/01/1c30526460f4d23222d0fabd5888868262fd0e2b71a00570ca26483cd993/ruff-0.15.2-py3-none-win32.whl", hash = "sha256:fd5ff9e5f519a7e1bd99cbe8daa324010a74f5e2ebc97c6242c08f26f3714f6f", size = 10507885, upload-time = "2026-02-19T22:32:15.635Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/10/3d18e3bbdf8fc50bbb4ac3cc45970aa5a9753c5cb51bf9ed9a3cd8b79fa3/ruff-0.15.2-py3-none-win_amd64.whl", hash = "sha256:d20014e3dfa400f3ff84830dfb5755ece2de45ab62ecea4af6b7262d0fb4f7c5", size = 11623725, upload-time = "2026-02-19T22:32:04.947Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/78/097c0798b1dab9f8affe73da9642bb4500e098cb27fd8dc9724816ac747b/ruff-0.15.2-py3-none-win_arm64.whl", hash = "sha256:cabddc5822acdc8f7b5527b36ceac55cc51eec7b1946e60181de8fe83ca8876e", size = 10941649, upload-time = "2026-02-19T22:32:18.108Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -1,10 +1 @@
|
||||
.langgraph_api/
|
||||
npm/
|
||||
|
||||
# Rust build artifacts
|
||||
/target/
|
||||
/dist/
|
||||
|
||||
# npm platform binaries (added at build time, not checked in)
|
||||
npm/*/bin/langgraph
|
||||
npm/*/bin/langgraph.exe
|
||||
|
||||
Generated
-2521
File diff suppressed because it is too large
Load Diff
@@ -1,37 +0,0 @@
|
||||
[package]
|
||||
name = "langgraph-cli"
|
||||
version = "0.2.10"
|
||||
edition = "2021"
|
||||
description = "Native CLI for LangGraph"
|
||||
license = "MIT"
|
||||
|
||||
[[bin]]
|
||||
name = "langgraph"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = { version = "1" }
|
||||
reqwest = { version = "0.12", features = ["rustls-tls", "blocking"], default-features = false }
|
||||
zip = "2"
|
||||
indexmap = { version = "2", features = ["serde"] }
|
||||
open = "5"
|
||||
indicatif = "0.17"
|
||||
console = "0.15"
|
||||
dotenvy = "0.15"
|
||||
tempfile = "3"
|
||||
thiserror = "2"
|
||||
dialoguer = "0.11"
|
||||
regex = "1"
|
||||
which = "7"
|
||||
|
||||
[dev-dependencies]
|
||||
assert_cmd = "2"
|
||||
predicates = "3"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
strip = "symbols"
|
||||
+8
-52
@@ -1,27 +1,7 @@
|
||||
.PHONY: test lint format test-integration update-schema build build-release clean cargo-test cargo-lint
|
||||
.PHONY: test lint type format test-integration update-schema bump-version
|
||||
|
||||
######################
|
||||
# RUST BUILD
|
||||
######################
|
||||
|
||||
build:
|
||||
cargo build
|
||||
|
||||
build-release:
|
||||
cargo build --release
|
||||
|
||||
cargo-test:
|
||||
cargo test
|
||||
|
||||
cargo-lint:
|
||||
cargo fmt --check
|
||||
cargo clippy -- -D warnings
|
||||
|
||||
cargo-fmt:
|
||||
cargo fmt
|
||||
|
||||
######################
|
||||
# PYTHON TESTING AND COVERAGE
|
||||
# TESTING AND COVERAGE
|
||||
######################
|
||||
|
||||
TEST?= "tests/unit_tests"
|
||||
@@ -31,7 +11,7 @@ test-integration:
|
||||
uv run pytest tests/integration_tests
|
||||
|
||||
######################
|
||||
# PYTHON LINTING AND FORMATTING
|
||||
# LINTING AND FORMATTING
|
||||
######################
|
||||
|
||||
# Define a variable for Python and notebook files.
|
||||
@@ -49,39 +29,15 @@ lint lint_diff lint_package lint_tests:
|
||||
[ "$(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)
|
||||
|
||||
type:
|
||||
mkdir -p $(MYPY_CACHE) && uv run mypy $(PYTHON_FILES) --cache-dir $(MYPY_CACHE)
|
||||
|
||||
format format_diff:
|
||||
uv run ruff format $(PYTHON_FILES)
|
||||
uv run ruff check --select I --fix $(PYTHON_FILES)
|
||||
|
||||
######################
|
||||
# PACKAGING
|
||||
######################
|
||||
|
||||
# Build maturin wheel for current platform (development)
|
||||
wheel-dev:
|
||||
maturin build --release
|
||||
|
||||
# Build maturin wheel for specific target
|
||||
# Usage: make wheel TARGET=x86_64-unknown-linux-gnu
|
||||
TARGET?=
|
||||
wheel:
|
||||
maturin build --release $(if $(TARGET),--target $(TARGET),)
|
||||
|
||||
# Build sdist
|
||||
sdist:
|
||||
maturin sdist
|
||||
|
||||
######################
|
||||
# SCHEMA
|
||||
######################
|
||||
|
||||
update-schema:
|
||||
uv run python generate_schema.py
|
||||
|
||||
######################
|
||||
# CLEANUP
|
||||
######################
|
||||
|
||||
clean:
|
||||
cargo clean
|
||||
rm -rf dist/ target/wheels/
|
||||
bump-version:
|
||||
uv run hatch version patch
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
fn main() {
|
||||
// Cargo already sets CARGO_PKG_VERSION, nothing to do
|
||||
}
|
||||
@@ -20,6 +20,7 @@ from langgraph_cli.schemas import (
|
||||
Config,
|
||||
ConfigurableHeaderConfig,
|
||||
CorsConfig,
|
||||
GraphDef,
|
||||
HttpConfig,
|
||||
IndexConfig,
|
||||
SecurityConfig,
|
||||
@@ -108,6 +109,7 @@ def add_descriptions_to_schema(schema, cls):
|
||||
# Find the class that corresponds to this definition
|
||||
for potential_cls in [
|
||||
Config,
|
||||
GraphDef,
|
||||
StoreConfig,
|
||||
IndexConfig,
|
||||
AuthConfig,
|
||||
|
||||
@@ -21,18 +21,18 @@
|
||||
"test:all": "yarn test && yarn test:int && yarn lint:langgraph"
|
||||
},
|
||||
"dependencies": {
|
||||
"@langchain/core": "^1.1.24",
|
||||
"@langchain/langgraph": "^1.1.4"
|
||||
"@langchain/core": "^1.1.27",
|
||||
"@langchain/langgraph": "^1.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.3",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tsconfig/recommended": "^1.0.13",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.55.0",
|
||||
"@typescript-eslint/parser": "^8.55.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.56.0",
|
||||
"@typescript-eslint/parser": "^8.56.0",
|
||||
"dotenv": "^17.3.1",
|
||||
"eslint": "^10.0.0",
|
||||
"eslint": "^10.0.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-no-instanceof": "^1.0.1",
|
||||
|
||||
+133
-133
@@ -474,14 +474,14 @@
|
||||
resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz#bccdf615bcf7b6e8db830ec0b8d21c9a25de597b"
|
||||
integrity sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==
|
||||
|
||||
"@eslint/config-array@^0.23.0":
|
||||
version "0.23.1"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.23.1.tgz#908223da7b9148f1af5bfb3144b77a9387a89446"
|
||||
integrity sha512-uVSdg/V4dfQmTjJzR0szNczjOH/J+FyUMMjYtr07xFRXR7EDf9i1qdxrD0VusZH9knj1/ecxzCQQxyic5NzAiA==
|
||||
"@eslint/config-array@^0.23.2":
|
||||
version "0.23.2"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.23.2.tgz#db85beeff7facc685a5775caacb1c845669b9470"
|
||||
integrity sha512-YF+fE6LV4v5MGWRGj7G404/OZzGNepVF8fxk7jqmqo3lrza7a0uUcDnROGRBG1WFC1omYUS/Wp1f42i0M+3Q3A==
|
||||
dependencies:
|
||||
"@eslint/object-schema" "^3.0.1"
|
||||
"@eslint/object-schema" "^3.0.2"
|
||||
debug "^4.3.1"
|
||||
minimatch "^10.1.1"
|
||||
minimatch "^10.2.1"
|
||||
|
||||
"@eslint/config-helpers@^0.5.2":
|
||||
version "0.5.2"
|
||||
@@ -517,10 +517,10 @@
|
||||
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-10.0.1.tgz#1e8a876f50117af8ab67e47d5ad94d38d6622583"
|
||||
integrity sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==
|
||||
|
||||
"@eslint/object-schema@^3.0.1":
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-3.0.1.tgz#9a1dc9af00d790dc79a9bf57a756e3cb2740ddb9"
|
||||
integrity sha512-P9cq2dpr+LU8j3qbLygLcSZrl2/ds/pUpfnHNNuk5HW7mnngHs+6WSq5C9mO3rqRX8A1poxqLTC9cu0KOyJlBg==
|
||||
"@eslint/object-schema@^3.0.2":
|
||||
version "3.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-3.0.2.tgz#c59c6a94aa4b428ed7f1615b6a4495c0a21f7a22"
|
||||
integrity sha512-HOy56KJt48Bx8KmJ+XGQNSUMT/6dZee/M54XyUyuvTvPXJmsERRvBchsUVx1UMe1WwIH49XLAczNC7V2INsuUw==
|
||||
|
||||
"@eslint/plugin-kit@^0.6.0":
|
||||
version "0.6.0"
|
||||
@@ -867,10 +867,10 @@
|
||||
"@jridgewell/resolve-uri" "^3.1.0"
|
||||
"@jridgewell/sourcemap-codec" "^1.4.14"
|
||||
|
||||
"@langchain/core@^1.1.24":
|
||||
version "1.1.24"
|
||||
resolved "https://registry.yarnpkg.com/@langchain/core/-/core-1.1.24.tgz#714f5d953c3887104616386a404fa5d5d29e8d9b"
|
||||
integrity sha512-u6l0dmMHN/2PCsY6stXoh9CH1OTlVR5Gjz0JjT1XRPuidAlu3kTq4ivW95xCog/PRhiAsCh6GCEC4/PqhNrcgQ==
|
||||
"@langchain/core@^1.1.27":
|
||||
version "1.1.27"
|
||||
resolved "https://registry.yarnpkg.com/@langchain/core/-/core-1.1.27.tgz#b5a05c014eef2973006fd9e0df1e135b9da640e2"
|
||||
integrity sha512-YVtEz3nqCh8WxtdVXUICmt2BR2An+mn4YRJUBwcHX47Yrh2VwxpO0l97B2N/sNi658m65HnGyz2/hAjF3fzc1w==
|
||||
dependencies:
|
||||
"@cfworker/json-schema" "^4.0.2"
|
||||
ansi-styles "^5.0.0"
|
||||
@@ -890,23 +890,23 @@
|
||||
dependencies:
|
||||
uuid "^10.0.0"
|
||||
|
||||
"@langchain/langgraph-sdk@~1.6.0":
|
||||
version "1.6.2"
|
||||
resolved "https://registry.yarnpkg.com/@langchain/langgraph-sdk/-/langgraph-sdk-1.6.2.tgz#3b5d0707ddb844fd288cfacb81bb1b726ea7ee5f"
|
||||
integrity sha512-UzRZsnDqdTmeitf/K5yZnVdl+V+7bDj/hQUXm+Y8TwWUuKtWUDocIReKgAmPQLoIz0AN8bOUt0QGnIISmCZyuA==
|
||||
"@langchain/langgraph-sdk@~2.0.0":
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@langchain/langgraph-sdk/-/langgraph-sdk-2.0.0.tgz#55ac46373aa4917443d92c8b2edd5efd162bc879"
|
||||
integrity sha512-Xdkl1hve84ZGQ7fgpiBIBvjODhtjbPPccY4snOtYgSdzRXZkESsi2Y7RDKgFe1nC9+DbX+QaYom0raD/XFBKAw==
|
||||
dependencies:
|
||||
"@types/json-schema" "^7.0.15"
|
||||
p-queue "^9.0.1"
|
||||
p-retry "^7.1.1"
|
||||
uuid "^13.0.0"
|
||||
|
||||
"@langchain/langgraph@^1.1.4":
|
||||
version "1.1.4"
|
||||
resolved "https://registry.yarnpkg.com/@langchain/langgraph/-/langgraph-1.1.4.tgz#9eb2ca3bf03329a16d0bd8ae734fdd139d5862f8"
|
||||
integrity sha512-9OhRF+7Zvcpure8TLtBrxfJDo0PAoHZhfzcPL6M3CsGXiYqLWm5tQe+FYqn9zRIV7IwphqVEl1QDNbOkVgo+kw==
|
||||
"@langchain/langgraph@^1.1.5":
|
||||
version "1.1.5"
|
||||
resolved "https://registry.yarnpkg.com/@langchain/langgraph/-/langgraph-1.1.5.tgz#7cab6c585b5e60ac52e70ef941ba6054f45b0d88"
|
||||
integrity sha512-uJC/asydf/GoHpo9x42lf9hs8ufCkMuJ9sDle5ybP7sMD0XryOfE0E4J3deARk9ZadCCt6zeCoCNu/mTbx8+Sg==
|
||||
dependencies:
|
||||
"@langchain/langgraph-checkpoint" "^1.0.0"
|
||||
"@langchain/langgraph-sdk" "~1.6.0"
|
||||
"@langchain/langgraph-sdk" "~2.0.0"
|
||||
"@standard-schema/spec" "1.1.0"
|
||||
uuid "^10.0.0"
|
||||
|
||||
@@ -1079,101 +1079,101 @@
|
||||
dependencies:
|
||||
"@types/yargs-parser" "*"
|
||||
|
||||
"@typescript-eslint/eslint-plugin@^8.55.0":
|
||||
version "8.55.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.55.0.tgz#086d2ef661507b561f7b17f62d3179d692a0765f"
|
||||
integrity sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==
|
||||
"@typescript-eslint/eslint-plugin@^8.56.0":
|
||||
version "8.56.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz#5aec3db807a6b8437ea5d5ebf7bd16b4119aba8d"
|
||||
integrity sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==
|
||||
dependencies:
|
||||
"@eslint-community/regexpp" "^4.12.2"
|
||||
"@typescript-eslint/scope-manager" "8.55.0"
|
||||
"@typescript-eslint/type-utils" "8.55.0"
|
||||
"@typescript-eslint/utils" "8.55.0"
|
||||
"@typescript-eslint/visitor-keys" "8.55.0"
|
||||
"@typescript-eslint/scope-manager" "8.56.0"
|
||||
"@typescript-eslint/type-utils" "8.56.0"
|
||||
"@typescript-eslint/utils" "8.56.0"
|
||||
"@typescript-eslint/visitor-keys" "8.56.0"
|
||||
ignore "^7.0.5"
|
||||
natural-compare "^1.4.0"
|
||||
ts-api-utils "^2.4.0"
|
||||
|
||||
"@typescript-eslint/parser@^8.55.0":
|
||||
version "8.55.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.55.0.tgz#6eace4e9e95f178d3447ed1f17f3d6a5dfdb345c"
|
||||
integrity sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==
|
||||
"@typescript-eslint/parser@^8.56.0":
|
||||
version "8.56.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.56.0.tgz#8ecff1678b8b1a742d29c446ccf5eeea7f971d72"
|
||||
integrity sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==
|
||||
dependencies:
|
||||
"@typescript-eslint/scope-manager" "8.55.0"
|
||||
"@typescript-eslint/types" "8.55.0"
|
||||
"@typescript-eslint/typescript-estree" "8.55.0"
|
||||
"@typescript-eslint/visitor-keys" "8.55.0"
|
||||
"@typescript-eslint/scope-manager" "8.56.0"
|
||||
"@typescript-eslint/types" "8.56.0"
|
||||
"@typescript-eslint/typescript-estree" "8.56.0"
|
||||
"@typescript-eslint/visitor-keys" "8.56.0"
|
||||
debug "^4.4.3"
|
||||
|
||||
"@typescript-eslint/project-service@8.55.0":
|
||||
version "8.55.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.55.0.tgz#b8a71c06a625bdad481c24d5614b68e252f3ae9b"
|
||||
integrity sha512-zRcVVPFUYWa3kNnjaZGXSu3xkKV1zXy8M4nO/pElzQhFweb7PPtluDLQtKArEOGmjXoRjnUZ29NjOiF0eCDkcQ==
|
||||
"@typescript-eslint/project-service@8.56.0":
|
||||
version "8.56.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.56.0.tgz#bb8562fecd8f7922e676fc6a1189c20dd7991d73"
|
||||
integrity sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==
|
||||
dependencies:
|
||||
"@typescript-eslint/tsconfig-utils" "^8.55.0"
|
||||
"@typescript-eslint/types" "^8.55.0"
|
||||
"@typescript-eslint/tsconfig-utils" "^8.56.0"
|
||||
"@typescript-eslint/types" "^8.56.0"
|
||||
debug "^4.4.3"
|
||||
|
||||
"@typescript-eslint/scope-manager@8.55.0":
|
||||
version "8.55.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.55.0.tgz#8a0752c31c788651840dc98f840b0c2ebe143b8c"
|
||||
integrity sha512-fVu5Omrd3jeqeQLiB9f1YsuK/iHFOwb04bCtY4BSCLgjNbOD33ZdV6KyEqplHr+IlpgT0QTZ/iJ+wT7hvTx49Q==
|
||||
"@typescript-eslint/scope-manager@8.56.0":
|
||||
version "8.56.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.56.0.tgz#604030a4c6433df3728effdd441d47f45a86edb4"
|
||||
integrity sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.55.0"
|
||||
"@typescript-eslint/visitor-keys" "8.55.0"
|
||||
"@typescript-eslint/types" "8.56.0"
|
||||
"@typescript-eslint/visitor-keys" "8.56.0"
|
||||
|
||||
"@typescript-eslint/tsconfig-utils@8.55.0", "@typescript-eslint/tsconfig-utils@^8.55.0":
|
||||
version "8.55.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.55.0.tgz#62f1d005419985e09d37a040b2f1450e4e805afa"
|
||||
integrity sha512-1R9cXqY7RQd7WuqSN47PK9EDpgFUK3VqdmbYrvWJZYDd0cavROGn+74ktWBlmJ13NXUQKlZ/iAEQHI/V0kKe0Q==
|
||||
"@typescript-eslint/tsconfig-utils@8.56.0", "@typescript-eslint/tsconfig-utils@^8.56.0":
|
||||
version "8.56.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.0.tgz#2538ce83cbc376e685487960cbb24b65fe2abc4e"
|
||||
integrity sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==
|
||||
|
||||
"@typescript-eslint/type-utils@8.55.0":
|
||||
version "8.55.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.55.0.tgz#195d854b3e56308ce475fdea2165313bb1190200"
|
||||
integrity sha512-x1iH2unH4qAt6I37I2CGlsNs+B9WGxurP2uyZLRz6UJoZWDBx9cJL1xVN/FiOmHEONEg6RIufdvyT0TEYIgC5g==
|
||||
"@typescript-eslint/type-utils@8.56.0":
|
||||
version "8.56.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.56.0.tgz#72b4edc1fc73988998f1632b3ec99c2a66eaac6e"
|
||||
integrity sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.55.0"
|
||||
"@typescript-eslint/typescript-estree" "8.55.0"
|
||||
"@typescript-eslint/utils" "8.55.0"
|
||||
"@typescript-eslint/types" "8.56.0"
|
||||
"@typescript-eslint/typescript-estree" "8.56.0"
|
||||
"@typescript-eslint/utils" "8.56.0"
|
||||
debug "^4.4.3"
|
||||
ts-api-utils "^2.4.0"
|
||||
|
||||
"@typescript-eslint/types@8.55.0", "@typescript-eslint/types@^8.55.0":
|
||||
version "8.55.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.55.0.tgz#8449c5a7adac61184cac92dbf6315733569708c2"
|
||||
integrity sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w==
|
||||
"@typescript-eslint/types@8.56.0", "@typescript-eslint/types@^8.56.0":
|
||||
version "8.56.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.56.0.tgz#a2444011b9a98ca13d70411d2cbfed5443b3526a"
|
||||
integrity sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==
|
||||
|
||||
"@typescript-eslint/typescript-estree@8.55.0":
|
||||
version "8.55.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.55.0.tgz#c83ac92c11ce79bedd984937c7780a65e7f7b2e3"
|
||||
integrity sha512-EwrH67bSWdx/3aRQhCoxDaHM+CrZjotc2UCCpEDVqfCE+7OjKAGWNY2HsCSTEVvWH2clYQK8pdeLp42EVs+xQw==
|
||||
"@typescript-eslint/typescript-estree@8.56.0":
|
||||
version "8.56.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.0.tgz#fadbc74c14c5bac947db04980ff58bb178701c2e"
|
||||
integrity sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==
|
||||
dependencies:
|
||||
"@typescript-eslint/project-service" "8.55.0"
|
||||
"@typescript-eslint/tsconfig-utils" "8.55.0"
|
||||
"@typescript-eslint/types" "8.55.0"
|
||||
"@typescript-eslint/visitor-keys" "8.55.0"
|
||||
"@typescript-eslint/project-service" "8.56.0"
|
||||
"@typescript-eslint/tsconfig-utils" "8.56.0"
|
||||
"@typescript-eslint/types" "8.56.0"
|
||||
"@typescript-eslint/visitor-keys" "8.56.0"
|
||||
debug "^4.4.3"
|
||||
minimatch "^9.0.5"
|
||||
semver "^7.7.3"
|
||||
tinyglobby "^0.2.15"
|
||||
ts-api-utils "^2.4.0"
|
||||
|
||||
"@typescript-eslint/utils@8.55.0":
|
||||
version "8.55.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.55.0.tgz#c1744d94a3901deb01f58b09d3478d811f96d619"
|
||||
integrity sha512-BqZEsnPGdYpgyEIkDC1BadNY8oMwckftxBT+C8W0g1iKPdeqKZBtTfnvcq0nf60u7MkjFO8RBvpRGZBPw4L2ow==
|
||||
"@typescript-eslint/utils@8.56.0":
|
||||
version "8.56.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.56.0.tgz#063ce6f702ec603de1b83ee795ed5e877d6f7841"
|
||||
integrity sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==
|
||||
dependencies:
|
||||
"@eslint-community/eslint-utils" "^4.9.1"
|
||||
"@typescript-eslint/scope-manager" "8.55.0"
|
||||
"@typescript-eslint/types" "8.55.0"
|
||||
"@typescript-eslint/typescript-estree" "8.55.0"
|
||||
"@typescript-eslint/scope-manager" "8.56.0"
|
||||
"@typescript-eslint/types" "8.56.0"
|
||||
"@typescript-eslint/typescript-estree" "8.56.0"
|
||||
|
||||
"@typescript-eslint/visitor-keys@8.55.0":
|
||||
version "8.55.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.55.0.tgz#3d9a40fd4e3705c63d8fae3af58988add3ed464d"
|
||||
integrity sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==
|
||||
"@typescript-eslint/visitor-keys@8.56.0":
|
||||
version "8.56.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.0.tgz#7d6592ab001827d3ce052155edf7ecad19688d7d"
|
||||
integrity sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.55.0"
|
||||
eslint-visitor-keys "^4.2.1"
|
||||
"@typescript-eslint/types" "8.56.0"
|
||||
eslint-visitor-keys "^5.0.0"
|
||||
|
||||
"@ungap/structured-clone@^1.3.0":
|
||||
version "1.3.0"
|
||||
@@ -1287,10 +1287,10 @@ acorn@^8.12.0:
|
||||
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.12.1.tgz#71616bdccbe25e27a54439e0046e89ca76df2248"
|
||||
integrity sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==
|
||||
|
||||
acorn@^8.15.0:
|
||||
version "8.15.0"
|
||||
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.15.0.tgz#a360898bc415edaac46c8241f6383975b930b816"
|
||||
integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==
|
||||
acorn@^8.16.0:
|
||||
version "8.16.0"
|
||||
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.16.0.tgz#4ce79c89be40afe7afe8f3adb902a1f1ce9ac08a"
|
||||
integrity sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==
|
||||
|
||||
ajv@^6.12.4:
|
||||
version "6.12.6"
|
||||
@@ -1555,10 +1555,10 @@ brace-expansion@^1.1.7:
|
||||
balanced-match "^1.0.0"
|
||||
concat-map "0.0.1"
|
||||
|
||||
brace-expansion@^2.0.1:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.1.tgz#1edc459e0f0c548486ecf9fc99f2221364b9a0ae"
|
||||
integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==
|
||||
brace-expansion@^2.0.2:
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.2.tgz#54fc53237a613d854c7bd37463aad17df87214e7"
|
||||
integrity sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==
|
||||
dependencies:
|
||||
balanced-match "^1.0.0"
|
||||
|
||||
@@ -2226,10 +2226,10 @@ eslint-plugin-prettier@^5.5.5:
|
||||
prettier-linter-helpers "^1.0.1"
|
||||
synckit "^0.11.12"
|
||||
|
||||
eslint-scope@^9.1.0:
|
||||
version "9.1.0"
|
||||
resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-9.1.0.tgz#dfcb41d6c0d73df6b977a50cf3e91c41ddb4154e"
|
||||
integrity sha512-CkWE42hOJsNj9FJRaoMX9waUFYhqY4jmyLFdAdzZr6VaCg3ynLYx4WnOdkaIifGfH4gsUcBTn4OZbHXkpLD0FQ==
|
||||
eslint-scope@^9.1.1:
|
||||
version "9.1.1"
|
||||
resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-9.1.1.tgz#f6a209486e38bd28356b5feb07d445cc99c89967"
|
||||
integrity sha512-GaUN0sWim5qc8KVErfPBWmc31LEsOkrUJbvJZV+xuL3u2phMUK4HIvXlWAakfC8W4nzlK+chPEAkYOYb5ZScIw==
|
||||
dependencies:
|
||||
"@types/esrecurse" "^4.3.1"
|
||||
"@types/estree" "^1.0.8"
|
||||
@@ -2241,24 +2241,24 @@ eslint-visitor-keys@^3.4.3:
|
||||
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800"
|
||||
integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==
|
||||
|
||||
eslint-visitor-keys@^4.0.0, eslint-visitor-keys@^4.2.1:
|
||||
eslint-visitor-keys@^4.0.0:
|
||||
version "4.2.1"
|
||||
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz#4cfea60fe7dd0ad8e816e1ed026c1d5251b512c1"
|
||||
integrity sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==
|
||||
|
||||
eslint-visitor-keys@^5.0.0:
|
||||
version "5.0.0"
|
||||
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.0.tgz#b9aa1a74aa48c44b3ae46c1597ce7171246a94a9"
|
||||
integrity sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==
|
||||
eslint-visitor-keys@^5.0.0, eslint-visitor-keys@^5.0.1:
|
||||
version "5.0.1"
|
||||
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz#9e3c9489697824d2d4ce3a8ad12628f91e9f59be"
|
||||
integrity sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==
|
||||
|
||||
eslint@^10.0.0:
|
||||
version "10.0.0"
|
||||
resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.0.0.tgz#c93c36a96d91621d0fbb680db848ea11af56ab1e"
|
||||
integrity sha512-O0piBKY36YSJhlFSG8p9VUdPV/SxxS4FYDWVpr/9GJuMaepzwlf4J8I4ov1b+ySQfDTPhc3DtLaxcT1fN0yqCg==
|
||||
eslint@^10.0.1:
|
||||
version "10.0.1"
|
||||
resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.0.1.tgz#b5c5f7706782a21590ba6451e7a30d2947273c2d"
|
||||
integrity sha512-20MV9SUdeN6Jd84xESsKhRly+/vxI+hwvpBMA93s+9dAcjdCuCojn4IqUGS3lvVaqjVYGYHSRMCpeFtF2rQYxQ==
|
||||
dependencies:
|
||||
"@eslint-community/eslint-utils" "^4.8.0"
|
||||
"@eslint-community/regexpp" "^4.12.2"
|
||||
"@eslint/config-array" "^0.23.0"
|
||||
"@eslint/config-array" "^0.23.2"
|
||||
"@eslint/config-helpers" "^0.5.2"
|
||||
"@eslint/core" "^1.1.0"
|
||||
"@eslint/plugin-kit" "^0.6.0"
|
||||
@@ -2270,9 +2270,9 @@ eslint@^10.0.0:
|
||||
cross-spawn "^7.0.6"
|
||||
debug "^4.3.2"
|
||||
escape-string-regexp "^4.0.0"
|
||||
eslint-scope "^9.1.0"
|
||||
eslint-visitor-keys "^5.0.0"
|
||||
espree "^11.1.0"
|
||||
eslint-scope "^9.1.1"
|
||||
eslint-visitor-keys "^5.0.1"
|
||||
espree "^11.1.1"
|
||||
esquery "^1.7.0"
|
||||
esutils "^2.0.2"
|
||||
fast-deep-equal "^3.1.3"
|
||||
@@ -2283,7 +2283,7 @@ eslint@^10.0.0:
|
||||
imurmurhash "^0.1.4"
|
||||
is-glob "^4.0.0"
|
||||
json-stable-stringify-without-jsonify "^1.0.1"
|
||||
minimatch "^10.1.1"
|
||||
minimatch "^10.2.1"
|
||||
natural-compare "^1.4.0"
|
||||
optionator "^0.9.3"
|
||||
|
||||
@@ -2296,14 +2296,14 @@ espree@^10.0.1:
|
||||
acorn-jsx "^5.3.2"
|
||||
eslint-visitor-keys "^4.0.0"
|
||||
|
||||
espree@^11.1.0:
|
||||
version "11.1.0"
|
||||
resolved "https://registry.yarnpkg.com/espree/-/espree-11.1.0.tgz#7d0c82a69f8df670728dba256264b383fbf73e8f"
|
||||
integrity sha512-WFWYhO1fV4iYkqOOvq8FbqIhr2pYfoDY0kCotMkDeNtGpiGGkZ1iov2u8ydjtgM8yF8rzK7oaTbw2NAzbAbehw==
|
||||
espree@^11.1.1:
|
||||
version "11.1.1"
|
||||
resolved "https://registry.yarnpkg.com/espree/-/espree-11.1.1.tgz#866f6bc9ccccd6f28876b7a6463abb281b9cb847"
|
||||
integrity sha512-AVHPqQoZYc+RUM4/3Ly5udlZY/U4LS8pIG05jEjWM2lQMU/oaZ7qshzAl2YP1tfNmXfftH3ohurfwNAug+MnsQ==
|
||||
dependencies:
|
||||
acorn "^8.15.0"
|
||||
acorn "^8.16.0"
|
||||
acorn-jsx "^5.3.2"
|
||||
eslint-visitor-keys "^5.0.0"
|
||||
eslint-visitor-keys "^5.0.1"
|
||||
|
||||
esprima@^4.0.0:
|
||||
version "4.0.1"
|
||||
@@ -3533,9 +3533,9 @@ js-tokens@^4.0.0:
|
||||
integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
|
||||
|
||||
js-yaml@^3.13.1:
|
||||
version "3.14.1"
|
||||
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.1.tgz#dae812fdb3825fa306609a8717383c50c36a0537"
|
||||
integrity sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==
|
||||
version "3.14.2"
|
||||
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.2.tgz#77485ce1dd7f33c061fd1b16ecea23b55fcb04b0"
|
||||
integrity sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==
|
||||
dependencies:
|
||||
argparse "^1.0.7"
|
||||
esprima "^4.0.0"
|
||||
@@ -3699,26 +3699,26 @@ mimic-fn@^2.1.0:
|
||||
resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b"
|
||||
integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==
|
||||
|
||||
minimatch@^10.1.1:
|
||||
version "10.2.0"
|
||||
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.0.tgz#e710473e66e3e1aaf376d0aa82438375cac86e9e"
|
||||
integrity sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w==
|
||||
minimatch@^10.2.1:
|
||||
version "10.2.4"
|
||||
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.4.tgz#465b3accbd0218b8281f5301e27cedc697f96fde"
|
||||
integrity sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==
|
||||
dependencies:
|
||||
brace-expansion "^5.0.2"
|
||||
|
||||
minimatch@^3.0.4, minimatch@^3.1.1, minimatch@^3.1.2:
|
||||
version "3.1.2"
|
||||
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b"
|
||||
integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==
|
||||
version "3.1.5"
|
||||
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.5.tgz#580c88f8d5445f2bd6aa8f3cadefa0de79fbd69e"
|
||||
integrity sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==
|
||||
dependencies:
|
||||
brace-expansion "^1.1.7"
|
||||
|
||||
minimatch@^9.0.4, minimatch@^9.0.5:
|
||||
version "9.0.5"
|
||||
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5"
|
||||
integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==
|
||||
version "9.0.9"
|
||||
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.9.tgz#9b0cb9fcb78087f6fd7eababe2511c4d3d60574e"
|
||||
integrity sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==
|
||||
dependencies:
|
||||
brace-expansion "^2.0.1"
|
||||
brace-expansion "^2.0.2"
|
||||
|
||||
minimist@^1.2.0, minimist@^1.2.5, minimist@^1.2.6:
|
||||
version "1.2.8"
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@js-monorepo-example/shared": "*",
|
||||
"@langchain/core": "^1.1.24",
|
||||
"@langchain/langgraph": "^1.1.4"
|
||||
"@langchain/core": "^1.1.27",
|
||||
"@langchain/langgraph": "^1.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3"
|
||||
|
||||
@@ -17,12 +17,12 @@
|
||||
"lint": "eslint 'apps/**/*.ts' 'libs/**/*.ts'"
|
||||
},
|
||||
"devDependencies": {
|
||||
"turbo": "^2.8.9",
|
||||
"turbo": "^2.8.10",
|
||||
"typescript": "^5.9.3",
|
||||
"@tsconfig/recommended": "^1.0.13",
|
||||
"@eslint/eslintrc": "^3.3.3",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"eslint": "^10.0.0",
|
||||
"eslint": "^10.0.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-import": "^2.27.5",
|
||||
"eslint-plugin-no-instanceof": "^1.0.1",
|
||||
|
||||
@@ -19,14 +19,14 @@
|
||||
resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz#bccdf615bcf7b6e8db830ec0b8d21c9a25de597b"
|
||||
integrity sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==
|
||||
|
||||
"@eslint/config-array@^0.23.0":
|
||||
version "0.23.1"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.23.1.tgz#908223da7b9148f1af5bfb3144b77a9387a89446"
|
||||
integrity sha512-uVSdg/V4dfQmTjJzR0szNczjOH/J+FyUMMjYtr07xFRXR7EDf9i1qdxrD0VusZH9knj1/ecxzCQQxyic5NzAiA==
|
||||
"@eslint/config-array@^0.23.2":
|
||||
version "0.23.2"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.23.2.tgz#db85beeff7facc685a5775caacb1c845669b9470"
|
||||
integrity sha512-YF+fE6LV4v5MGWRGj7G404/OZzGNepVF8fxk7jqmqo3lrza7a0uUcDnROGRBG1WFC1omYUS/Wp1f42i0M+3Q3A==
|
||||
dependencies:
|
||||
"@eslint/object-schema" "^3.0.1"
|
||||
"@eslint/object-schema" "^3.0.2"
|
||||
debug "^4.3.1"
|
||||
minimatch "^10.1.1"
|
||||
minimatch "^10.2.1"
|
||||
|
||||
"@eslint/config-helpers@^0.5.2":
|
||||
version "0.5.2"
|
||||
@@ -62,10 +62,10 @@
|
||||
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-10.0.1.tgz#1e8a876f50117af8ab67e47d5ad94d38d6622583"
|
||||
integrity sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==
|
||||
|
||||
"@eslint/object-schema@^3.0.1":
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-3.0.1.tgz#9a1dc9af00d790dc79a9bf57a756e3cb2740ddb9"
|
||||
integrity sha512-P9cq2dpr+LU8j3qbLygLcSZrl2/ds/pUpfnHNNuk5HW7mnngHs+6WSq5C9mO3rqRX8A1poxqLTC9cu0KOyJlBg==
|
||||
"@eslint/object-schema@^3.0.2":
|
||||
version "3.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-3.0.2.tgz#c59c6a94aa4b428ed7f1615b6a4495c0a21f7a22"
|
||||
integrity sha512-HOy56KJt48Bx8KmJ+XGQNSUMT/6dZee/M54XyUyuvTvPXJmsERRvBchsUVx1UMe1WwIH49XLAczNC7V2INsuUw==
|
||||
|
||||
"@eslint/plugin-kit@^0.6.0":
|
||||
version "0.6.0"
|
||||
@@ -103,10 +103,10 @@
|
||||
resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-9.0.0.tgz#4d0a3f127058043bf2e7ee169eaf30ed901302f3"
|
||||
integrity sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==
|
||||
|
||||
"@langchain/core@^1.1.24":
|
||||
version "1.1.24"
|
||||
resolved "https://registry.yarnpkg.com/@langchain/core/-/core-1.1.24.tgz#714f5d953c3887104616386a404fa5d5d29e8d9b"
|
||||
integrity sha512-u6l0dmMHN/2PCsY6stXoh9CH1OTlVR5Gjz0JjT1XRPuidAlu3kTq4ivW95xCog/PRhiAsCh6GCEC4/PqhNrcgQ==
|
||||
"@langchain/core@^1.1.27":
|
||||
version "1.1.27"
|
||||
resolved "https://registry.yarnpkg.com/@langchain/core/-/core-1.1.27.tgz#b5a05c014eef2973006fd9e0df1e135b9da640e2"
|
||||
integrity sha512-YVtEz3nqCh8WxtdVXUICmt2BR2An+mn4YRJUBwcHX47Yrh2VwxpO0l97B2N/sNi658m65HnGyz2/hAjF3fzc1w==
|
||||
dependencies:
|
||||
"@cfworker/json-schema" "^4.0.2"
|
||||
ansi-styles "^5.0.0"
|
||||
@@ -126,23 +126,23 @@
|
||||
dependencies:
|
||||
uuid "^10.0.0"
|
||||
|
||||
"@langchain/langgraph-sdk@~1.6.0":
|
||||
version "1.6.2"
|
||||
resolved "https://registry.yarnpkg.com/@langchain/langgraph-sdk/-/langgraph-sdk-1.6.2.tgz#3b5d0707ddb844fd288cfacb81bb1b726ea7ee5f"
|
||||
integrity sha512-UzRZsnDqdTmeitf/K5yZnVdl+V+7bDj/hQUXm+Y8TwWUuKtWUDocIReKgAmPQLoIz0AN8bOUt0QGnIISmCZyuA==
|
||||
"@langchain/langgraph-sdk@~2.0.0":
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@langchain/langgraph-sdk/-/langgraph-sdk-2.0.0.tgz#55ac46373aa4917443d92c8b2edd5efd162bc879"
|
||||
integrity sha512-Xdkl1hve84ZGQ7fgpiBIBvjODhtjbPPccY4snOtYgSdzRXZkESsi2Y7RDKgFe1nC9+DbX+QaYom0raD/XFBKAw==
|
||||
dependencies:
|
||||
"@types/json-schema" "^7.0.15"
|
||||
p-queue "^9.0.1"
|
||||
p-retry "^7.1.1"
|
||||
uuid "^13.0.0"
|
||||
|
||||
"@langchain/langgraph@^1.1.4":
|
||||
version "1.1.4"
|
||||
resolved "https://registry.yarnpkg.com/@langchain/langgraph/-/langgraph-1.1.4.tgz#9eb2ca3bf03329a16d0bd8ae734fdd139d5862f8"
|
||||
integrity sha512-9OhRF+7Zvcpure8TLtBrxfJDo0PAoHZhfzcPL6M3CsGXiYqLWm5tQe+FYqn9zRIV7IwphqVEl1QDNbOkVgo+kw==
|
||||
"@langchain/langgraph@^1.1.5":
|
||||
version "1.1.5"
|
||||
resolved "https://registry.yarnpkg.com/@langchain/langgraph/-/langgraph-1.1.5.tgz#7cab6c585b5e60ac52e70ef941ba6054f45b0d88"
|
||||
integrity sha512-uJC/asydf/GoHpo9x42lf9hs8ufCkMuJ9sDle5ybP7sMD0XryOfE0E4J3deARk9ZadCCt6zeCoCNu/mTbx8+Sg==
|
||||
dependencies:
|
||||
"@langchain/langgraph-checkpoint" "^1.0.0"
|
||||
"@langchain/langgraph-sdk" "~1.6.0"
|
||||
"@langchain/langgraph-sdk" "~2.0.0"
|
||||
"@standard-schema/spec" "1.1.0"
|
||||
uuid "^10.0.0"
|
||||
|
||||
@@ -297,6 +297,11 @@ acorn@^8.15.0:
|
||||
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.15.0.tgz#a360898bc415edaac46c8241f6383975b930b816"
|
||||
integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==
|
||||
|
||||
acorn@^8.16.0:
|
||||
version "8.16.0"
|
||||
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.16.0.tgz#4ce79c89be40afe7afe8f3adb902a1f1ce9ac08a"
|
||||
integrity sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==
|
||||
|
||||
ajv@^6.12.4:
|
||||
version "6.12.6"
|
||||
resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4"
|
||||
@@ -429,7 +434,7 @@ brace-expansion@^1.1.7:
|
||||
balanced-match "^1.0.0"
|
||||
concat-map "0.0.1"
|
||||
|
||||
brace-expansion@^2.0.1:
|
||||
brace-expansion@^2.0.2:
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.2.tgz#54fc53237a613d854c7bd37463aad17df87214e7"
|
||||
integrity sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==
|
||||
@@ -772,10 +777,10 @@ eslint-plugin-prettier@^5.5.5:
|
||||
prettier-linter-helpers "^1.0.1"
|
||||
synckit "^0.11.12"
|
||||
|
||||
eslint-scope@^9.1.0:
|
||||
version "9.1.0"
|
||||
resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-9.1.0.tgz#dfcb41d6c0d73df6b977a50cf3e91c41ddb4154e"
|
||||
integrity sha512-CkWE42hOJsNj9FJRaoMX9waUFYhqY4jmyLFdAdzZr6VaCg3ynLYx4WnOdkaIifGfH4gsUcBTn4OZbHXkpLD0FQ==
|
||||
eslint-scope@^9.1.1:
|
||||
version "9.1.1"
|
||||
resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-9.1.1.tgz#f6a209486e38bd28356b5feb07d445cc99c89967"
|
||||
integrity sha512-GaUN0sWim5qc8KVErfPBWmc31LEsOkrUJbvJZV+xuL3u2phMUK4HIvXlWAakfC8W4nzlK+chPEAkYOYb5ZScIw==
|
||||
dependencies:
|
||||
"@types/esrecurse" "^4.3.1"
|
||||
"@types/estree" "^1.0.8"
|
||||
@@ -792,19 +797,19 @@ eslint-visitor-keys@^4.2.1:
|
||||
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz#4cfea60fe7dd0ad8e816e1ed026c1d5251b512c1"
|
||||
integrity sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==
|
||||
|
||||
eslint-visitor-keys@^5.0.0:
|
||||
version "5.0.0"
|
||||
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.0.tgz#b9aa1a74aa48c44b3ae46c1597ce7171246a94a9"
|
||||
integrity sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==
|
||||
eslint-visitor-keys@^5.0.0, eslint-visitor-keys@^5.0.1:
|
||||
version "5.0.1"
|
||||
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz#9e3c9489697824d2d4ce3a8ad12628f91e9f59be"
|
||||
integrity sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==
|
||||
|
||||
eslint@^10.0.0:
|
||||
version "10.0.0"
|
||||
resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.0.0.tgz#c93c36a96d91621d0fbb680db848ea11af56ab1e"
|
||||
integrity sha512-O0piBKY36YSJhlFSG8p9VUdPV/SxxS4FYDWVpr/9GJuMaepzwlf4J8I4ov1b+ySQfDTPhc3DtLaxcT1fN0yqCg==
|
||||
eslint@^10.0.1:
|
||||
version "10.0.1"
|
||||
resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.0.1.tgz#b5c5f7706782a21590ba6451e7a30d2947273c2d"
|
||||
integrity sha512-20MV9SUdeN6Jd84xESsKhRly+/vxI+hwvpBMA93s+9dAcjdCuCojn4IqUGS3lvVaqjVYGYHSRMCpeFtF2rQYxQ==
|
||||
dependencies:
|
||||
"@eslint-community/eslint-utils" "^4.8.0"
|
||||
"@eslint-community/regexpp" "^4.12.2"
|
||||
"@eslint/config-array" "^0.23.0"
|
||||
"@eslint/config-array" "^0.23.2"
|
||||
"@eslint/config-helpers" "^0.5.2"
|
||||
"@eslint/core" "^1.1.0"
|
||||
"@eslint/plugin-kit" "^0.6.0"
|
||||
@@ -816,9 +821,9 @@ eslint@^10.0.0:
|
||||
cross-spawn "^7.0.6"
|
||||
debug "^4.3.2"
|
||||
escape-string-regexp "^4.0.0"
|
||||
eslint-scope "^9.1.0"
|
||||
eslint-visitor-keys "^5.0.0"
|
||||
espree "^11.1.0"
|
||||
eslint-scope "^9.1.1"
|
||||
eslint-visitor-keys "^5.0.1"
|
||||
espree "^11.1.1"
|
||||
esquery "^1.7.0"
|
||||
esutils "^2.0.2"
|
||||
fast-deep-equal "^3.1.3"
|
||||
@@ -829,7 +834,7 @@ eslint@^10.0.0:
|
||||
imurmurhash "^0.1.4"
|
||||
is-glob "^4.0.0"
|
||||
json-stable-stringify-without-jsonify "^1.0.1"
|
||||
minimatch "^10.1.1"
|
||||
minimatch "^10.2.1"
|
||||
natural-compare "^1.4.0"
|
||||
optionator "^0.9.3"
|
||||
|
||||
@@ -842,14 +847,14 @@ espree@^10.0.1:
|
||||
acorn-jsx "^5.3.2"
|
||||
eslint-visitor-keys "^4.2.1"
|
||||
|
||||
espree@^11.1.0:
|
||||
version "11.1.0"
|
||||
resolved "https://registry.yarnpkg.com/espree/-/espree-11.1.0.tgz#7d0c82a69f8df670728dba256264b383fbf73e8f"
|
||||
integrity sha512-WFWYhO1fV4iYkqOOvq8FbqIhr2pYfoDY0kCotMkDeNtGpiGGkZ1iov2u8ydjtgM8yF8rzK7oaTbw2NAzbAbehw==
|
||||
espree@^11.1.1:
|
||||
version "11.1.1"
|
||||
resolved "https://registry.yarnpkg.com/espree/-/espree-11.1.1.tgz#866f6bc9ccccd6f28876b7a6463abb281b9cb847"
|
||||
integrity sha512-AVHPqQoZYc+RUM4/3Ly5udlZY/U4LS8pIG05jEjWM2lQMU/oaZ7qshzAl2YP1tfNmXfftH3ohurfwNAug+MnsQ==
|
||||
dependencies:
|
||||
acorn "^8.15.0"
|
||||
acorn "^8.16.0"
|
||||
acorn-jsx "^5.3.2"
|
||||
eslint-visitor-keys "^5.0.0"
|
||||
eslint-visitor-keys "^5.0.1"
|
||||
|
||||
esquery@^1.7.0:
|
||||
version "1.7.0"
|
||||
@@ -1374,26 +1379,26 @@ math-intrinsics@^1.1.0:
|
||||
resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9"
|
||||
integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==
|
||||
|
||||
minimatch@^10.1.1:
|
||||
version "10.2.1"
|
||||
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.1.tgz#9d82835834cdc85d5084dd055e9a4685fa56e5f0"
|
||||
integrity sha512-MClCe8IL5nRRmawL6ib/eT4oLyeKMGCghibcDWK+J0hh0Q8kqSdia6BvbRMVk6mPa6WqUa5uR2oxt6C5jd533A==
|
||||
minimatch@^10.2.1:
|
||||
version "10.2.4"
|
||||
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.4.tgz#465b3accbd0218b8281f5301e27cedc697f96fde"
|
||||
integrity sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==
|
||||
dependencies:
|
||||
brace-expansion "^5.0.2"
|
||||
|
||||
minimatch@^3.1.2:
|
||||
version "3.1.2"
|
||||
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b"
|
||||
integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==
|
||||
version "3.1.5"
|
||||
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.5.tgz#580c88f8d5445f2bd6aa8f3cadefa0de79fbd69e"
|
||||
integrity sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==
|
||||
dependencies:
|
||||
brace-expansion "^1.1.7"
|
||||
|
||||
minimatch@^9.0.5:
|
||||
version "9.0.5"
|
||||
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5"
|
||||
integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==
|
||||
version "9.0.9"
|
||||
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.9.tgz#9b0cb9fcb78087f6fd7eababe2511c4d3d60574e"
|
||||
integrity sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==
|
||||
dependencies:
|
||||
brace-expansion "^2.0.1"
|
||||
brace-expansion "^2.0.2"
|
||||
|
||||
minimist@^1.2.0, minimist@^1.2.6:
|
||||
version "1.2.8"
|
||||
@@ -1858,47 +1863,47 @@ tsconfig-paths@^3.15.0:
|
||||
minimist "^1.2.6"
|
||||
strip-bom "^3.0.0"
|
||||
|
||||
turbo-darwin-64@2.8.9:
|
||||
version "2.8.9"
|
||||
resolved "https://registry.yarnpkg.com/turbo-darwin-64/-/turbo-darwin-64-2.8.9.tgz#c99b425437c8b3b49eb4ddf7ca44674dd6d4e940"
|
||||
integrity sha512-KnCw1ZI9KTnEAhdI9avZrnZ/z4wsM++flMA1w8s8PKOqi5daGpFV36qoPafg4S8TmYMe52JPWEoFr0L+lQ5JIw==
|
||||
turbo-darwin-64@2.8.10:
|
||||
version "2.8.10"
|
||||
resolved "https://registry.yarnpkg.com/turbo-darwin-64/-/turbo-darwin-64-2.8.10.tgz#38ba73b9bbb3459cd27c6d3b5777bae80daa7e9f"
|
||||
integrity sha512-A03fXh+B7S8mL3PbdhTd+0UsaGrhfyPkODvzBDpKRY7bbeac4MDFpJ7I+Slf2oSkCEeSvHKR7Z4U71uKRUfX7g==
|
||||
|
||||
turbo-darwin-arm64@2.8.9:
|
||||
version "2.8.9"
|
||||
resolved "https://registry.yarnpkg.com/turbo-darwin-arm64/-/turbo-darwin-arm64-2.8.9.tgz#01a424a14956186bdd3a29a825f87c303ecbfda1"
|
||||
integrity sha512-CbD5Y2NKJKBXTOZ7z7Cc7vGlFPZkYjApA7ri9lH4iFwKV1X7MoZswh9gyRLetXYWImVX1BqIvP8KftulJg/wIA==
|
||||
turbo-darwin-arm64@2.8.10:
|
||||
version "2.8.10"
|
||||
resolved "https://registry.yarnpkg.com/turbo-darwin-arm64/-/turbo-darwin-arm64-2.8.10.tgz#1c7a278a6361aef0a4e94849bf7181ff3c91d782"
|
||||
integrity sha512-sidzowgWL3s5xCHLeqwC9M3s9M0i16W1nuQF3Mc7fPHpZ+YPohvcbVFBB2uoRRHYZg6yBnwD4gyUHKTeXfwtXA==
|
||||
|
||||
turbo-linux-64@2.8.9:
|
||||
version "2.8.9"
|
||||
resolved "https://registry.yarnpkg.com/turbo-linux-64/-/turbo-linux-64-2.8.9.tgz#f8e7f4bf82a2f94a3d8bcd042642db4990cfd3bd"
|
||||
integrity sha512-OXC9HdCtsHvyH+5KUoH8ds+p5WU13vdif0OPbsFzZca4cUXMwKA3HWwUuCgQetk0iAE4cscXpi/t8A263n3VTg==
|
||||
turbo-linux-64@2.8.10:
|
||||
version "2.8.10"
|
||||
resolved "https://registry.yarnpkg.com/turbo-linux-64/-/turbo-linux-64-2.8.10.tgz#aaa6ede45619daab2be359ec6afc3b0a73941272"
|
||||
integrity sha512-YK9vcpL3TVtqonB021XwgaQhY9hJJbKKUhLv16osxV0HkcQASQWUqR56yMge7puh6nxU67rQlTq1b7ksR1T3KA==
|
||||
|
||||
turbo-linux-arm64@2.8.9:
|
||||
version "2.8.9"
|
||||
resolved "https://registry.yarnpkg.com/turbo-linux-arm64/-/turbo-linux-arm64-2.8.9.tgz#7e7cd446c518dd4e19aa87f71c3234124449681d"
|
||||
integrity sha512-yI5n8jNXiFA6+CxnXG0gO7h5ZF1+19K8uO3/kXPQmyl37AdiA7ehKJQOvf9OPAnmkGDHcF2HSCPltabERNRmug==
|
||||
turbo-linux-arm64@2.8.10:
|
||||
version "2.8.10"
|
||||
resolved "https://registry.yarnpkg.com/turbo-linux-arm64/-/turbo-linux-arm64-2.8.10.tgz#a9a52e4eca69968d85f09adb24ba3aec24a50023"
|
||||
integrity sha512-3+j2tL0sG95iBJTm+6J8/45JsETQABPqtFyYjVjBbi6eVGdtNTiBmHNKrbvXRlQ3ZbUG75bKLaSSDHSEEN+btQ==
|
||||
|
||||
turbo-windows-64@2.8.9:
|
||||
version "2.8.9"
|
||||
resolved "https://registry.yarnpkg.com/turbo-windows-64/-/turbo-windows-64-2.8.9.tgz#71da356ba3a9585ddbf3eb6f85a92239454aa9bd"
|
||||
integrity sha512-/OztzeGftJAg258M/9vK2ZCkUKUzqrWXJIikiD2pm8TlqHcIYUmepDbyZSDfOiUjMy6NzrLFahpNLnY7b5vNgg==
|
||||
turbo-windows-64@2.8.10:
|
||||
version "2.8.10"
|
||||
resolved "https://registry.yarnpkg.com/turbo-windows-64/-/turbo-windows-64-2.8.10.tgz#b0f64a29451477c1ecdc7dbe0555b8e75c464044"
|
||||
integrity sha512-hdeF5qmVY/NFgiucf8FW0CWJWtyT2QPm5mIsX0W1DXAVzqKVXGq+Zf+dg4EUngAFKjDzoBeN6ec2Fhajwfztkw==
|
||||
|
||||
turbo-windows-arm64@2.8.9:
|
||||
version "2.8.9"
|
||||
resolved "https://registry.yarnpkg.com/turbo-windows-arm64/-/turbo-windows-arm64-2.8.9.tgz#b1c223c1f2c900292246074c3c2ebc9c7db57684"
|
||||
integrity sha512-xZ2VTwVTjIqpFZKN4UBxDHCPM3oJ2J5cpRzCBSmRpJ/Pn33wpiYjs+9FB2E03svKaD04/lSSLlEUej0UYsugfg==
|
||||
turbo-windows-arm64@2.8.10:
|
||||
version "2.8.10"
|
||||
resolved "https://registry.yarnpkg.com/turbo-windows-arm64/-/turbo-windows-arm64-2.8.10.tgz#8d178389a995f98142b7ce8f76e1fce8a4b7c79d"
|
||||
integrity sha512-QGdr/Q8LWmj+ITMkSvfiz2glf0d7JG0oXVzGL3jxkGqiBI1zXFj20oqVY0qWi+112LO9SVrYdpHS0E/oGFrMbQ==
|
||||
|
||||
turbo@^2.8.9:
|
||||
version "2.8.9"
|
||||
resolved "https://registry.yarnpkg.com/turbo/-/turbo-2.8.9.tgz#15d8468f9f1725381a9bab58ccdb12c5614de5bc"
|
||||
integrity sha512-G+Mq8VVQAlpz/0HTsxiNNk/xywaHGl+dk1oiBREgOEVCCDjXInDlONWUn5srRnC9s5tdHTFD1bx1N19eR4hI+g==
|
||||
turbo@^2.8.10:
|
||||
version "2.8.10"
|
||||
resolved "https://registry.yarnpkg.com/turbo/-/turbo-2.8.10.tgz#4ead3ef7c2fd80f9fe367f9a8b88b8a819fb5065"
|
||||
integrity sha512-OxbzDES66+x7nnKGg2MwBA1ypVsZoDTLHpeaP4giyiHSixbsiTaMyeJqbEyvBdp5Cm28fc+8GG6RdQtic0ijwQ==
|
||||
optionalDependencies:
|
||||
turbo-darwin-64 "2.8.9"
|
||||
turbo-darwin-arm64 "2.8.9"
|
||||
turbo-linux-64 "2.8.9"
|
||||
turbo-linux-arm64 "2.8.9"
|
||||
turbo-windows-64 "2.8.9"
|
||||
turbo-windows-arm64 "2.8.9"
|
||||
turbo-darwin-64 "2.8.10"
|
||||
turbo-darwin-arm64 "2.8.10"
|
||||
turbo-linux-64 "2.8.10"
|
||||
turbo-linux-arm64 "2.8.10"
|
||||
turbo-windows-64 "2.8.10"
|
||||
turbo-windows-arm64 "2.8.10"
|
||||
|
||||
type-check@^0.4.0, type-check@~0.4.0:
|
||||
version "0.4.0"
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.4.13"
|
||||
__version__ = "0.4.14"
|
||||
|
||||
@@ -765,6 +765,8 @@ def dev(
|
||||
allow_blocking=allow_blocking,
|
||||
tunnel=tunnel,
|
||||
server_level=server_log_level,
|
||||
checkpointer=config_json.get("checkpointer"),
|
||||
disable_persistence=config_json.get("disable_persistence", False),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -678,6 +678,51 @@ def _update_encryption_path(
|
||||
)
|
||||
|
||||
|
||||
def _update_checkpointer_path(
|
||||
config_path: pathlib.Path, config: Config, local_deps: LocalDeps
|
||||
) -> None:
|
||||
"""Update checkpointer.path to use Docker container paths."""
|
||||
checkpointer_conf = config.get("checkpointer")
|
||||
if not checkpointer_conf or not isinstance(checkpointer_conf, dict):
|
||||
return
|
||||
if not (path_str := checkpointer_conf.get("path")):
|
||||
return
|
||||
|
||||
module_str, sep, attr_str = path_str.partition(":")
|
||||
if not sep or not module_str.startswith("."):
|
||||
return # Already validated or absolute path
|
||||
|
||||
resolved = config_path.parent / module_str
|
||||
if not resolved.exists():
|
||||
raise FileNotFoundError(
|
||||
f"Checkpointer file not found: {resolved} (from {path_str})"
|
||||
)
|
||||
if not resolved.is_file():
|
||||
raise IsADirectoryError(f"Checkpointer path must be a file: {resolved}")
|
||||
|
||||
# Check faux packages first (higher priority)
|
||||
for faux_path, (_, destpath) in local_deps.faux_pkgs.items():
|
||||
if resolved.is_relative_to(faux_path):
|
||||
new_path = f"{destpath}/{resolved.relative_to(faux_path)}:{attr_str}"
|
||||
checkpointer_conf["path"] = new_path
|
||||
return
|
||||
|
||||
# Check real packages
|
||||
for real_path in local_deps.real_pkgs:
|
||||
if resolved.is_relative_to(real_path):
|
||||
new_path = (
|
||||
f"/deps/{real_path.name}/{resolved.relative_to(real_path)}:{attr_str}"
|
||||
)
|
||||
checkpointer_conf["path"] = new_path
|
||||
return
|
||||
|
||||
raise ValueError(
|
||||
f"Checkpointer file '{resolved}' not covered by dependencies.\n"
|
||||
"Add its parent directory to the 'dependencies' array in your config.\n"
|
||||
f"Current dependencies: {config['dependencies']}"
|
||||
)
|
||||
|
||||
|
||||
def _update_http_app_path(
|
||||
config_path: pathlib.Path, config: Config, local_deps: LocalDeps
|
||||
) -> None:
|
||||
@@ -877,6 +922,8 @@ def python_config_to_docker(
|
||||
_update_auth_path(config_path, config, local_deps)
|
||||
# Rewrite encryption path, so it points to the correct location in the Docker container
|
||||
_update_encryption_path(config_path, config, local_deps)
|
||||
# Rewrite checkpointer path, so it points to the correct location in the Docker container
|
||||
_update_checkpointer_path(config_path, config, local_deps)
|
||||
# Rewrite HTTP app path, so it points to the correct location in the Docker container
|
||||
_update_http_app_path(config_path, config, local_deps)
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ class SerdeConfig(TypedDict, total=False):
|
||||
If omitted, no serde is set up (the object store will still be present, however)."""
|
||||
|
||||
allowed_json_modules: list[list[str]] | bool | None
|
||||
"""Optional. List of allowed python modules to de-serialize custom objects from.
|
||||
"""Optional. List of allowed python modules to de-serialize custom objects from JSON.
|
||||
|
||||
If provided, only the specified modules will be allowed to be deserialized.
|
||||
If omitted, no modules are allowed, and the object returned will simply be a json object OR
|
||||
@@ -148,7 +148,34 @@ class SerdeConfig(TypedDict, total=False):
|
||||
Example:
|
||||
{...
|
||||
"serde": {
|
||||
"allowed_json_modules": true
|
||||
"allowed_json_modules": True
|
||||
}
|
||||
}
|
||||
|
||||
"""
|
||||
allowed_msgpack_modules: list[list[str]] | bool | None
|
||||
"""Optional. List of allowed python modules to de-serialize custom objects from msgpack.
|
||||
|
||||
Known safe types (langgraph.checkpoint.serde.jsonplus.SAFE_MSGPACK_TYPES) are always
|
||||
allowed regardless of this setting. Use this to allowlist your custom Pydantic models,
|
||||
dataclasses, and other user-defined types.
|
||||
|
||||
If True (default), unregistered types will log a warning but still be deserialized.
|
||||
If None, only known safe types will be deserialized; unregistered types will be blocked.
|
||||
|
||||
Example - allowlist specific types (no warnings for these):
|
||||
{...
|
||||
"serde": {
|
||||
"allowed_msgpack_modules": [
|
||||
["my_agent.models", "MyState"],
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Example - strict mode (only safe types allowed):
|
||||
{...
|
||||
"serde": {
|
||||
"allowed_msgpack_modules": null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,6 +194,26 @@ class CheckpointerConfig(TypedDict, total=False):
|
||||
If omitted, no checkpointer is set up (the object store will still be present, however).
|
||||
"""
|
||||
|
||||
path: str
|
||||
"""Import path to an async context manager that yields a `BaseCheckpointSaver`
|
||||
instance.
|
||||
|
||||
The referenced object should be an `@asynccontextmanager`-decorated function
|
||||
so that the server can properly manage the checkpointer's lifecycle (e.g.
|
||||
opening and closing connections).
|
||||
|
||||
Examples:
|
||||
- "./my_checkpointer.py:create_checkpointer"
|
||||
- "my_package.checkpointer:create_checkpointer"
|
||||
|
||||
When provided, this replaces the default checkpointer.
|
||||
|
||||
You can use the `langgraph-checkpoint-conformance` package
|
||||
(https://pypi.org/project/langgraph-checkpoint-conformance/) to run simple
|
||||
conformance tests against your custom checkpointer and catch
|
||||
incompatibilities early.
|
||||
"""
|
||||
|
||||
ttl: ThreadTTLConfig | None
|
||||
"""Optional. Defines the TTL (time-to-live) behavior configuration.
|
||||
|
||||
@@ -308,8 +355,7 @@ class EncryptionConfig(TypedDict, total=False):
|
||||
"""Configuration for custom at-rest encryption logic.
|
||||
|
||||
Allows you to implement custom encryption for sensitive data stored in the database,
|
||||
including metadata fields and checkpoint blobs.
|
||||
"""
|
||||
including metadata fields and checkpoint blobs."""
|
||||
|
||||
path: str
|
||||
"""Required. Path to an instance of the Encryption() class that implements custom encryption handlers.
|
||||
@@ -509,6 +555,21 @@ class WebhookUrlPolicy(TypedDict, total=False):
|
||||
"""Disallow relative URLs (internal loopback calls) when true."""
|
||||
|
||||
|
||||
class GraphDef(TypedDict, total=False):
|
||||
"""Definition of a graph with additional metadata."""
|
||||
|
||||
path: str
|
||||
"""Required. Import path to the graph object.
|
||||
|
||||
Format: "path/to/file.py:object_name"
|
||||
"""
|
||||
description: str | None
|
||||
"""Optional. A description of the graph's purpose and functionality.
|
||||
|
||||
This description is surfaced in the API and can help users understand what the graph does.
|
||||
"""
|
||||
|
||||
|
||||
class WebhooksConfig(TypedDict, total=False):
|
||||
env_prefix: str
|
||||
"""Required prefix for environment variables referenced in header templates.
|
||||
@@ -599,19 +660,23 @@ class Config(TypedDict, total=False):
|
||||
Defaults to an empty list, meaning no additional packages installed beyond your base environment.
|
||||
"""
|
||||
|
||||
graphs: dict[str, str]
|
||||
graphs: dict[str, str | GraphDef]
|
||||
"""Optional. Named definitions of graphs, each pointing to a Python object.
|
||||
|
||||
|
||||
|
||||
Graphs can be StateGraph, @entrypoint, or any other Pregel object OR they can point to (async) context
|
||||
managers that accept a single configuration argument (of type RunnableConfig) and return a pregel object
|
||||
(instance of Stategraph, etc.).
|
||||
|
||||
Keys are graph names, values are "path/to/file.py:object_name".
|
||||
|
||||
Keys are graph names, values are either "path/to/file.py:object_name" strings
|
||||
or objects with a "path" key and optional "description" key.
|
||||
Example:
|
||||
{
|
||||
"mygraph": "graphs/my_graph.py:graph_definition",
|
||||
"anothergraph": "graphs/another.py:get_graph"
|
||||
"anothergraph": {
|
||||
"path": "graphs/another.py:get_graph",
|
||||
"description": "A graph that does X"
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
@@ -676,6 +741,7 @@ class Config(TypedDict, total=False):
|
||||
|
||||
__all__ = [
|
||||
"Config",
|
||||
"GraphDef",
|
||||
"StoreConfig",
|
||||
"CheckpointerConfig",
|
||||
"AuthConfig",
|
||||
|
||||
+15
-8
@@ -1,6 +1,6 @@
|
||||
[build-system]
|
||||
requires = ["maturin>=1.0,<2.0"]
|
||||
build-backend = "maturin"
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "langgraph-cli"
|
||||
@@ -11,8 +11,12 @@ requires-python = ">=3.10"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
license-files = ['LICENSE']
|
||||
dependencies = []
|
||||
|
||||
dependencies = [
|
||||
"click>=8.1.7",
|
||||
"langgraph-sdk>=0.1.0 ; python_version >= '3.11'",
|
||||
]
|
||||
[tool.hatch.version]
|
||||
path = "langgraph_cli/__init__.py"
|
||||
[project.optional-dependencies]
|
||||
inmem = [
|
||||
"langgraph-api>=0.5.35,<0.8.0 ; python_version >= '3.11'",
|
||||
@@ -26,10 +30,8 @@ Twitter = "https://x.com/LangChain"
|
||||
Slack = "https://www.langchain.com/join-community"
|
||||
Reddit = "https://www.reddit.com/r/LangChain/"
|
||||
|
||||
[tool.maturin]
|
||||
bindings = "bin"
|
||||
manifest-path = "Cargo.toml"
|
||||
strip = true
|
||||
[project.scripts]
|
||||
langgraph = "langgraph_cli.cli:cli"
|
||||
|
||||
[dependency-groups]
|
||||
test = [
|
||||
@@ -47,11 +49,15 @@ lint = [
|
||||
dev = [
|
||||
{include-group = "test"},
|
||||
{include-group = "lint"},
|
||||
"hatch>=1.16.2",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
default-groups = ['dev']
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
include = ["langgraph_cli"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
addopts = "--strict-markers --strict-config --durations=5 -vv"
|
||||
asyncio_mode = "auto"
|
||||
@@ -63,6 +69,7 @@ lint.select = [
|
||||
"UP", # pyupgrade
|
||||
"B", # flake8-bugbear
|
||||
"I", # isort
|
||||
"UP", # pyupgrade
|
||||
]
|
||||
lint.ignore = ["E501", "B008"]
|
||||
target-version = "py310"
|
||||
|
||||
@@ -127,9 +127,16 @@
|
||||
"graphs": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/GraphDef"
|
||||
}
|
||||
]
|
||||
},
|
||||
"description": "Optional. Named definitions of graphs, each pointing to a Python object.\n\n\nGraphs can be StateGraph, @entrypoint, or any other Pregel object OR they can point to (async) context\nmanagers that accept a single configuration argument (of type RunnableConfig) and return a pregel object\n(instance of Stategraph, etc.).\n"
|
||||
"description": "Optional. Named definitions of graphs, each pointing to a Python object.\n\n\nGraphs can be StateGraph, @entrypoint, or any other Pregel object OR they can point to (async) context\nmanagers that accept a single configuration argument (of type RunnableConfig) and return a pregel object\n(instance of Stategraph, etc.).\n\nor objects with a \"path\" key and optional \"description\" key."
|
||||
},
|
||||
"http": {
|
||||
"anyOf": [
|
||||
@@ -341,9 +348,16 @@
|
||||
"graphs": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/GraphDef"
|
||||
}
|
||||
]
|
||||
},
|
||||
"description": "Optional. Named definitions of graphs, each pointing to a Python object.\n\n\nGraphs can be StateGraph, @entrypoint, or any other Pregel object OR they can point to (async) context\nmanagers that accept a single configuration argument (of type RunnableConfig) and return a pregel object\n(instance of Stategraph, etc.).\n"
|
||||
"description": "Optional. Named definitions of graphs, each pointing to a Python object.\n\n\nGraphs can be StateGraph, @entrypoint, or any other Pregel object OR they can point to (async) context\nmanagers that accept a single configuration argument (of type RunnableConfig) and return a pregel object\n(instance of Stategraph, etc.).\n\nor objects with a \"path\" key and optional \"description\" key."
|
||||
},
|
||||
"http": {
|
||||
"anyOf": [
|
||||
@@ -542,6 +556,10 @@
|
||||
"description": "Configuration for the built-in checkpointer, which handles checkpointing of state.\n\nIf omitted, no checkpointer is set up (the object store will still be present, however).",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Import path to an async context manager that yields a `BaseCheckpointSaver`\ninstance.\n\nThe referenced object should be an `@asynccontextmanager`-decorated function\nso that the server can properly manage the checkpointer's lifecycle (e.g.\nopening and closing connections).\n"
|
||||
},
|
||||
"serde": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -590,7 +608,27 @@
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional. List of allowed python modules to de-serialize custom objects from.\n\nIf provided, only the specified modules will be allowed to be deserialized.\nIf omitted, no modules are allowed, and the object returned will simply be a json object OR\na deserialized langchain object.\n"
|
||||
"description": "Optional. List of allowed python modules to de-serialize custom objects from JSON.\n\nIf provided, only the specified modules will be allowed to be deserialized.\nIf omitted, no modules are allowed, and the object returned will simply be a json object OR\na deserialized langchain object.\n"
|
||||
},
|
||||
"allowed_msgpack_modules": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional. List of allowed python modules to de-serialize custom objects from msgpack.\n\nKnown safe types (langgraph.checkpoint.serde.jsonplus.SAFE_MSGPACK_TYPES) are always\nallowed regardless of this setting. Use this to allowlist your custom Pydantic models,\ndataclasses, and other user-defined types.\n\nIf True (default), unregistered types will log a warning but still be deserialized.\nIf None, only known safe types will be deserialized; unregistered types will be blocked.\n\n{...\n[\"my_agent.models\", \"MyState\"],\n]\n}\n}\n\n{...\n}\n}\n\n"
|
||||
},
|
||||
"pickle_fallback": {
|
||||
"type": "boolean",
|
||||
@@ -658,6 +696,29 @@
|
||||
},
|
||||
"required": []
|
||||
},
|
||||
"GraphDef": {
|
||||
"title": "GraphDef",
|
||||
"description": "Definition of a graph with additional metadata.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"description": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional. A description of the graph's purpose and functionality.\n\nThis description is surfaced in the API and can help users understand what the graph does.\n"
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Required. Import path to the graph object.\n"
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
},
|
||||
"HttpConfig": {
|
||||
"title": "HttpConfig",
|
||||
"description": "Configuration for the built-in HTTP server that powers your deployment's routes and endpoints.",
|
||||
|
||||
@@ -127,9 +127,16 @@
|
||||
"graphs": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/GraphDef"
|
||||
}
|
||||
]
|
||||
},
|
||||
"description": "Optional. Named definitions of graphs, each pointing to a Python object.\n\n\nGraphs can be StateGraph, @entrypoint, or any other Pregel object OR they can point to (async) context\nmanagers that accept a single configuration argument (of type RunnableConfig) and return a pregel object\n(instance of Stategraph, etc.).\n"
|
||||
"description": "Optional. Named definitions of graphs, each pointing to a Python object.\n\n\nGraphs can be StateGraph, @entrypoint, or any other Pregel object OR they can point to (async) context\nmanagers that accept a single configuration argument (of type RunnableConfig) and return a pregel object\n(instance of Stategraph, etc.).\n\nor objects with a \"path\" key and optional \"description\" key."
|
||||
},
|
||||
"http": {
|
||||
"anyOf": [
|
||||
@@ -341,9 +348,16 @@
|
||||
"graphs": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/GraphDef"
|
||||
}
|
||||
]
|
||||
},
|
||||
"description": "Optional. Named definitions of graphs, each pointing to a Python object.\n\n\nGraphs can be StateGraph, @entrypoint, or any other Pregel object OR they can point to (async) context\nmanagers that accept a single configuration argument (of type RunnableConfig) and return a pregel object\n(instance of Stategraph, etc.).\n"
|
||||
"description": "Optional. Named definitions of graphs, each pointing to a Python object.\n\n\nGraphs can be StateGraph, @entrypoint, or any other Pregel object OR they can point to (async) context\nmanagers that accept a single configuration argument (of type RunnableConfig) and return a pregel object\n(instance of Stategraph, etc.).\n\nor objects with a \"path\" key and optional \"description\" key."
|
||||
},
|
||||
"http": {
|
||||
"anyOf": [
|
||||
@@ -542,6 +556,10 @@
|
||||
"description": "Configuration for the built-in checkpointer, which handles checkpointing of state.\n\nIf omitted, no checkpointer is set up (the object store will still be present, however).",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Import path to an async context manager that yields a `BaseCheckpointSaver`\ninstance.\n\nThe referenced object should be an `@asynccontextmanager`-decorated function\nso that the server can properly manage the checkpointer's lifecycle (e.g.\nopening and closing connections).\n"
|
||||
},
|
||||
"serde": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -590,7 +608,27 @@
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional. List of allowed python modules to de-serialize custom objects from.\n\nIf provided, only the specified modules will be allowed to be deserialized.\nIf omitted, no modules are allowed, and the object returned will simply be a json object OR\na deserialized langchain object.\n"
|
||||
"description": "Optional. List of allowed python modules to de-serialize custom objects from JSON.\n\nIf provided, only the specified modules will be allowed to be deserialized.\nIf omitted, no modules are allowed, and the object returned will simply be a json object OR\na deserialized langchain object.\n"
|
||||
},
|
||||
"allowed_msgpack_modules": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional. List of allowed python modules to de-serialize custom objects from msgpack.\n\nKnown safe types (langgraph.checkpoint.serde.jsonplus.SAFE_MSGPACK_TYPES) are always\nallowed regardless of this setting. Use this to allowlist your custom Pydantic models,\ndataclasses, and other user-defined types.\n\nIf True (default), unregistered types will log a warning but still be deserialized.\nIf None, only known safe types will be deserialized; unregistered types will be blocked.\n\n{...\n[\"my_agent.models\", \"MyState\"],\n]\n}\n}\n\n{...\n}\n}\n\n"
|
||||
},
|
||||
"pickle_fallback": {
|
||||
"type": "boolean",
|
||||
@@ -658,6 +696,29 @@
|
||||
},
|
||||
"required": []
|
||||
},
|
||||
"GraphDef": {
|
||||
"title": "GraphDef",
|
||||
"description": "Definition of a graph with additional metadata.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"description": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional. A description of the graph's purpose and functionality.\n\nThis description is surfaced in the API and can help users understand what the graph does.\n"
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Required. Import path to the graph object.\n"
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
},
|
||||
"HttpConfig": {
|
||||
"title": "HttpConfig",
|
||||
"description": "Configuration for the built-in HTTP server that powers your deployment's routes and endpoints.",
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Bumps the version across Cargo.toml and all npm package.json files.
|
||||
# Usage: ./scripts/bump-version.sh 0.3.0
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [ $# -ne 1 ]; then
|
||||
echo "Usage: $0 <version>"
|
||||
echo "Example: $0 0.3.0"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERSION="$1"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
CLI_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
echo "Bumping version to $VERSION"
|
||||
|
||||
# Cargo.toml
|
||||
sed -i.bak "s/^version = \".*\"/version = \"$VERSION\"/" "$CLI_DIR/Cargo.toml"
|
||||
rm -f "$CLI_DIR/Cargo.toml.bak"
|
||||
echo " Updated Cargo.toml"
|
||||
|
||||
# All npm package.json files
|
||||
for pkg in "$CLI_DIR"/npm/*/package.json; do
|
||||
# Update the package's own version
|
||||
sed -i.bak "s/\"version\": \".*\"/\"version\": \"$VERSION\"/" "$pkg"
|
||||
rm -f "$pkg.bak"
|
||||
|
||||
# Update optionalDependencies versions (main package only)
|
||||
if grep -q "optionalDependencies" "$pkg" 2>/dev/null; then
|
||||
sed -i.bak "s/\"@langchain\/langgraph-cli-\([^\"]*\)\": \"[^\"]*\"/\"@langchain\/langgraph-cli-\1\": \"$VERSION\"/" "$pkg"
|
||||
rm -f "$pkg.bak"
|
||||
fi
|
||||
|
||||
echo " Updated $(basename "$(dirname "$pkg")")/package.json"
|
||||
done
|
||||
|
||||
# Update Cargo.lock
|
||||
cd "$CLI_DIR"
|
||||
cargo update -p langgraph-cli 2>/dev/null || true
|
||||
echo " Updated Cargo.lock"
|
||||
|
||||
echo "Done! Version is now $VERSION"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. git add -A && git commit -m 'chore(cli): bump version to $VERSION'"
|
||||
echo " 2. git tag cli-v$VERSION"
|
||||
echo " 3. git push origin main --tags"
|
||||
@@ -1,50 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::constants::{SUPABASE_PUBLIC_API_KEY, SUPABASE_URL};
|
||||
|
||||
/// Fire-and-forget telemetry: log a CLI command invocation.
|
||||
///
|
||||
/// Spawns a background thread that POSTs anonymized usage data to Supabase.
|
||||
/// Respects the `LANGGRAPH_CLI_NO_ANALYTICS` environment variable -- if set to "1",
|
||||
/// no data is sent.
|
||||
pub fn log_command(command: &str, params: &HashMap<String, String>) {
|
||||
if std::env::var("LANGGRAPH_CLI_NO_ANALYTICS").as_deref() == Ok("1") {
|
||||
return;
|
||||
}
|
||||
|
||||
let os_name = std::env::consts::OS.to_string();
|
||||
let arch = std::env::consts::ARCH.to_string();
|
||||
let cli_version = env!("CARGO_PKG_VERSION").to_string();
|
||||
let command = command.to_string();
|
||||
let params = params.clone();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let data = serde_json::json!({
|
||||
"os": os_name,
|
||||
"os_version": arch,
|
||||
"python_version": "rust",
|
||||
"cli_version": cli_version,
|
||||
"cli_command": command,
|
||||
"params": params,
|
||||
});
|
||||
|
||||
let url = format!("{SUPABASE_URL}/rest/v1/logs");
|
||||
|
||||
// Use a blocking reqwest client in this thread
|
||||
let client = match reqwest::blocking::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let _ = client
|
||||
.post(&url)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("apikey", SUPABASE_PUBLIC_API_KEY)
|
||||
.header("User-Agent", "Mozilla/5.0")
|
||||
.body(data.to_string())
|
||||
.send();
|
||||
});
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use console::style;
|
||||
|
||||
use crate::analytics;
|
||||
use crate::config::docker_tag::docker_tag;
|
||||
use crate::config::validate_config_file;
|
||||
use crate::docker::dockerfile::config_to_docker;
|
||||
use crate::exec::{run_command, run_command_streaming};
|
||||
use crate::progress::Progress;
|
||||
use crate::util::warn_non_wolfi_distro;
|
||||
|
||||
/// Build a LangGraph API server Docker image.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn run(
|
||||
config: &str,
|
||||
tag: &str,
|
||||
pull: bool,
|
||||
base_image: Option<&str>,
|
||||
api_version: Option<&str>,
|
||||
install_command: Option<&str>,
|
||||
build_command: Option<&str>,
|
||||
docker_build_args: &[String],
|
||||
) -> Result<(), String> {
|
||||
// Fire-and-forget analytics
|
||||
let mut params = HashMap::new();
|
||||
params.insert("pull".to_string(), pull.to_string());
|
||||
analytics::log_command("build", ¶ms);
|
||||
|
||||
// Check docker is available
|
||||
if which::which("docker").is_err() {
|
||||
return Err("Docker not installed".to_string());
|
||||
}
|
||||
|
||||
let progress = Progress::new("Pulling...");
|
||||
|
||||
// Validate config
|
||||
let config_path = Path::new(config);
|
||||
let mut config_json = validate_config_file(config_path)?;
|
||||
let config_value = serde_json::to_value(&config_json).unwrap();
|
||||
warn_non_wolfi_distro(&config_value);
|
||||
|
||||
// Pull latest images if requested
|
||||
if pull {
|
||||
let image_tag = docker_tag(&config_json, base_image, api_version);
|
||||
run_command("docker", &["pull", &image_tag], None, true)?;
|
||||
}
|
||||
|
||||
progress.set_message("Building...");
|
||||
|
||||
// Determine build context
|
||||
let is_js_project =
|
||||
config_json.node_version.is_some() && config_json.python_version.is_none();
|
||||
|
||||
// For JS projects with install/build commands, use CWD; otherwise use config parent
|
||||
let build_context = if is_js_project && (build_command.is_some() || install_command.is_some()) {
|
||||
std::env::current_dir()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|_| ".".to_string())
|
||||
} else {
|
||||
config_path
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new("."))
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
};
|
||||
|
||||
// Generate Dockerfile
|
||||
let (dockerfile_content, additional_contexts) = config_to_docker(
|
||||
config_path,
|
||||
&mut config_json,
|
||||
base_image,
|
||||
api_version,
|
||||
install_command,
|
||||
build_command,
|
||||
Some(&build_context),
|
||||
false, // no variable escaping for docker build
|
||||
)?;
|
||||
|
||||
// Build docker build args
|
||||
let mut args: Vec<String> = vec![
|
||||
"build".to_string(),
|
||||
"-f".to_string(),
|
||||
"-".to_string(), // Dockerfile from stdin
|
||||
"-t".to_string(),
|
||||
tag.to_string(),
|
||||
];
|
||||
|
||||
// Add additional build contexts
|
||||
for (name, path) in &additional_contexts {
|
||||
args.push("--build-context".to_string());
|
||||
args.push(format!("{name}={path}"));
|
||||
}
|
||||
|
||||
// Add passthrough docker build args
|
||||
for extra in docker_build_args {
|
||||
args.push(extra.clone());
|
||||
}
|
||||
|
||||
// Add build context as last arg
|
||||
args.push(build_context);
|
||||
|
||||
progress.finish();
|
||||
|
||||
// Run docker build with streaming output
|
||||
let args_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
|
||||
run_command_streaming("docker", &args_refs, Some(&dockerfile_content), true)?;
|
||||
|
||||
eprintln!(
|
||||
"{}",
|
||||
style(format!("Successfully built image: {tag}")).green()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use console::style;
|
||||
|
||||
use crate::analytics;
|
||||
use crate::config::validate_config_file;
|
||||
|
||||
/// Find the Python interpreter, preferring python3 over python.
|
||||
fn find_python() -> Result<String, String> {
|
||||
for candidate in &["python3", "python"] {
|
||||
if which::which(candidate).is_ok() {
|
||||
return Ok(candidate.to_string());
|
||||
}
|
||||
}
|
||||
Err(
|
||||
"Python not found. The `langgraph dev` command requires Python >= 3.11 with \
|
||||
langgraph-cli[inmem] installed.\n\
|
||||
Install with: pip install -U \"langgraph-cli[inmem]\""
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Run the LangGraph API server in development mode (in-memory, via Python subprocess).
|
||||
///
|
||||
/// This passes the config as a JSON object via stdin to a Python bootstrap script,
|
||||
/// avoiding any string interpolation into Python source code.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn run(
|
||||
host: &str,
|
||||
port: u16,
|
||||
no_reload: bool,
|
||||
config: &str,
|
||||
n_jobs_per_worker: Option<u32>,
|
||||
no_browser: bool,
|
||||
debug_port: Option<u16>,
|
||||
wait_for_client: bool,
|
||||
studio_url: Option<&str>,
|
||||
allow_blocking: bool,
|
||||
tunnel: bool,
|
||||
server_log_level: &str,
|
||||
) -> Result<(), String> {
|
||||
// Fire-and-forget analytics
|
||||
let mut params = HashMap::new();
|
||||
params.insert("no_reload".to_string(), no_reload.to_string());
|
||||
params.insert("no_browser".to_string(), no_browser.to_string());
|
||||
params.insert("allow_blocking".to_string(), allow_blocking.to_string());
|
||||
params.insert("tunnel".to_string(), tunnel.to_string());
|
||||
analytics::log_command("dev", ¶ms);
|
||||
|
||||
// Validate config
|
||||
let config_path = Path::new(config);
|
||||
let config_json = validate_config_file(config_path)?;
|
||||
|
||||
// Check for node_version -- in-mem server doesn't support JS graphs
|
||||
if config_json.node_version.is_some() {
|
||||
return Err(
|
||||
"In-mem server for JS graphs is not supported in this version of the LangGraph CLI. \
|
||||
Please use `npx @langchain/langgraph-cli` instead."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let python = find_python()?;
|
||||
|
||||
// Pre-check that langgraph_api is importable
|
||||
let check = Command::new(&python)
|
||||
.args(["-c", "from langgraph_api.cli import run_server"])
|
||||
.output();
|
||||
|
||||
match check {
|
||||
Ok(output) if !output.status.success() => {
|
||||
return Err(
|
||||
"Required package 'langgraph-api' is not installed.\n\
|
||||
Please install it with:\n\n\
|
||||
pip install -U \"langgraph-cli[inmem]\"\n\n\
|
||||
Note: The in-mem server requires Python 3.11 or higher."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
Err(_) => {
|
||||
return Err(format!(
|
||||
"Failed to run {python}. The `langgraph dev` command requires Python >= 3.11 with \
|
||||
langgraph-cli[inmem] installed.\n\
|
||||
Install with: pip install -U \"langgraph-cli[inmem]\""
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Build a JSON config object to pass via stdin.
|
||||
// This avoids interpolating user data into Python source code.
|
||||
let dev_config = serde_json::json!({
|
||||
"host": host,
|
||||
"port": port,
|
||||
"reload": !no_reload,
|
||||
"open_browser": !no_browser,
|
||||
"wait_for_client": wait_for_client,
|
||||
"allow_blocking": allow_blocking,
|
||||
"tunnel": tunnel,
|
||||
"server_log_level": server_log_level,
|
||||
"dependencies": config_json.dependencies,
|
||||
"graphs": config_json.graphs,
|
||||
"n_jobs_per_worker": n_jobs_per_worker,
|
||||
"debug_port": debug_port,
|
||||
"studio_url": studio_url,
|
||||
"env": config_json.env,
|
||||
"store": config_json.store,
|
||||
"auth": config_json.auth,
|
||||
"http": config_json.http,
|
||||
"ui": config_json.ui,
|
||||
"ui_config": config_json.ui_config,
|
||||
"webhooks": config_json.webhooks,
|
||||
});
|
||||
|
||||
// Python bootstrap: reads JSON from stdin, calls run_server
|
||||
let python_code = r#"
|
||||
import sys, os, json, pathlib
|
||||
config = json.loads(sys.stdin.read())
|
||||
cwd = os.getcwd()
|
||||
sys.path.append(cwd)
|
||||
for dep in config.get('dependencies', []):
|
||||
dep_path = pathlib.Path(cwd) / dep
|
||||
if dep_path.is_dir() and dep_path.exists():
|
||||
sys.path.append(str(dep_path))
|
||||
from langgraph_api.cli import run_server
|
||||
run_server(
|
||||
config['host'],
|
||||
config['port'],
|
||||
config['reload'],
|
||||
config['graphs'],
|
||||
n_jobs_per_worker=config.get('n_jobs_per_worker'),
|
||||
open_browser=config['open_browser'],
|
||||
debug_port=config.get('debug_port'),
|
||||
env=config.get('env'),
|
||||
store=config.get('store'),
|
||||
wait_for_client=config['wait_for_client'],
|
||||
auth=config.get('auth'),
|
||||
http=config.get('http'),
|
||||
ui=config.get('ui'),
|
||||
ui_config=config.get('ui_config'),
|
||||
webhooks=config.get('webhooks'),
|
||||
studio_url=config.get('studio_url'),
|
||||
allow_blocking=config['allow_blocking'],
|
||||
tunnel=config['tunnel'],
|
||||
server_level=config['server_log_level'],
|
||||
)
|
||||
"#;
|
||||
|
||||
eprintln!(
|
||||
"{}",
|
||||
style("Starting LangGraph API server in development mode...").green()
|
||||
);
|
||||
|
||||
// Spawn Python subprocess with config on stdin
|
||||
let mut child = Command::new(&python)
|
||||
.arg("-c")
|
||||
.arg(python_code)
|
||||
.current_dir(
|
||||
config_path
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new(".")),
|
||||
)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::inherit())
|
||||
.stderr(std::process::Stdio::inherit())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to start Python: {e}"))?;
|
||||
|
||||
// Write JSON config to stdin
|
||||
if let Some(ref mut stdin) = child.stdin {
|
||||
use std::io::Write;
|
||||
let json_bytes = dev_config.to_string();
|
||||
stdin
|
||||
.write_all(json_bytes.as_bytes())
|
||||
.map_err(|e| format!("Failed to write config to Python stdin: {e}"))?;
|
||||
}
|
||||
// Drop stdin to signal EOF
|
||||
drop(child.stdin.take());
|
||||
|
||||
let status = child
|
||||
.wait()
|
||||
.map_err(|e| format!("Failed to wait for Python: {e}"))?;
|
||||
|
||||
if !status.success() {
|
||||
let code = status.code().unwrap_or(1);
|
||||
if code == 130 {
|
||||
// User interrupted with Ctrl-C
|
||||
return Ok(());
|
||||
}
|
||||
return Err(format!("Development server exited with code {code}"));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use console::style;
|
||||
|
||||
use crate::analytics;
|
||||
use crate::config::validate_config_file;
|
||||
use crate::docker::capabilities::check_capabilities;
|
||||
use crate::docker::compose::compose_as_dict;
|
||||
use crate::docker::compose::dict_to_yaml;
|
||||
use crate::docker::dockerfile::config_to_docker;
|
||||
use crate::util::warn_non_wolfi_distro;
|
||||
|
||||
/// Docker ignore file content.
|
||||
fn get_docker_ignore_content() -> &'static str {
|
||||
"\
|
||||
# Ignore node_modules and other dependency directories
|
||||
node_modules
|
||||
bower_components
|
||||
vendor
|
||||
|
||||
# Ignore logs and temporary files
|
||||
*.log
|
||||
*.tmp
|
||||
*.swp
|
||||
|
||||
# Ignore .env files and other environment files
|
||||
.env
|
||||
.env.*
|
||||
*.local
|
||||
|
||||
# Ignore git-related files
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Ignore Docker-related files and configs
|
||||
.dockerignore
|
||||
docker-compose.yml
|
||||
|
||||
# Ignore build and cache directories
|
||||
dist
|
||||
build
|
||||
.cache
|
||||
__pycache__
|
||||
|
||||
# Ignore IDE and editor configurations
|
||||
.vscode
|
||||
.idea
|
||||
*.sublime-project
|
||||
*.sublime-workspace
|
||||
.DS_Store # macOS-specific
|
||||
|
||||
# Ignore test and coverage files
|
||||
coverage
|
||||
*.coverage
|
||||
*.test.js
|
||||
*.spec.js
|
||||
tests
|
||||
"
|
||||
}
|
||||
|
||||
/// Generate a Dockerfile for the LangGraph API server.
|
||||
pub fn run(
|
||||
save_path: &str,
|
||||
config: &str,
|
||||
add_docker_compose: bool,
|
||||
base_image: Option<&str>,
|
||||
api_version: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
// Fire-and-forget analytics
|
||||
let mut params = HashMap::new();
|
||||
params.insert(
|
||||
"add_docker_compose".to_string(),
|
||||
add_docker_compose.to_string(),
|
||||
);
|
||||
analytics::log_command("dockerfile", ¶ms);
|
||||
|
||||
let save_path = Path::new(save_path);
|
||||
let abs_save_path = if save_path.is_absolute() {
|
||||
save_path.to_path_buf()
|
||||
} else {
|
||||
std::env::current_dir()
|
||||
.unwrap_or_default()
|
||||
.join(save_path)
|
||||
};
|
||||
|
||||
eprintln!(
|
||||
"{}",
|
||||
style(format!("Validating configuration at path: {config}")).yellow()
|
||||
);
|
||||
let config_path = Path::new(config);
|
||||
let mut config_json = validate_config_file(config_path)?;
|
||||
let config_value = serde_json::to_value(&config_json).unwrap();
|
||||
warn_non_wolfi_distro(&config_value);
|
||||
eprintln!("{}", style("Configuration validated!").green());
|
||||
|
||||
eprintln!(
|
||||
"{}",
|
||||
style(format!(
|
||||
"Generating Dockerfile at {}",
|
||||
abs_save_path.display()
|
||||
))
|
||||
.yellow()
|
||||
);
|
||||
|
||||
let (dockerfile_content, additional_contexts) = config_to_docker(
|
||||
config_path,
|
||||
&mut config_json,
|
||||
base_image,
|
||||
api_version,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
)?;
|
||||
|
||||
std::fs::write(&abs_save_path, &dockerfile_content)
|
||||
.map_err(|e| format!("Failed to write Dockerfile: {e}"))?;
|
||||
eprintln!("{}", style("Created: Dockerfile").green());
|
||||
|
||||
if !additional_contexts.is_empty() {
|
||||
let ctx_str: Vec<String> = additional_contexts
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{k}={v}"))
|
||||
.collect();
|
||||
eprintln!(
|
||||
"{}",
|
||||
style(format!(
|
||||
"Run docker build with these additional build contexts `--build-context {}`",
|
||||
ctx_str.join(",")
|
||||
))
|
||||
.yellow()
|
||||
);
|
||||
}
|
||||
|
||||
if add_docker_compose {
|
||||
let parent = abs_save_path.parent().unwrap_or(Path::new("."));
|
||||
|
||||
// Write .dockerignore
|
||||
let dockerignore_path = parent.join(".dockerignore");
|
||||
std::fs::write(&dockerignore_path, get_docker_ignore_content())
|
||||
.map_err(|e| format!("Failed to write .dockerignore: {e}"))?;
|
||||
eprintln!("{}", style("Created: .dockerignore").green());
|
||||
|
||||
// Generate docker-compose.yml
|
||||
let capabilities = check_capabilities()?;
|
||||
let mut compose_dict = compose_as_dict(
|
||||
&capabilities,
|
||||
8123,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
base_image,
|
||||
api_version,
|
||||
);
|
||||
|
||||
// Add env_file and build context to langgraph-api service
|
||||
if let Some(crate::docker::compose::YamlValue::Dict(ref mut services)) =
|
||||
compose_dict.get_mut("services")
|
||||
{
|
||||
if let Some(crate::docker::compose::YamlValue::Dict(ref mut api_service)) =
|
||||
services.get_mut("langgraph-api")
|
||||
{
|
||||
api_service.insert(
|
||||
"env_file".to_string(),
|
||||
crate::docker::compose::YamlValue::List(vec![".env".to_string()]),
|
||||
);
|
||||
|
||||
let dockerfile_name = abs_save_path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let mut build_config = indexmap::IndexMap::new();
|
||||
build_config.insert(
|
||||
"context".to_string(),
|
||||
crate::docker::compose::YamlValue::String(".".to_string()),
|
||||
);
|
||||
build_config.insert(
|
||||
"dockerfile".to_string(),
|
||||
crate::docker::compose::YamlValue::String(dockerfile_name),
|
||||
);
|
||||
api_service.insert(
|
||||
"build".to_string(),
|
||||
crate::docker::compose::YamlValue::Dict(build_config),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let compose_yaml = dict_to_yaml(&compose_dict, 0);
|
||||
let compose_path = parent.join("docker-compose.yml");
|
||||
std::fs::write(&compose_path, &compose_yaml)
|
||||
.map_err(|e| format!("Failed to write docker-compose.yml: {e}"))?;
|
||||
eprintln!("{}", style("Created: docker-compose.yml").green());
|
||||
|
||||
// Create .env file if it doesn't exist
|
||||
let env_path = parent.join(".env");
|
||||
if !env_path.exists() {
|
||||
let env_content = "\
|
||||
# Uncomment the following line to add your LangSmith API key
|
||||
# LANGSMITH_API_KEY=your-api-key
|
||||
# Or if you have a LangSmith Deployment license key, then uncomment the following line:
|
||||
# LANGGRAPH_CLOUD_LICENSE_KEY=your-license-key
|
||||
# Add any other environment variables go below...
|
||||
";
|
||||
std::fs::write(&env_path, env_content)
|
||||
.map_err(|e| format!("Failed to write .env: {e}"))?;
|
||||
eprintln!("{}", style("Created: .env").green());
|
||||
} else {
|
||||
eprintln!(
|
||||
"{}",
|
||||
style("Skipped: .env. It already exists!").yellow()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!(
|
||||
"{}",
|
||||
style(format!(
|
||||
"Files generated successfully at path {}!",
|
||||
abs_save_path
|
||||
.parent()
|
||||
.unwrap_or(Path::new("."))
|
||||
.display()
|
||||
))
|
||||
.cyan()
|
||||
.bold()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
pub mod build_cmd;
|
||||
pub mod dev;
|
||||
pub mod dockerfile;
|
||||
pub mod new;
|
||||
pub mod up;
|
||||
@@ -1,16 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::analytics;
|
||||
use crate::templates;
|
||||
|
||||
/// Create a new LangGraph project from a template.
|
||||
pub fn run(path: Option<&str>, template: Option<&str>) -> Result<(), String> {
|
||||
// Fire-and-forget analytics
|
||||
let mut params = HashMap::new();
|
||||
if let Some(t) = template {
|
||||
params.insert("template".to_string(), t.to_string());
|
||||
}
|
||||
analytics::log_command("new", ¶ms);
|
||||
|
||||
templates::create_new(path, template)
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use console::style;
|
||||
|
||||
use crate::analytics;
|
||||
use crate::config::docker_tag::{default_base_image, docker_tag};
|
||||
use crate::config::validate_config_file;
|
||||
use crate::docker::capabilities::{check_capabilities, ComposeType};
|
||||
use crate::docker::compose::compose;
|
||||
use crate::docker::dockerfile::config_to_compose;
|
||||
use crate::exec::{run_command, run_command_streaming_with_callback};
|
||||
use crate::progress::Progress;
|
||||
use crate::util::warn_non_wolfi_distro;
|
||||
|
||||
/// Launch LangGraph API server with Docker Compose.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn run(
|
||||
config: &str,
|
||||
port: u16,
|
||||
docker_compose: Option<&str>,
|
||||
verbose: bool,
|
||||
watch: bool,
|
||||
recreate: bool,
|
||||
pull: bool,
|
||||
wait: bool,
|
||||
debugger_port: Option<u16>,
|
||||
debugger_base_url: Option<&str>,
|
||||
postgres_uri: Option<&str>,
|
||||
api_version: Option<&str>,
|
||||
image: Option<&str>,
|
||||
base_image: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
// Fire-and-forget analytics
|
||||
let mut params = HashMap::new();
|
||||
params.insert("verbose".to_string(), verbose.to_string());
|
||||
params.insert("watch".to_string(), watch.to_string());
|
||||
params.insert("recreate".to_string(), recreate.to_string());
|
||||
params.insert("pull".to_string(), pull.to_string());
|
||||
params.insert("wait".to_string(), wait.to_string());
|
||||
analytics::log_command("up", ¶ms);
|
||||
|
||||
eprintln!("{}", style("Starting LangGraph API server...").green());
|
||||
eprintln!(
|
||||
"For local dev, requires env var LANGSMITH_API_KEY with access to LangSmith Deployment.\n\
|
||||
For production use, requires a license key in env var LANGGRAPH_CLOUD_LICENSE_KEY."
|
||||
);
|
||||
|
||||
let progress = Progress::new("Pulling...");
|
||||
|
||||
// Validate config
|
||||
let config_path = Path::new(config);
|
||||
let mut config_json = validate_config_file(config_path)?;
|
||||
let config_value = serde_json::to_value(&config_json).unwrap();
|
||||
warn_non_wolfi_distro(&config_value);
|
||||
|
||||
// Check docker capabilities
|
||||
let capabilities = check_capabilities()?;
|
||||
|
||||
// Pull latest images if requested
|
||||
if pull {
|
||||
let tag = docker_tag(&config_json, base_image, api_version);
|
||||
progress.set_message("Pulling...");
|
||||
run_command("docker", &["pull", &tag], None, verbose)?;
|
||||
}
|
||||
|
||||
// Generate compose YAML
|
||||
let debugger_base_url_resolved = debugger_base_url
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| format!("http://127.0.0.1:{port}"));
|
||||
|
||||
let mut compose_stdin = compose(
|
||||
&capabilities,
|
||||
port,
|
||||
debugger_port,
|
||||
Some(&debugger_base_url_resolved),
|
||||
postgres_uri,
|
||||
image,
|
||||
base_image,
|
||||
api_version,
|
||||
);
|
||||
|
||||
// Append config-to-compose output (build instructions, env, watch sections)
|
||||
let base_img = base_image
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| default_base_image(&config_json));
|
||||
let compose_config = config_to_compose(
|
||||
config_path,
|
||||
&mut config_json,
|
||||
Some(&base_img),
|
||||
api_version,
|
||||
image,
|
||||
watch,
|
||||
)?;
|
||||
compose_stdin.push_str(&compose_config);
|
||||
|
||||
// Build docker compose args
|
||||
let mut args: Vec<String> = Vec::new();
|
||||
args.push("--project-directory".to_string());
|
||||
args.push(
|
||||
config_path
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new("."))
|
||||
.to_string_lossy()
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
if let Some(dc) = docker_compose {
|
||||
args.push("-f".to_string());
|
||||
args.push(dc.to_string());
|
||||
}
|
||||
|
||||
// Read compose from stdin
|
||||
args.push("-f".to_string());
|
||||
args.push("-".to_string());
|
||||
|
||||
// Add up + options
|
||||
args.push("up".to_string());
|
||||
args.push("--remove-orphans".to_string());
|
||||
|
||||
if recreate {
|
||||
args.push("--force-recreate".to_string());
|
||||
args.push("--renew-anon-volumes".to_string());
|
||||
// Try to remove the volume, ignore errors
|
||||
let _ = run_command("docker", &["volume", "rm", "langgraph-data"], None, false);
|
||||
}
|
||||
|
||||
if watch {
|
||||
args.push("--watch".to_string());
|
||||
}
|
||||
|
||||
if wait {
|
||||
args.push("--wait".to_string());
|
||||
} else {
|
||||
args.push("--abort-on-container-exit".to_string());
|
||||
}
|
||||
|
||||
progress.set_message("Building...");
|
||||
|
||||
// Determine compose command
|
||||
let compose_cmd = match capabilities.compose_type {
|
||||
ComposeType::Plugin => vec!["docker", "compose"],
|
||||
ComposeType::Standalone => vec!["docker-compose"],
|
||||
};
|
||||
|
||||
// Build final command args
|
||||
let mut cmd_args: Vec<&str> = Vec::new();
|
||||
if compose_cmd.len() > 1 {
|
||||
// "docker compose ..."
|
||||
cmd_args.extend_from_slice(&compose_cmd[1..]);
|
||||
}
|
||||
for a in &args {
|
||||
cmd_args.push(a.as_str());
|
||||
}
|
||||
|
||||
// Run docker compose with streaming output, intercepting stdout
|
||||
// to detect startup and show Ready! URLs
|
||||
let mut ready_printed = false;
|
||||
let debugger_base_url_query = debugger_base_url
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| format!("http://127.0.0.1:{port}"));
|
||||
|
||||
run_command_streaming_with_callback(
|
||||
compose_cmd[0],
|
||||
&cmd_args,
|
||||
Some(&compose_stdin),
|
||||
verbose,
|
||||
|line| {
|
||||
if !ready_printed {
|
||||
if line.contains("unpacking to docker.io") {
|
||||
progress.set_message("Starting...");
|
||||
} else if line.contains("Application startup complete") {
|
||||
progress.finish();
|
||||
ready_printed = true;
|
||||
|
||||
let debugger_origin = if let Some(dp) = debugger_port {
|
||||
format!("http://localhost:{dp}")
|
||||
} else {
|
||||
"https://smith.langchain.com".to_string()
|
||||
};
|
||||
|
||||
println!(
|
||||
"Ready!\n\
|
||||
- API: http://localhost:{port}\n\
|
||||
- Docs: http://localhost:{port}/docs\n\
|
||||
- LangGraph Studio: {debugger_origin}/studio/?baseUrl={debugger_base_url_query}"
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
)?;
|
||||
|
||||
if !ready_printed {
|
||||
progress.finish();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,458 +0,0 @@
|
||||
use super::schema::Config;
|
||||
use crate::constants::DEFAULT_IMAGE_DISTRO;
|
||||
|
||||
/// Get the default base image for a config.
|
||||
pub fn default_base_image(config: &Config) -> String {
|
||||
if let Some(ref base) = config.base_image {
|
||||
return base.clone();
|
||||
}
|
||||
if config.node_version.is_some() && config.python_version.is_none() {
|
||||
"langchain/langgraphjs-api".to_string()
|
||||
} else {
|
||||
"langchain/langgraph-api".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the Docker image tag string.
|
||||
pub fn docker_tag(
|
||||
config: &Config,
|
||||
base_image: Option<&str>,
|
||||
api_version: Option<&str>,
|
||||
) -> String {
|
||||
let api_version = api_version
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| config.api_version.clone());
|
||||
let base_image = base_image
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| default_base_image(config));
|
||||
|
||||
let image_distro = config
|
||||
.image_distro
|
||||
.as_deref()
|
||||
.unwrap_or(DEFAULT_IMAGE_DISTRO);
|
||||
let distro_tag = if image_distro == DEFAULT_IMAGE_DISTRO {
|
||||
String::new()
|
||||
} else {
|
||||
format!("-{image_distro}")
|
||||
};
|
||||
|
||||
if let Some(ref tag) = config.internal_docker_tag {
|
||||
return format!("{base_image}:{tag}");
|
||||
}
|
||||
|
||||
// Build the standard tag format
|
||||
let (language, version) = if config.node_version.is_some() && config.python_version.is_none() {
|
||||
("node", config.node_version.as_deref().unwrap_or("20"))
|
||||
} else {
|
||||
(
|
||||
"py",
|
||||
config
|
||||
.python_version
|
||||
.as_deref()
|
||||
.unwrap_or(crate::constants::DEFAULT_PYTHON_VERSION),
|
||||
)
|
||||
};
|
||||
|
||||
let version_distro_tag = format!("{version}{distro_tag}");
|
||||
|
||||
if let Some(api_ver) = api_version {
|
||||
format!("{base_image}:{api_ver}-{language}{version_distro_tag}")
|
||||
} else if base_image.contains("/langgraph-server") && !base_image.contains(&version_distro_tag)
|
||||
{
|
||||
format!("{base_image}-{language}{version_distro_tag}")
|
||||
} else {
|
||||
format!("{base_image}:{version_distro_tag}")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::schema::Config;
|
||||
use crate::config::validate_config;
|
||||
use serde_json::json;
|
||||
|
||||
fn config_from_json(v: serde_json::Value) -> Config {
|
||||
serde_json::from_value(v).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_base_image_python() {
|
||||
let config = Config {
|
||||
python_version: Some("3.11".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(default_base_image(&config), "langchain/langgraph-api");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_base_image_node() {
|
||||
let config = Config {
|
||||
node_version: Some("20".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(default_base_image(&config), "langchain/langgraphjs-api");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_docker_tag_basic() {
|
||||
let config = Config {
|
||||
python_version: Some("3.11".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
docker_tag(&config, None, None),
|
||||
"langchain/langgraph-api:3.11"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_docker_tag_with_api_version_basic() {
|
||||
let config = Config {
|
||||
python_version: Some("3.11".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
docker_tag(&config, None, Some("0.2.74")),
|
||||
"langchain/langgraph-api:0.2.74-py3.11"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_docker_tag_wolfi() {
|
||||
let config = Config {
|
||||
python_version: Some("3.12".to_string()),
|
||||
image_distro: Some("wolfi".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
docker_tag(&config, None, None),
|
||||
"langchain/langgraph-api:3.12-wolfi"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_docker_tag_internal() {
|
||||
let config = Config {
|
||||
python_version: Some("3.11".to_string()),
|
||||
internal_docker_tag: Some("custom-tag".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
docker_tag(&config, None, None),
|
||||
"langchain/langgraph-api:custom-tag"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Comprehensive tests ported from Python test_config.py
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_docker_tag_image_distro() {
|
||||
// Test 1: Default distro (debian) - no suffix
|
||||
let cfg = config_from_json(json!({
|
||||
"python_version": "3.11",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"}
|
||||
}));
|
||||
let cfg = validate_config(cfg).unwrap();
|
||||
assert_eq!(docker_tag(&cfg, None, None), "langchain/langgraph-api:3.11");
|
||||
|
||||
// Test 2: Explicit debian distro - same as default
|
||||
let cfg = config_from_json(json!({
|
||||
"python_version": "3.11",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"image_distro": "debian"
|
||||
}));
|
||||
let cfg = validate_config(cfg).unwrap();
|
||||
assert_eq!(docker_tag(&cfg, None, None), "langchain/langgraph-api:3.11");
|
||||
|
||||
// Test 3: Wolfi distro with python
|
||||
let cfg = config_from_json(json!({
|
||||
"python_version": "3.11",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"image_distro": "wolfi"
|
||||
}));
|
||||
let cfg = validate_config(cfg).unwrap();
|
||||
assert_eq!(
|
||||
docker_tag(&cfg, None, None),
|
||||
"langchain/langgraph-api:3.11-wolfi"
|
||||
);
|
||||
|
||||
// Test 4: Node.js with default distro
|
||||
let cfg = config_from_json(json!({
|
||||
"node_version": "20",
|
||||
"graphs": {"agent": "./agent.js:graph"}
|
||||
}));
|
||||
let cfg = validate_config(cfg).unwrap();
|
||||
assert_eq!(
|
||||
docker_tag(&cfg, None, None),
|
||||
"langchain/langgraphjs-api:20"
|
||||
);
|
||||
|
||||
// Test 5: Node.js with wolfi distro
|
||||
let cfg = config_from_json(json!({
|
||||
"node_version": "20",
|
||||
"graphs": {"agent": "./agent.js:graph"},
|
||||
"image_distro": "wolfi"
|
||||
}));
|
||||
let cfg = validate_config(cfg).unwrap();
|
||||
assert_eq!(
|
||||
docker_tag(&cfg, None, None),
|
||||
"langchain/langgraphjs-api:20-wolfi"
|
||||
);
|
||||
|
||||
// Test 6: Custom base image with wolfi
|
||||
let cfg = config_from_json(json!({
|
||||
"python_version": "3.12",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"image_distro": "wolfi",
|
||||
"base_image": "my-registry/custom-image"
|
||||
}));
|
||||
let cfg = validate_config(cfg).unwrap();
|
||||
assert_eq!(
|
||||
docker_tag(&cfg, Some("my-registry/custom-image"), None),
|
||||
"my-registry/custom-image:3.12-wolfi"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_docker_tag_multiplatform_with_distro() {
|
||||
// Test 1: Python + Node with wolfi -> defaults to Python
|
||||
let cfg = config_from_json(json!({
|
||||
"python_version": "3.11",
|
||||
"node_version": "20",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"python": "./agent.py:graph", "js": "./agent.js:graph"},
|
||||
"image_distro": "wolfi"
|
||||
}));
|
||||
let cfg = validate_config(cfg).unwrap();
|
||||
assert_eq!(
|
||||
docker_tag(&cfg, None, None),
|
||||
"langchain/langgraph-api:3.11-wolfi"
|
||||
);
|
||||
|
||||
// Test 2: Node-only with wolfi
|
||||
let cfg = config_from_json(json!({
|
||||
"node_version": "20",
|
||||
"graphs": {"js": "./agent.js:graph"},
|
||||
"image_distro": "wolfi"
|
||||
}));
|
||||
let cfg = validate_config(cfg).unwrap();
|
||||
assert_eq!(
|
||||
docker_tag(&cfg, None, None),
|
||||
"langchain/langgraphjs-api:20-wolfi"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_docker_tag_different_python_versions_with_distro() {
|
||||
for (ver, expected) in &[
|
||||
("3.11", "langchain/langgraph-api:3.11-wolfi"),
|
||||
("3.12", "langchain/langgraph-api:3.12-wolfi"),
|
||||
("3.13", "langchain/langgraph-api:3.13-wolfi"),
|
||||
] {
|
||||
let cfg = config_from_json(json!({
|
||||
"python_version": ver,
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"image_distro": "wolfi"
|
||||
}));
|
||||
let cfg = validate_config(cfg).unwrap();
|
||||
assert_eq!(
|
||||
docker_tag(&cfg, None, None),
|
||||
*expected,
|
||||
"Failed for Python {ver}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_docker_tag_different_node_versions_with_distro() {
|
||||
for (ver, expected) in &[
|
||||
("20", "langchain/langgraphjs-api:20-wolfi"),
|
||||
("21", "langchain/langgraphjs-api:21-wolfi"),
|
||||
("22", "langchain/langgraphjs-api:22-wolfi"),
|
||||
] {
|
||||
let cfg = config_from_json(json!({
|
||||
"node_version": ver,
|
||||
"graphs": {"agent": "./agent.js:graph"},
|
||||
"image_distro": "wolfi"
|
||||
}));
|
||||
let cfg = validate_config(cfg).unwrap();
|
||||
assert_eq!(
|
||||
docker_tag(&cfg, None, None),
|
||||
*expected,
|
||||
"Failed for Node.js {ver}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to run a single api_version test scenario with both in_config and as-argument modes.
|
||||
fn run_api_version_test(
|
||||
base_json: serde_json::Value,
|
||||
api_version: &str,
|
||||
base_image_arg: Option<&str>,
|
||||
expected: &str,
|
||||
) {
|
||||
// Mode 1: api_version passed as function argument (not in config)
|
||||
{
|
||||
let cfg = config_from_json(base_json.clone());
|
||||
let cfg = validate_config(cfg).unwrap();
|
||||
assert_eq!(
|
||||
docker_tag(&cfg, base_image_arg, Some(api_version)),
|
||||
expected,
|
||||
"Failed with api_version as argument"
|
||||
);
|
||||
}
|
||||
|
||||
// Mode 2: api_version set in config (not passed as argument)
|
||||
{
|
||||
let mut json_val = base_json.clone();
|
||||
json_val
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.insert("api_version".to_string(), json!(api_version));
|
||||
let cfg = config_from_json(json_val);
|
||||
let cfg = validate_config(cfg).unwrap();
|
||||
assert_eq!(
|
||||
docker_tag(&cfg, base_image_arg, None),
|
||||
expected,
|
||||
"Failed with api_version in config"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_docker_tag_with_api_version() {
|
||||
let version = "0.2.74";
|
||||
|
||||
// Test 1: Python config with api_version and default distro
|
||||
run_api_version_test(
|
||||
json!({
|
||||
"python_version": "3.11",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"}
|
||||
}),
|
||||
version,
|
||||
None,
|
||||
"langchain/langgraph-api:0.2.74-py3.11",
|
||||
);
|
||||
|
||||
// Test 2: Python config with api_version and wolfi distro
|
||||
run_api_version_test(
|
||||
json!({
|
||||
"python_version": "3.12",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"image_distro": "wolfi"
|
||||
}),
|
||||
version,
|
||||
None,
|
||||
"langchain/langgraph-api:0.2.74-py3.12-wolfi",
|
||||
);
|
||||
|
||||
// Test 3: Node.js config with api_version and default distro
|
||||
run_api_version_test(
|
||||
json!({
|
||||
"node_version": "20",
|
||||
"graphs": {"agent": "./agent.js:graph"}
|
||||
}),
|
||||
version,
|
||||
None,
|
||||
"langchain/langgraphjs-api:0.2.74-node20",
|
||||
);
|
||||
|
||||
// Test 4: Node.js config with api_version and wolfi distro
|
||||
run_api_version_test(
|
||||
json!({
|
||||
"node_version": "20",
|
||||
"graphs": {"agent": "./agent.js:graph"},
|
||||
"image_distro": "wolfi"
|
||||
}),
|
||||
version,
|
||||
None,
|
||||
"langchain/langgraphjs-api:0.2.74-node20-wolfi",
|
||||
);
|
||||
|
||||
// Test 5: Custom base image with api_version
|
||||
run_api_version_test(
|
||||
json!({
|
||||
"python_version": "3.11",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"base_image": "my-registry/custom-image"
|
||||
}),
|
||||
version,
|
||||
Some("my-registry/custom-image"),
|
||||
"my-registry/custom-image:0.2.74-py3.11",
|
||||
);
|
||||
|
||||
// Test 6: Different Python versions with api_version
|
||||
for py_ver in &["3.11", "3.12", "3.13"] {
|
||||
let expected = format!("langchain/langgraph-api:{version}-py{py_ver}");
|
||||
run_api_version_test(
|
||||
json!({
|
||||
"python_version": py_ver,
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"}
|
||||
}),
|
||||
version,
|
||||
None,
|
||||
&expected,
|
||||
);
|
||||
}
|
||||
|
||||
// Test 7: Without api_version should work as before
|
||||
let cfg = config_from_json(json!({
|
||||
"python_version": "3.11",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"}
|
||||
}));
|
||||
let cfg = validate_config(cfg).unwrap();
|
||||
assert_eq!(docker_tag(&cfg, None, None), "langchain/langgraph-api:3.11");
|
||||
|
||||
// Test 8: Multiplatform with api_version (should default to Python)
|
||||
run_api_version_test(
|
||||
json!({
|
||||
"python_version": "3.11",
|
||||
"node_version": "20",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"python": "./agent.py:graph", "js": "./agent.js:graph"}
|
||||
}),
|
||||
version,
|
||||
None,
|
||||
"langchain/langgraph-api:0.2.74-py3.11",
|
||||
);
|
||||
|
||||
// Test 9: _INTERNAL_docker_tag ignores api_version
|
||||
// (can only test with api_version as argument since both in config is invalid)
|
||||
let cfg = config_from_json(json!({
|
||||
"python_version": "3.11",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"_INTERNAL_docker_tag": "internal-tag"
|
||||
}));
|
||||
let cfg = validate_config(cfg).unwrap();
|
||||
assert_eq!(
|
||||
docker_tag(&cfg, None, Some("0.2.74")),
|
||||
"langchain/langgraph-api:internal-tag"
|
||||
);
|
||||
|
||||
// Test 10: langgraph-server base image with api_version
|
||||
run_api_version_test(
|
||||
json!({
|
||||
"python_version": "3.11",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"}
|
||||
}),
|
||||
version,
|
||||
Some("langchain/langgraph-server"),
|
||||
"langchain/langgraph-server:0.2.74-py3.11",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use indexmap::IndexMap;
|
||||
|
||||
use super::schema::Config;
|
||||
use crate::constants::RESERVED_PACKAGE_NAMES;
|
||||
|
||||
/// Container for referencing and managing local Python dependencies.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LocalDeps {
|
||||
/// (host_requirements_path, container_requirements_path)
|
||||
pub pip_reqs: Vec<(PathBuf, String)>,
|
||||
/// host_path -> (dependency_string, container_package_name)
|
||||
pub real_pkgs: IndexMap<PathBuf, (String, String)>,
|
||||
/// host_path -> (dependency_string, container_path)
|
||||
pub faux_pkgs: IndexMap<PathBuf, (String, String)>,
|
||||
/// If "." is in dependencies, use it as working_dir
|
||||
pub working_dir: Option<String>,
|
||||
/// Directories outside the config parent that need additional Docker build contexts
|
||||
pub additional_contexts: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl Default for LocalDeps {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
pip_reqs: Vec::new(),
|
||||
real_pkgs: IndexMap::new(),
|
||||
faux_pkgs: IndexMap::new(),
|
||||
working_dir: None,
|
||||
additional_contexts: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Assemble local dependencies from config.
|
||||
pub fn assemble_local_deps(
|
||||
config_path: &Path,
|
||||
config: &Config,
|
||||
) -> Result<LocalDeps, String> {
|
||||
let config_path = config_path
|
||||
.canonicalize()
|
||||
.map_err(|e| format!("Could not resolve config path: {e}"))?;
|
||||
let config_parent = config_path.parent().unwrap();
|
||||
|
||||
let mut reserved: HashSet<String> = RESERVED_PACKAGE_NAMES
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
let mut counter: HashMap<String, usize> = HashMap::new();
|
||||
|
||||
let check_reserved = |name: &str, ref_str: &str, reserved: &mut HashSet<String>| -> Result<(), String> {
|
||||
if reserved.contains(name) {
|
||||
return Err(format!(
|
||||
"Package name '{name}' used in local dep '{ref_str}' is reserved. Rename the directory."
|
||||
));
|
||||
}
|
||||
reserved.insert(name.to_string());
|
||||
Ok(())
|
||||
};
|
||||
|
||||
let mut pip_reqs = Vec::new();
|
||||
let mut real_pkgs = IndexMap::new();
|
||||
let mut faux_pkgs = IndexMap::new();
|
||||
let mut working_dir: Option<String> = None;
|
||||
let mut additional_contexts = Vec::new();
|
||||
|
||||
for local_dep in &config.dependencies {
|
||||
if !local_dep.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let resolved = (config_parent.join(local_dep))
|
||||
.canonicalize()
|
||||
.map_err(|_| format!("Could not find local dependency: {}", config_parent.join(local_dep).display()))?;
|
||||
|
||||
if !resolved.exists() {
|
||||
return Err(format!("Could not find local dependency: {}", resolved.display()));
|
||||
}
|
||||
if !resolved.is_dir() {
|
||||
return Err(format!(
|
||||
"Local dependency must be a directory: {}",
|
||||
resolved.display()
|
||||
));
|
||||
}
|
||||
|
||||
if resolved != config_parent && !resolved.starts_with(config_parent) {
|
||||
additional_contexts.push(resolved.clone());
|
||||
}
|
||||
|
||||
let files: Vec<String> = std::fs::read_dir(&resolved)
|
||||
.map_err(|e| format!("Could not read directory {}: {e}", resolved.display()))?
|
||||
.filter_map(|entry| entry.ok().map(|e| e.file_name().to_string_lossy().to_string()))
|
||||
.collect();
|
||||
|
||||
if files.contains(&"pyproject.toml".to_string())
|
||||
|| files.contains(&"setup.py".to_string())
|
||||
{
|
||||
// Real package
|
||||
let dir_name = resolved
|
||||
.file_name()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let count = counter.entry(dir_name.clone()).or_insert(0);
|
||||
let container_name = if *count > 0 {
|
||||
format!("{}_{}", dir_name, count)
|
||||
} else {
|
||||
dir_name.clone()
|
||||
};
|
||||
*count += 1;
|
||||
|
||||
real_pkgs.insert(resolved.clone(), (local_dep.clone(), container_name.clone()));
|
||||
|
||||
if local_dep == "." {
|
||||
working_dir = Some(format!("/deps/{container_name}"));
|
||||
}
|
||||
} else {
|
||||
// Faux package
|
||||
let dir_name = resolved
|
||||
.file_name()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
if files.contains(&"__init__.py".to_string()) {
|
||||
// Flat layout
|
||||
if dir_name.contains('-') {
|
||||
return Err(format!(
|
||||
"Package name '{dir_name}' contains a hyphen. \
|
||||
Rename the directory to use it as flat-layout package."
|
||||
));
|
||||
}
|
||||
check_reserved(&dir_name, local_dep, &mut reserved)?;
|
||||
let container_path = format!("/deps/outer-{dir_name}/{dir_name}");
|
||||
faux_pkgs.insert(resolved.clone(), (local_dep.clone(), container_path.clone()));
|
||||
if local_dep == "." {
|
||||
working_dir = Some(container_path);
|
||||
}
|
||||
} else {
|
||||
// Src layout
|
||||
let container_path = format!("/deps/outer-{dir_name}/src");
|
||||
|
||||
for file in &files {
|
||||
let rfile = resolved.join(file);
|
||||
if rfile.is_dir() && file != "__pycache__" && !file.starts_with('.') {
|
||||
if let Ok(entries) = std::fs::read_dir(&rfile) {
|
||||
for subentry in entries.flatten() {
|
||||
let subname = subentry.file_name().to_string_lossy().to_string();
|
||||
if subname.ends_with(".py") {
|
||||
check_reserved(file, local_dep, &mut reserved)?;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
faux_pkgs.insert(resolved.clone(), (local_dep.clone(), container_path.clone()));
|
||||
if local_dep == "." {
|
||||
working_dir = Some(container_path);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for requirements.txt
|
||||
if files.contains(&"requirements.txt".to_string()) {
|
||||
let rfile = resolved.join("requirements.txt");
|
||||
let container_req_path = if let Some((_, ref cp)) = faux_pkgs.get(&resolved) {
|
||||
format!("{cp}/requirements.txt")
|
||||
} else {
|
||||
format!("/deps/outer-{dir_name}/requirements.txt")
|
||||
};
|
||||
pip_reqs.push((rfile, container_req_path));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(LocalDeps {
|
||||
pip_reqs,
|
||||
real_pkgs,
|
||||
faux_pkgs,
|
||||
working_dir,
|
||||
additional_contexts,
|
||||
})
|
||||
}
|
||||
@@ -1,849 +0,0 @@
|
||||
pub mod docker_tag;
|
||||
pub mod local_deps;
|
||||
pub mod path_rewrite;
|
||||
pub mod schema;
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use schema::{Config, EnvConfig, GraphSpec, KeepPkgTools};
|
||||
|
||||
use crate::constants::{
|
||||
BUILD_TOOLS, DEFAULT_IMAGE_DISTRO, DEFAULT_NODE_VERSION, DEFAULT_PYTHON_VERSION,
|
||||
MIN_NODE_VERSION, MIN_PYTHON_VERSION, VALID_DISTROS, VALID_PIP_INSTALLERS,
|
||||
};
|
||||
|
||||
/// Check if a graph spec references a Node.js file based on extension.
|
||||
pub fn is_node_graph(spec: &GraphSpec) -> bool {
|
||||
let path_str = match spec {
|
||||
GraphSpec::Path(s) => s.as_str(),
|
||||
GraphSpec::Dict(m) => match m.get("path").and_then(|v| v.as_str()) {
|
||||
Some(p) => p,
|
||||
None => return false,
|
||||
},
|
||||
};
|
||||
|
||||
let file_path = path_str.split(':').next().unwrap_or("");
|
||||
matches!(
|
||||
Path::new(file_path)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str()),
|
||||
Some("ts" | "mts" | "cts" | "js" | "mjs" | "cjs")
|
||||
)
|
||||
}
|
||||
|
||||
/// Parse a Python version string "major.minor" into (major, minor).
|
||||
fn parse_version(version_str: &str) -> Result<(u32, u32), String> {
|
||||
let cleaned = version_str.split('-').next().unwrap_or(version_str);
|
||||
let parts: Vec<&str> = cleaned.split('.').collect();
|
||||
if parts.len() != 2 {
|
||||
return Err(format!("Invalid version format: {version_str}"));
|
||||
}
|
||||
let major: u32 = parts[0]
|
||||
.parse()
|
||||
.map_err(|_| format!("Invalid version format: {version_str}"))?;
|
||||
let minor: u32 = parts[1]
|
||||
.parse()
|
||||
.map_err(|_| format!("Invalid version format: {version_str}"))?;
|
||||
Ok((major, minor))
|
||||
}
|
||||
|
||||
/// Parse a Node.js version string (major only) into u32.
|
||||
fn parse_node_version(version_str: &str) -> Result<u32, String> {
|
||||
if version_str.contains('.') {
|
||||
return Err(format!(
|
||||
"Invalid Node.js version format: {version_str}. Use major version only (e.g., '20')."
|
||||
));
|
||||
}
|
||||
version_str
|
||||
.parse::<u32>()
|
||||
.map_err(|_| format!("Invalid Node.js version format: {version_str}. Use major version only (e.g., '20')."))
|
||||
}
|
||||
|
||||
/// Validate a configuration dictionary.
|
||||
pub fn validate_config(mut config: Config) -> Result<Config, String> {
|
||||
let graphs = &config.graphs;
|
||||
|
||||
let some_node = graphs.values().any(|spec| is_node_graph(spec));
|
||||
let some_python = graphs.values().any(|spec| !is_node_graph(spec));
|
||||
|
||||
// Set defaults for node_version and python_version
|
||||
if config.node_version.is_none() && some_node {
|
||||
config.node_version = Some(DEFAULT_NODE_VERSION.to_string());
|
||||
}
|
||||
if config.python_version.is_none() && some_python {
|
||||
config.python_version = Some(DEFAULT_PYTHON_VERSION.to_string());
|
||||
}
|
||||
|
||||
// Default image_distro
|
||||
if config.image_distro.is_none() {
|
||||
config.image_distro = Some(DEFAULT_IMAGE_DISTRO.to_string());
|
||||
}
|
||||
|
||||
// Default pip_installer
|
||||
if config.pip_installer.is_none() {
|
||||
config.pip_installer = Some("auto".to_string());
|
||||
}
|
||||
|
||||
// Default env
|
||||
if config.env.is_none() {
|
||||
config.env = Some(EnvConfig::default());
|
||||
}
|
||||
|
||||
// Validate _INTERNAL_docker_tag vs api_version
|
||||
if config.internal_docker_tag.is_some() && config.api_version.is_some() {
|
||||
return Err("Cannot specify both _INTERNAL_docker_tag and api_version.".to_string());
|
||||
}
|
||||
|
||||
// Validate api_version format
|
||||
if let Some(ref api_version) = config.api_version {
|
||||
let cleaned = api_version.split('-').next().unwrap_or(api_version);
|
||||
let parts: Vec<&str> = cleaned.split('.').collect();
|
||||
if parts.len() > 3 {
|
||||
return Err("Version must be major or major.minor or major.minor.patch.".to_string());
|
||||
}
|
||||
for part in &parts {
|
||||
part.parse::<u32>()
|
||||
.map_err(|_| format!("Invalid version format: {api_version}"))?;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate node_version
|
||||
if let Some(ref node_version) = config.node_version {
|
||||
let major = parse_node_version(node_version)?;
|
||||
if major < MIN_NODE_VERSION {
|
||||
return Err(format!(
|
||||
"Node.js version {node_version} is not supported. Minimum required version is {MIN_NODE_VERSION}."
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Validate python_version
|
||||
if let Some(ref pyversion) = config.python_version {
|
||||
let cleaned = pyversion.split('-').next().unwrap_or(pyversion);
|
||||
if cleaned.split('.').count() != 2
|
||||
|| !cleaned.split('.').all(|p| p.chars().all(|c| c.is_ascii_digit()))
|
||||
{
|
||||
return Err(format!(
|
||||
"Invalid Python version format: {pyversion}. \
|
||||
Use 'major.minor' format (e.g., '3.11'). \
|
||||
Patch version cannot be specified."
|
||||
));
|
||||
}
|
||||
if parse_version(pyversion)? < MIN_PYTHON_VERSION {
|
||||
return Err(format!(
|
||||
"Python version {pyversion} is not supported. \
|
||||
Minimum required version is {}.{}.",
|
||||
MIN_PYTHON_VERSION.0, MIN_PYTHON_VERSION.1
|
||||
));
|
||||
}
|
||||
|
||||
if config.dependencies.is_empty() {
|
||||
return Err(
|
||||
"No dependencies found in config. Add at least one dependency to 'dependencies' list."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate graphs
|
||||
if config.graphs.is_empty() {
|
||||
return Err(
|
||||
"No graphs found in config. Add at least one graph to 'graphs' dictionary."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
// Validate image_distro
|
||||
if let Some(ref distro) = config.image_distro {
|
||||
if distro == "bullseye" {
|
||||
return Err(
|
||||
"Bullseye images were deprecated in version 0.4.13. \
|
||||
Please use 'bookworm' or 'debian' instead."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
if !VALID_DISTROS.contains(&distro.as_str()) {
|
||||
return Err(format!(
|
||||
"Invalid image_distro: '{distro}'. Must be one of 'debian', 'wolfi', or 'bookworm'."
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Validate pip_installer
|
||||
if let Some(ref pip_installer) = config.pip_installer {
|
||||
if !VALID_PIP_INSTALLERS.contains(&pip_installer.as_str()) {
|
||||
return Err(format!(
|
||||
"Invalid pip_installer: '{pip_installer}'. Must be 'auto', 'pip', or 'uv'."
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Validate auth config
|
||||
if let Some(ref auth_conf) = config.auth {
|
||||
if let Some(ref path) = auth_conf.path {
|
||||
if !path.contains(':') {
|
||||
return Err(format!(
|
||||
"Invalid auth.path format: '{path}'. \
|
||||
Must be in format './path/to/file.py:attribute_name'"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate encryption config
|
||||
if let Some(ref encryption_conf) = config.encryption {
|
||||
if let Some(ref path) = encryption_conf.path {
|
||||
if !path.contains(':') {
|
||||
return Err(format!(
|
||||
"Invalid encryption.path format: '{path}'. \
|
||||
Must be in format './path/to/file.py:attribute_name'"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate http config
|
||||
if let Some(ref http_conf) = config.http {
|
||||
if let Some(ref app) = http_conf.app {
|
||||
if !app.contains(':') {
|
||||
return Err(format!(
|
||||
"Invalid http.app format: '{app}'. \
|
||||
Must be in format './path/to/file.py:attribute_name'"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate keep_pkg_tools
|
||||
if let Some(ref keep_pkg_tools) = config.keep_pkg_tools {
|
||||
match keep_pkg_tools {
|
||||
KeepPkgTools::List(tools) => {
|
||||
for tool in tools {
|
||||
if !BUILD_TOOLS.contains(&tool.as_str()) {
|
||||
return Err(format!(
|
||||
"Invalid keep_pkg_tools: '{tool}'. \
|
||||
Must be one of 'pip', 'setuptools', 'wheel'."
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
KeepPkgTools::Bool(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Load and validate a configuration file.
|
||||
pub fn validate_config_file(config_path: &Path) -> Result<Config, String> {
|
||||
let content = std::fs::read_to_string(config_path)
|
||||
.map_err(|e| format!("Could not read config file {}: {e}", config_path.display()))?;
|
||||
|
||||
let config: Config = serde_json::from_str(&content)
|
||||
.map_err(|e| format!("Invalid JSON in config file {}: {e}", config_path.display()))?;
|
||||
|
||||
let validated = validate_config(config)?;
|
||||
|
||||
// Check package.json engines
|
||||
if validated.node_version.is_some() {
|
||||
let package_json_path = config_path.parent().unwrap().join("package.json");
|
||||
if package_json_path.is_file() {
|
||||
let pkg_content = std::fs::read_to_string(&package_json_path)
|
||||
.map_err(|e| format!("Could not read package.json: {e}"))?;
|
||||
let pkg: serde_json::Value = serde_json::from_str(&pkg_content).map_err(|_| {
|
||||
format!(
|
||||
"Invalid package.json found in langgraph config directory {}: file is not valid JSON",
|
||||
package_json_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
if let Some(engines) = pkg.get("engines").and_then(|e| e.as_object()) {
|
||||
// Only 'node' engine is supported
|
||||
if engines.keys().any(|k| k != "node") {
|
||||
return Err(format!(
|
||||
"Only 'node' engine is supported in package.json engines. Got engines: {:?}",
|
||||
engines.keys().collect::<Vec<_>>()
|
||||
));
|
||||
}
|
||||
if let Some(node_version) = engines.get("node").and_then(|v| v.as_str()) {
|
||||
let major = parse_node_version(node_version)?;
|
||||
if major < MIN_NODE_VERSION {
|
||||
return Err(format!(
|
||||
"Node.js version in package.json engines must be >= {MIN_NODE_VERSION} \
|
||||
(major version only), got '{node_version}'. Minor/patch versions \
|
||||
(like '20.x.y') are not supported to prevent deployment issues \
|
||||
when new Node.js versions are released."
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(validated)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use std::io::Write;
|
||||
|
||||
fn config_from_json(val: serde_json::Value) -> Config {
|
||||
serde_json::from_value::<Config>(val).unwrap()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// test_validate_config
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_minimal() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"}
|
||||
}));
|
||||
let result = validate_config(config).unwrap();
|
||||
assert_eq!(result.python_version.as_deref(), Some("3.11"));
|
||||
assert_eq!(result.node_version, None);
|
||||
assert_eq!(result.pip_installer.as_deref(), Some("auto"));
|
||||
assert_eq!(result.image_distro.as_deref(), Some("debian"));
|
||||
assert_eq!(result.base_image, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_full() {
|
||||
let config = config_from_json(json!({
|
||||
"python_version": "3.12",
|
||||
"dependencies": [".", "langchain"],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"pip_config_file": "/etc/pip.conf",
|
||||
"dockerfile_lines": ["RUN apt-get update"],
|
||||
"env": ".env"
|
||||
}));
|
||||
let result = validate_config(config).unwrap();
|
||||
assert_eq!(result.python_version.as_deref(), Some("3.12"));
|
||||
assert_eq!(result.pip_config_file.as_deref(), Some("/etc/pip.conf"));
|
||||
assert_eq!(result.dockerfile_lines, vec!["RUN apt-get update"]);
|
||||
assert!(matches!(result.env, Some(EnvConfig::File(ref s)) if s == ".env"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_python_313() {
|
||||
let config = config_from_json(json!({
|
||||
"python_version": "3.13",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"}
|
||||
}));
|
||||
let result = validate_config(config).unwrap();
|
||||
assert_eq!(result.python_version.as_deref(), Some("3.13"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_python_39_error() {
|
||||
let config = config_from_json(json!({
|
||||
"python_version": "3.9",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"}
|
||||
}));
|
||||
let err = validate_config(config).unwrap_err();
|
||||
assert!(err.contains("Minimum required version"), "Expected 'Minimum required version' but got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_missing_dependencies() {
|
||||
let config = config_from_json(json!({
|
||||
"python_version": "3.11",
|
||||
"graphs": {"agent": "./agent.py:graph"}
|
||||
}));
|
||||
let err = validate_config(config).unwrap_err();
|
||||
assert!(err.contains("dependencies"), "Expected error about dependencies but got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_missing_graphs() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."]
|
||||
}));
|
||||
let err = validate_config(config).unwrap_err();
|
||||
assert!(err.contains("graphs"), "Expected error about graphs but got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_python_version_with_patch() {
|
||||
let config = config_from_json(json!({
|
||||
"python_version": "3.11.0",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"}
|
||||
}));
|
||||
let err = validate_config(config).unwrap_err();
|
||||
assert!(err.contains("Invalid Python version format"), "Expected 'Invalid Python version format' but got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_python_version_major_only() {
|
||||
let config = config_from_json(json!({
|
||||
"python_version": "3",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"}
|
||||
}));
|
||||
let err = validate_config(config).unwrap_err();
|
||||
assert!(err.contains("Invalid Python version format"), "Expected 'Invalid Python version format' but got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_python_version_non_numeric() {
|
||||
let config = config_from_json(json!({
|
||||
"python_version": "abc.def",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"}
|
||||
}));
|
||||
let err = validate_config(config).unwrap_err();
|
||||
assert!(err.contains("Invalid Python version format"), "Expected 'Invalid Python version format' but got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_python_310_error() {
|
||||
let config = config_from_json(json!({
|
||||
"python_version": "3.10",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"}
|
||||
}));
|
||||
let err = validate_config(config).unwrap_err();
|
||||
assert!(err.contains("Minimum required version"), "Expected 'Minimum required version' but got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_python_312_slim() {
|
||||
let config = config_from_json(json!({
|
||||
"python_version": "3.12-slim",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"}
|
||||
}));
|
||||
let result = validate_config(config).unwrap();
|
||||
assert_eq!(result.python_version.as_deref(), Some("3.12-slim"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_http_app_no_colon() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"http": {"app": "../../examples/my_app.py"}
|
||||
}));
|
||||
let err = validate_config(config).unwrap_err();
|
||||
assert!(err.contains("Invalid http.app format"), "Expected 'Invalid http.app format' but got: {err}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// test_validate_config_image_distro
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_image_distro_debian() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"image_distro": "debian"
|
||||
}));
|
||||
let result = validate_config(config).unwrap();
|
||||
assert_eq!(result.image_distro.as_deref(), Some("debian"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_image_distro_wolfi() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"image_distro": "wolfi"
|
||||
}));
|
||||
let result = validate_config(config).unwrap();
|
||||
assert_eq!(result.image_distro.as_deref(), Some("wolfi"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_image_distro_default() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"}
|
||||
}));
|
||||
let result = validate_config(config).unwrap();
|
||||
assert_eq!(result.image_distro.as_deref(), Some("debian"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_image_distro_bullseye_error() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"image_distro": "bullseye"
|
||||
}));
|
||||
let err = validate_config(config).unwrap_err();
|
||||
assert!(err.contains("Bullseye images were deprecated"), "Expected 'Bullseye images were deprecated' but got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_image_distro_ubuntu_error() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"image_distro": "ubuntu"
|
||||
}));
|
||||
let err = validate_config(config).unwrap_err();
|
||||
assert!(err.contains("Invalid image_distro: 'ubuntu'"), "Expected \"Invalid image_distro: 'ubuntu'\" but got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_image_distro_alpine_error() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"image_distro": "alpine"
|
||||
}));
|
||||
let err = validate_config(config).unwrap_err();
|
||||
assert!(err.contains("Invalid image_distro: 'alpine'"), "Expected \"Invalid image_distro: 'alpine'\" but got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_node_with_wolfi() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.mts:graph"},
|
||||
"image_distro": "wolfi"
|
||||
}));
|
||||
let result = validate_config(config).unwrap();
|
||||
assert_eq!(result.image_distro.as_deref(), Some("wolfi"));
|
||||
assert_eq!(result.node_version.as_deref(), Some("20"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_node_default_distro() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.mts:graph"}
|
||||
}));
|
||||
let result = validate_config(config).unwrap();
|
||||
assert_eq!(result.image_distro.as_deref(), Some("debian"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// test_validate_config_pip_installer
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_pip_installer_auto() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"pip_installer": "auto"
|
||||
}));
|
||||
let result = validate_config(config).unwrap();
|
||||
assert_eq!(result.pip_installer.as_deref(), Some("auto"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_pip_installer_pip() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"pip_installer": "pip"
|
||||
}));
|
||||
let result = validate_config(config).unwrap();
|
||||
assert_eq!(result.pip_installer.as_deref(), Some("pip"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_pip_installer_uv() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"pip_installer": "uv"
|
||||
}));
|
||||
let result = validate_config(config).unwrap();
|
||||
assert_eq!(result.pip_installer.as_deref(), Some("uv"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_pip_installer_default() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"}
|
||||
}));
|
||||
let result = validate_config(config).unwrap();
|
||||
assert_eq!(result.pip_installer.as_deref(), Some("auto"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_pip_installer_conda_error() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"pip_installer": "conda"
|
||||
}));
|
||||
let err = validate_config(config).unwrap_err();
|
||||
assert!(err.contains("Invalid pip_installer: 'conda'"), "Expected \"Invalid pip_installer: 'conda'\" but got: {err}");
|
||||
assert!(err.contains("Must be 'auto', 'pip', or 'uv'"), "Expected \"Must be 'auto', 'pip', or 'uv'\" but got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_pip_installer_invalid_error() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"pip_installer": "invalid"
|
||||
}));
|
||||
let err = validate_config(config).unwrap_err();
|
||||
assert!(err.contains("Invalid pip_installer: 'invalid'"), "Expected \"Invalid pip_installer: 'invalid'\" but got: {err}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// test_validate_config_multiplatform
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_js_only_no_explicit_versions() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./js.mts:graph"}
|
||||
}));
|
||||
let result = validate_config(config).unwrap();
|
||||
assert_eq!(result.node_version.as_deref(), Some("20"));
|
||||
assert_eq!(result.python_version, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_both_versions_explicit() {
|
||||
let config = config_from_json(json!({
|
||||
"python_version": "3.12",
|
||||
"node_version": "22",
|
||||
"dependencies": ["."],
|
||||
"graphs": {
|
||||
"agent_py": "./agent.py:graph",
|
||||
"agent_js": "./agent.mts:graph"
|
||||
}
|
||||
}));
|
||||
let result = validate_config(config).unwrap();
|
||||
assert_eq!(result.python_version.as_deref(), Some("3.12"));
|
||||
assert_eq!(result.node_version.as_deref(), Some("22"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_mixed_graphs_no_explicit_versions() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {
|
||||
"agent_py": "./agent.py:graph",
|
||||
"agent_js": "./agent.mts:graph"
|
||||
}
|
||||
}));
|
||||
let result = validate_config(config).unwrap();
|
||||
assert_eq!(result.node_version.as_deref(), Some("20"));
|
||||
assert_eq!(result.python_version.as_deref(), Some("3.11"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_mixed_graphs_node_version_only() {
|
||||
let config = config_from_json(json!({
|
||||
"node_version": "22",
|
||||
"dependencies": ["."],
|
||||
"graphs": {
|
||||
"agent_py": "./agent.py:graph",
|
||||
"agent_js": "./agent.mts:graph"
|
||||
}
|
||||
}));
|
||||
let result = validate_config(config).unwrap();
|
||||
assert_eq!(result.node_version.as_deref(), Some("22"));
|
||||
assert_eq!(result.python_version.as_deref(), Some("3.11"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_mixed_graphs_python_version_only() {
|
||||
let config = config_from_json(json!({
|
||||
"python_version": "3.12",
|
||||
"dependencies": ["."],
|
||||
"graphs": {
|
||||
"agent_py": "./agent.py:graph",
|
||||
"agent_js": "./agent.mts:graph"
|
||||
}
|
||||
}));
|
||||
let result = validate_config(config).unwrap();
|
||||
assert_eq!(result.node_version.as_deref(), Some("20"));
|
||||
assert_eq!(result.python_version.as_deref(), Some("3.12"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_unknown_extension_assumes_python() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "local.workflow:graph"}
|
||||
}));
|
||||
let result = validate_config(config).unwrap();
|
||||
assert_eq!(result.python_version.as_deref(), Some("3.11"));
|
||||
assert_eq!(result.node_version, None);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// test_validate_config_encryption
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_encryption_valid() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"encryption": {"path": "./encryption.py:encryption"}
|
||||
}));
|
||||
let result = validate_config(config).unwrap();
|
||||
assert!(result.encryption.is_some());
|
||||
assert_eq!(
|
||||
result.encryption.as_ref().unwrap().path.as_deref(),
|
||||
Some("./encryption.py:encryption")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_encryption_no_colon_error() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"encryption": {"path": "./encryption.py"}
|
||||
}));
|
||||
let err = validate_config(config).unwrap_err();
|
||||
assert!(err.contains("Invalid encryption.path format"), "Expected 'Invalid encryption.path format' but got: {err}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// test_validate_config_file
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_file_node_config() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config_path = tmp.path().join("langgraph.json");
|
||||
let config_json = json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.mts:graph"}
|
||||
});
|
||||
let mut f = std::fs::File::create(&config_path).unwrap();
|
||||
write!(f, "{}", serde_json::to_string(&config_json).unwrap()).unwrap();
|
||||
drop(f);
|
||||
|
||||
let result = validate_config_file(&config_path).unwrap();
|
||||
assert_eq!(result.node_version.as_deref(), Some("20"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_file_with_valid_package_json() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config_path = tmp.path().join("langgraph.json");
|
||||
let config_json = json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.mts:graph"}
|
||||
});
|
||||
std::fs::write(&config_path, serde_json::to_string(&config_json).unwrap()).unwrap();
|
||||
|
||||
let pkg_path = tmp.path().join("package.json");
|
||||
let pkg_json = json!({
|
||||
"engines": {"node": "20"}
|
||||
});
|
||||
std::fs::write(&pkg_path, serde_json::to_string(&pkg_json).unwrap()).unwrap();
|
||||
|
||||
let result = validate_config_file(&config_path).unwrap();
|
||||
assert_eq!(result.node_version.as_deref(), Some("20"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_file_package_json_minor_version_error() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config_path = tmp.path().join("langgraph.json");
|
||||
let config_json = json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.mts:graph"}
|
||||
});
|
||||
std::fs::write(&config_path, serde_json::to_string(&config_json).unwrap()).unwrap();
|
||||
|
||||
let pkg_path = tmp.path().join("package.json");
|
||||
let pkg_json = json!({
|
||||
"engines": {"node": "20.18"}
|
||||
});
|
||||
std::fs::write(&pkg_path, serde_json::to_string(&pkg_json).unwrap()).unwrap();
|
||||
|
||||
let err = validate_config_file(&config_path).unwrap_err();
|
||||
assert!(err.contains("Use major version only"), "Expected 'Use major version only' but got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_file_package_json_old_node_error() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config_path = tmp.path().join("langgraph.json");
|
||||
let config_json = json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.mts:graph"}
|
||||
});
|
||||
std::fs::write(&config_path, serde_json::to_string(&config_json).unwrap()).unwrap();
|
||||
|
||||
let pkg_path = tmp.path().join("package.json");
|
||||
let pkg_json = json!({
|
||||
"engines": {"node": "18"}
|
||||
});
|
||||
std::fs::write(&pkg_path, serde_json::to_string(&pkg_json).unwrap()).unwrap();
|
||||
|
||||
let err = validate_config_file(&config_path).unwrap_err();
|
||||
assert!(err.contains("must be >= 20"), "Expected 'must be >= 20' but got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_file_package_json_deno_engine_error() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config_path = tmp.path().join("langgraph.json");
|
||||
let config_json = json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.mts:graph"}
|
||||
});
|
||||
std::fs::write(&config_path, serde_json::to_string(&config_json).unwrap()).unwrap();
|
||||
|
||||
let pkg_path = tmp.path().join("package.json");
|
||||
let pkg_json = json!({
|
||||
"engines": {"node": "20", "deno": "1.0"}
|
||||
});
|
||||
std::fs::write(&pkg_path, serde_json::to_string(&pkg_json).unwrap()).unwrap();
|
||||
|
||||
let err = validate_config_file(&config_path).unwrap_err();
|
||||
assert!(err.contains("Only 'node' engine is supported"), "Expected 'Only node engine is supported' but got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_file_invalid_package_json() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config_path = tmp.path().join("langgraph.json");
|
||||
let config_json = json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.mts:graph"}
|
||||
});
|
||||
std::fs::write(&config_path, serde_json::to_string(&config_json).unwrap()).unwrap();
|
||||
|
||||
let pkg_path = tmp.path().join("package.json");
|
||||
std::fs::write(&pkg_path, "this is not valid json!!!").unwrap();
|
||||
|
||||
let err = validate_config_file(&config_path).unwrap_err();
|
||||
assert!(err.contains("Invalid package.json"), "Expected 'Invalid package.json' but got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_file_python_ignores_bad_package_json() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config_path = tmp.path().join("langgraph.json");
|
||||
let config_json = json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"}
|
||||
});
|
||||
std::fs::write(&config_path, serde_json::to_string(&config_json).unwrap()).unwrap();
|
||||
|
||||
// Write a bad package.json - should be ignored for Python-only config
|
||||
let pkg_path = tmp.path().join("package.json");
|
||||
std::fs::write(&pkg_path, "this is not valid json!!!").unwrap();
|
||||
|
||||
let result = validate_config_file(&config_path).unwrap();
|
||||
assert_eq!(result.python_version.as_deref(), Some("3.11"));
|
||||
}
|
||||
}
|
||||
@@ -1,324 +0,0 @@
|
||||
use std::path::Path;
|
||||
|
||||
use super::local_deps::LocalDeps;
|
||||
use super::schema::{Config, GraphSpec};
|
||||
|
||||
/// Remap each graph's import path to the correct in-container path.
|
||||
pub fn update_graph_paths(
|
||||
config_path: &Path,
|
||||
config: &mut Config,
|
||||
local_deps: &LocalDeps,
|
||||
) -> Result<(), String> {
|
||||
let config_parent = config_path.parent().unwrap();
|
||||
let graph_ids: Vec<String> = config.graphs.keys().cloned().collect();
|
||||
|
||||
for graph_id in graph_ids {
|
||||
let import_str = {
|
||||
let spec = config.graphs.get(&graph_id).unwrap();
|
||||
match spec {
|
||||
GraphSpec::Path(s) => s.clone(),
|
||||
GraphSpec::Dict(m) => {
|
||||
if let Some(path_val) = m.get("path") {
|
||||
path_val
|
||||
.as_str()
|
||||
.ok_or_else(|| {
|
||||
format!("Graph '{graph_id}' path must be a string")
|
||||
})?
|
||||
.to_string()
|
||||
} else {
|
||||
return Err(format!(
|
||||
"Graph '{graph_id}' must contain a 'path' key if it is a dictionary."
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let (module_str, attr_str) = match import_str.split_once(':') {
|
||||
Some((m, a)) if !m.is_empty() && !a.is_empty() => (m, a),
|
||||
_ => {
|
||||
return Err(format!(
|
||||
"Import string \"{import_str}\" must be in format \"<module>:<attribute>\"."
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// Check for file path (contains / or \)
|
||||
if module_str.contains('/') || module_str.contains('\\') {
|
||||
let resolved = config_parent
|
||||
.join(module_str)
|
||||
.canonicalize()
|
||||
.map_err(|_| format!("Could not find local module: {}", config_parent.join(module_str).display()))?;
|
||||
|
||||
if !resolved.exists() {
|
||||
return Err(format!("Could not find local module: {}", resolved.display()));
|
||||
}
|
||||
if !resolved.is_file() {
|
||||
return Err(format!("Local module must be a file: {}", resolved.display()));
|
||||
}
|
||||
|
||||
let mut new_module = None;
|
||||
|
||||
// Check real packages
|
||||
for (path, (_, container_name)) in &local_deps.real_pkgs {
|
||||
if resolved.starts_with(path) {
|
||||
if let Ok(relative) = resolved.strip_prefix(path) {
|
||||
let container_path =
|
||||
format!("/deps/{}/{}", container_name, relative.to_string_lossy().replace('\\', "/"));
|
||||
new_module = Some(container_path);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check faux packages
|
||||
if new_module.is_none() {
|
||||
for (faux_path, (_, destpath)) in &local_deps.faux_pkgs {
|
||||
if resolved.starts_with(faux_path) {
|
||||
if let Ok(relative) = resolved.strip_prefix(faux_path) {
|
||||
new_module = Some(format!(
|
||||
"{}/{}",
|
||||
destpath,
|
||||
relative.to_string_lossy().replace('\\', "/")
|
||||
));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(new_mod) = new_module {
|
||||
let new_path = format!("{new_mod}:{attr_str}");
|
||||
config.graphs.get_mut(&graph_id).unwrap().set_path(new_path);
|
||||
} else {
|
||||
return Err(format!(
|
||||
"Module '{import_str}' not found in 'dependencies' list. \
|
||||
Add its containing package to 'dependencies' list."
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update auth.path to use Docker container paths.
|
||||
pub fn update_auth_path(
|
||||
config_path: &Path,
|
||||
config: &mut Config,
|
||||
local_deps: &LocalDeps,
|
||||
) -> Result<(), String> {
|
||||
let auth_conf = match config.auth.as_mut() {
|
||||
Some(a) => a,
|
||||
None => return Ok(()),
|
||||
};
|
||||
let path_str = match auth_conf.path.as_ref() {
|
||||
Some(p) => p.clone(),
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
let (module_str, attr_str) = match path_str.split_once(':') {
|
||||
Some((m, a)) => (m, a),
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
if !module_str.starts_with('.') {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let config_parent = config_path.parent().unwrap();
|
||||
let resolved = config_parent
|
||||
.join(module_str)
|
||||
.canonicalize()
|
||||
.map_err(|_| format!("Auth file not found: {} (from {path_str})", config_parent.join(module_str).display()))?;
|
||||
|
||||
if !resolved.is_file() {
|
||||
return Err(format!("Auth path must be a file: {}", resolved.display()));
|
||||
}
|
||||
|
||||
// Check faux packages first
|
||||
for (faux_path, (_, destpath)) in &local_deps.faux_pkgs {
|
||||
if resolved.starts_with(faux_path) {
|
||||
if let Ok(relative) = resolved.strip_prefix(faux_path) {
|
||||
auth_conf.path = Some(format!(
|
||||
"{}/{}:{attr_str}",
|
||||
destpath,
|
||||
relative.to_string_lossy()
|
||||
));
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check real packages
|
||||
for (real_path, _) in &local_deps.real_pkgs {
|
||||
if resolved.starts_with(real_path) {
|
||||
if let Ok(relative) = resolved.strip_prefix(real_path) {
|
||||
let dir_name = real_path.file_name().unwrap().to_string_lossy();
|
||||
auth_conf.path = Some(format!(
|
||||
"/deps/{}/{}:{attr_str}",
|
||||
dir_name,
|
||||
relative.to_string_lossy()
|
||||
));
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"Auth file '{}' not covered by dependencies.\n\
|
||||
Add its parent directory to the 'dependencies' array in your config.",
|
||||
resolved.display()
|
||||
))
|
||||
}
|
||||
|
||||
/// Update encryption.path to use Docker container paths.
|
||||
pub fn update_encryption_path(
|
||||
config_path: &Path,
|
||||
config: &mut Config,
|
||||
local_deps: &LocalDeps,
|
||||
) -> Result<(), String> {
|
||||
let encryption_conf = match config.encryption.as_mut() {
|
||||
Some(e) => e,
|
||||
None => return Ok(()),
|
||||
};
|
||||
let path_str = match encryption_conf.path.as_ref() {
|
||||
Some(p) => p.clone(),
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
let (module_str, attr_str) = match path_str.split_once(':') {
|
||||
Some((m, a)) => (m, a),
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
if !module_str.starts_with('.') {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let config_parent = config_path.parent().unwrap();
|
||||
let resolved = config_parent
|
||||
.join(module_str)
|
||||
.canonicalize()
|
||||
.map_err(|_| {
|
||||
format!(
|
||||
"Encryption file not found: {} (from {path_str})",
|
||||
config_parent.join(module_str).display()
|
||||
)
|
||||
})?;
|
||||
|
||||
if !resolved.is_file() {
|
||||
return Err(format!(
|
||||
"Encryption path must be a file: {}",
|
||||
resolved.display()
|
||||
));
|
||||
}
|
||||
|
||||
for (faux_path, (_, destpath)) in &local_deps.faux_pkgs {
|
||||
if resolved.starts_with(faux_path) {
|
||||
if let Ok(relative) = resolved.strip_prefix(faux_path) {
|
||||
encryption_conf.path = Some(format!(
|
||||
"{}/{}:{attr_str}",
|
||||
destpath,
|
||||
relative.to_string_lossy()
|
||||
));
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (real_path, _) in &local_deps.real_pkgs {
|
||||
if resolved.starts_with(real_path) {
|
||||
if let Ok(relative) = resolved.strip_prefix(real_path) {
|
||||
let dir_name = real_path.file_name().unwrap().to_string_lossy();
|
||||
encryption_conf.path = Some(format!(
|
||||
"/deps/{}/{}:{attr_str}",
|
||||
dir_name,
|
||||
relative.to_string_lossy()
|
||||
));
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"Encryption file '{}' not covered by dependencies.\n\
|
||||
Add its parent directory to the 'dependencies' array in your config.",
|
||||
resolved.display()
|
||||
))
|
||||
}
|
||||
|
||||
/// Update the HTTP app path to point to the correct location in the Docker container.
|
||||
pub fn update_http_app_path(
|
||||
config_path: &Path,
|
||||
config: &mut Config,
|
||||
local_deps: &LocalDeps,
|
||||
) -> Result<(), String> {
|
||||
let http_config = match config.http.as_mut() {
|
||||
Some(h) => h,
|
||||
None => return Ok(()),
|
||||
};
|
||||
let app_str = match http_config.app.as_ref() {
|
||||
Some(a) => a.clone(),
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
let (module_str, attr_str) = match app_str.split_once(':') {
|
||||
Some((m, a)) if !m.is_empty() && !a.is_empty() => (m, a),
|
||||
_ => {
|
||||
return Err(format!(
|
||||
"Import string \"{app_str}\" must be in format \"<module>:<attribute>\"."
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
if !module_str.contains('/') && !module_str.contains('\\') {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let config_parent = config_path.parent().unwrap();
|
||||
let resolved = config_parent
|
||||
.join(module_str)
|
||||
.canonicalize()
|
||||
.map_err(|_| format!("Could not find HTTP app module: {}", config_parent.join(module_str).display()))?;
|
||||
|
||||
if !resolved.is_file() {
|
||||
return Err(format!(
|
||||
"HTTP app module must be a file: {}",
|
||||
resolved.display()
|
||||
));
|
||||
}
|
||||
|
||||
// Check real packages
|
||||
for (path, (_, _name)) in &local_deps.real_pkgs {
|
||||
if resolved.starts_with(path) {
|
||||
if let Ok(relative) = resolved.strip_prefix(path) {
|
||||
let dir_name = path.file_name().unwrap().to_string_lossy();
|
||||
http_config.app = Some(format!(
|
||||
"/deps/{}/{}:{attr_str}",
|
||||
dir_name,
|
||||
relative.to_string_lossy().replace('\\', "/")
|
||||
));
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check faux packages
|
||||
for (faux_path, (_, destpath)) in &local_deps.faux_pkgs {
|
||||
if resolved.starts_with(faux_path) {
|
||||
if let Ok(relative) = resolved.strip_prefix(faux_path) {
|
||||
http_config.app = Some(format!(
|
||||
"{}/{}:{attr_str}",
|
||||
destpath,
|
||||
relative.to_string_lossy().replace('\\', "/")
|
||||
));
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"HTTP app module '{app_str}' not found in 'dependencies' list. \
|
||||
Add its containing package to 'dependencies' list."
|
||||
))
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
use indexmap::IndexMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// Top-level config for langgraph-cli deployment.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct Config {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub python_version: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub node_version: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub api_version: Option<String>,
|
||||
|
||||
#[serde(rename = "_INTERNAL_docker_tag", skip_serializing_if = "Option::is_none")]
|
||||
pub internal_docker_tag: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub base_image: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub image_distro: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub pip_config_file: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub pip_installer: Option<String>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub dockerfile_lines: Vec<String>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub dependencies: Vec<String>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "IndexMap::is_empty")]
|
||||
pub graphs: IndexMap<String, GraphSpec>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub env: Option<EnvConfig>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub store: Option<Value>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub auth: Option<AuthConfig>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub encryption: Option<EncryptionConfig>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub http: Option<HttpConfig>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub webhooks: Option<Value>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub checkpointer: Option<Value>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ui: Option<IndexMap<String, String>>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ui_config: Option<Value>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub keep_pkg_tools: Option<KeepPkgTools>,
|
||||
}
|
||||
|
||||
/// Graph specification: either a string path or a dict with a "path" key.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum GraphSpec {
|
||||
Path(String),
|
||||
Dict(IndexMap<String, Value>),
|
||||
}
|
||||
|
||||
impl GraphSpec {
|
||||
pub fn set_path(&mut self, new_path: String) {
|
||||
match self {
|
||||
GraphSpec::Path(s) => *s = new_path,
|
||||
GraphSpec::Dict(m) => {
|
||||
m.insert("path".to_string(), Value::String(new_path));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Environment config: either a dict of key-value pairs or a path to an env file.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum EnvConfig {
|
||||
Dict(IndexMap<String, String>),
|
||||
File(String),
|
||||
}
|
||||
|
||||
impl Default for EnvConfig {
|
||||
fn default() -> Self {
|
||||
EnvConfig::Dict(IndexMap::new())
|
||||
}
|
||||
}
|
||||
|
||||
/// Auth configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AuthConfig {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub disable_studio_auth: Option<bool>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub openapi: Option<Value>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache: Option<Value>,
|
||||
}
|
||||
|
||||
/// Encryption configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EncryptionConfig {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
/// HTTP configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HttpConfig {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub app: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub disable_assistants: Option<bool>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub disable_threads: Option<bool>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub disable_runs: Option<bool>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub disable_store: Option<bool>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub disable_mcp: Option<bool>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub disable_a2a: Option<bool>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub disable_meta: Option<bool>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub disable_ui: Option<bool>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub disable_webhooks: Option<bool>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cors: Option<Value>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub configurable_headers: Option<Value>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub logging_headers: Option<Value>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub middleware_order: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enable_custom_route_auth: Option<bool>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mount_prefix: Option<String>,
|
||||
}
|
||||
|
||||
/// Keep package tools config: either a boolean or a list of tool names.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum KeepPkgTools {
|
||||
Bool(bool),
|
||||
List(Vec<String>),
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
pub const MIN_NODE_VERSION: u32 = 20;
|
||||
pub const DEFAULT_NODE_VERSION: u32 = 20;
|
||||
|
||||
pub const MIN_PYTHON_VERSION: (u32, u32) = (3, 11);
|
||||
pub const DEFAULT_PYTHON_VERSION: &str = "3.11";
|
||||
|
||||
pub const DEFAULT_IMAGE_DISTRO: &str = "debian";
|
||||
|
||||
pub const BUILD_TOOLS: &[&str] = &["pip", "setuptools", "wheel"];
|
||||
|
||||
// Analytics
|
||||
pub const SUPABASE_PUBLIC_API_KEY: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imt6cmxwcG9qaW5wY3l5YWlweG5iIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MTkyNTc1NzksImV4cCI6MjAzNDgzMzU3OX0.kkVOlLz3BxemA5nP-vat3K4qRtrDuO4SwZSR_htcX9c";
|
||||
pub const SUPABASE_URL: &str = "https://kzrlppojinpcyyaipxnb.supabase.co";
|
||||
|
||||
pub const DEFAULT_POSTGRES_URI: &str =
|
||||
"postgres://postgres:postgres@langgraph-postgres:5432/postgres?sslmode=disable";
|
||||
|
||||
pub const VALID_DISTROS: &[&str] = &["debian", "wolfi", "bookworm"];
|
||||
pub const VALID_PIP_INSTALLERS: &[&str] = &["auto", "pip", "uv"];
|
||||
|
||||
pub const RESERVED_PACKAGE_NAMES: &[&str] = &[
|
||||
"src",
|
||||
"langgraph-api",
|
||||
"langgraph_api",
|
||||
"langgraph",
|
||||
"langchain-core",
|
||||
"langchain_core",
|
||||
"pydantic",
|
||||
"orjson",
|
||||
"fastapi",
|
||||
"uvicorn",
|
||||
"psycopg",
|
||||
"httpx",
|
||||
"langsmith",
|
||||
];
|
||||
@@ -1,185 +0,0 @@
|
||||
use std::process::Command;
|
||||
|
||||
/// Semantic version tuple.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct Version {
|
||||
pub major: u32,
|
||||
pub minor: u32,
|
||||
pub patch: u32,
|
||||
}
|
||||
|
||||
impl Version {
|
||||
pub fn new(major: u32, minor: u32, patch: u32) -> Self {
|
||||
Self {
|
||||
major,
|
||||
minor,
|
||||
patch,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Type of Docker Compose installation.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ComposeType {
|
||||
Plugin,
|
||||
Standalone,
|
||||
}
|
||||
|
||||
/// Docker capabilities detected on the system.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DockerCapabilities {
|
||||
#[allow(dead_code)]
|
||||
pub version_docker: Version,
|
||||
#[allow(dead_code)]
|
||||
pub version_compose: Version,
|
||||
pub healthcheck_start_interval: bool,
|
||||
pub compose_type: ComposeType,
|
||||
}
|
||||
|
||||
/// Parse a version string like "1.2.3", "v1.2.3-alpha", etc.
|
||||
pub fn parse_version(version: &str) -> Version {
|
||||
let cleaned = version.trim();
|
||||
let parts: Vec<&str> = cleaned.split('.').collect();
|
||||
|
||||
let parse_part = |s: &str| -> u32 {
|
||||
let s = s.trim_start_matches('v');
|
||||
let s = s.split('-').next().unwrap_or(s);
|
||||
let s = s.split('+').next().unwrap_or(s);
|
||||
s.parse().unwrap_or(0)
|
||||
};
|
||||
|
||||
match parts.len() {
|
||||
1 => Version::new(parse_part(parts[0]), 0, 0),
|
||||
2 => Version::new(parse_part(parts[0]), parse_part(parts[1]), 0),
|
||||
_ => Version::new(
|
||||
parse_part(parts[0]),
|
||||
parse_part(parts[1]),
|
||||
parse_part(parts[2]),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check Docker capabilities on the system.
|
||||
pub fn check_capabilities() -> Result<DockerCapabilities, String> {
|
||||
// Check docker is available
|
||||
if which::which("docker").is_err() {
|
||||
return Err("Docker not installed".to_string());
|
||||
}
|
||||
|
||||
// Get docker info
|
||||
let output = Command::new("docker")
|
||||
.args(["info", "-f", "{{json .}}"])
|
||||
.output()
|
||||
.map_err(|_| "Docker not installed or not running".to_string())?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err("Docker not installed or not running".to_string());
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let info: serde_json::Value =
|
||||
serde_json::from_str(&stdout).map_err(|_| "Docker not installed or not running".to_string())?;
|
||||
|
||||
let server_version = info
|
||||
.get("ServerVersion")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Docker not running")?;
|
||||
|
||||
if server_version.is_empty() {
|
||||
return Err("Docker not running".to_string());
|
||||
}
|
||||
|
||||
// Try to find compose as plugin
|
||||
let (compose_version_str, compose_type) = if let Some(plugins) = info
|
||||
.get("ClientInfo")
|
||||
.and_then(|ci| ci.get("Plugins"))
|
||||
.and_then(|p| p.as_array())
|
||||
{
|
||||
if let Some(compose) = plugins
|
||||
.iter()
|
||||
.find(|p| p.get("Name").and_then(|n| n.as_str()) == Some("compose"))
|
||||
{
|
||||
let version = compose
|
||||
.get("Version")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("0.0.0");
|
||||
(version.to_string(), ComposeType::Plugin)
|
||||
} else {
|
||||
get_standalone_compose_version()?
|
||||
}
|
||||
} else {
|
||||
get_standalone_compose_version()?
|
||||
};
|
||||
|
||||
let docker_version = parse_version(server_version);
|
||||
let compose_version = parse_version(&compose_version_str);
|
||||
|
||||
Ok(DockerCapabilities {
|
||||
version_docker: docker_version,
|
||||
version_compose: compose_version,
|
||||
healthcheck_start_interval: docker_version >= Version::new(25, 0, 0),
|
||||
compose_type,
|
||||
})
|
||||
}
|
||||
|
||||
fn get_standalone_compose_version() -> Result<(String, ComposeType), String> {
|
||||
if which::which("docker-compose").is_err() {
|
||||
return Err("Docker Compose not installed".to_string());
|
||||
}
|
||||
|
||||
let output = Command::new("docker-compose")
|
||||
.args(["--version", "--short"])
|
||||
.output()
|
||||
.map_err(|_| "Docker Compose not installed".to_string())?;
|
||||
|
||||
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
Ok((version, ComposeType::Standalone))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_version_basic() {
|
||||
assert_eq!(parse_version("1.2.3"), Version::new(1, 2, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_version_with_v() {
|
||||
assert_eq!(parse_version("v1.2.3"), Version::new(1, 2, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_version_with_prerelease() {
|
||||
assert_eq!(parse_version("1.2.3-alpha"), Version::new(1, 2, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_version_with_build() {
|
||||
assert_eq!(parse_version("1.2.3+1"), Version::new(1, 2, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_version_two_parts() {
|
||||
assert_eq!(parse_version("1.2"), Version::new(1, 2, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_version_one_part() {
|
||||
assert_eq!(parse_version("1"), Version::new(1, 0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_version_complex() {
|
||||
assert_eq!(parse_version("v28.1.1+1"), Version::new(28, 1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_version_beta() {
|
||||
assert_eq!(
|
||||
parse_version("2.0.0-beta.1+exp.sha.5114f85"),
|
||||
Version::new(2, 0, 0)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,791 +0,0 @@
|
||||
use indexmap::IndexMap;
|
||||
|
||||
use super::capabilities::DockerCapabilities;
|
||||
use crate::constants::DEFAULT_POSTGRES_URI;
|
||||
|
||||
/// Value types for our custom YAML writer.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum YamlValue {
|
||||
String(String),
|
||||
Dict(IndexMap<String, YamlValue>),
|
||||
List(Vec<String>),
|
||||
}
|
||||
|
||||
/// Convert a dictionary to a YAML string with custom formatting.
|
||||
/// This matches the Python dict_to_yaml() output exactly.
|
||||
pub fn dict_to_yaml(d: &IndexMap<String, YamlValue>, indent: usize) -> String {
|
||||
let mut yaml_str = String::new();
|
||||
|
||||
for (idx, (key, value)) in d.iter().enumerate() {
|
||||
// Extra newline for top-level keys only (after the first)
|
||||
if idx >= 1 && indent < 2 {
|
||||
yaml_str.push('\n');
|
||||
}
|
||||
let space = " ".repeat(indent);
|
||||
match value {
|
||||
YamlValue::Dict(inner) => {
|
||||
yaml_str.push_str(&format!("{space}{key}:\n"));
|
||||
yaml_str.push_str(&dict_to_yaml(inner, indent + 1));
|
||||
}
|
||||
YamlValue::List(items) => {
|
||||
yaml_str.push_str(&format!("{space}{key}:\n"));
|
||||
for item in items {
|
||||
yaml_str.push_str(&format!("{space} - {item}\n"));
|
||||
}
|
||||
}
|
||||
YamlValue::String(val) => {
|
||||
yaml_str.push_str(&format!("{space}{key}: {val}\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
yaml_str
|
||||
}
|
||||
|
||||
/// Create debugger service config.
|
||||
pub fn debugger_compose(port: Option<u16>, base_url: Option<&str>) -> IndexMap<String, YamlValue> {
|
||||
let port = match port {
|
||||
Some(p) => p,
|
||||
None => return IndexMap::new(),
|
||||
};
|
||||
|
||||
let mut debugger = IndexMap::new();
|
||||
debugger.insert(
|
||||
"image".to_string(),
|
||||
YamlValue::String("langchain/langgraph-debugger".to_string()),
|
||||
);
|
||||
debugger.insert(
|
||||
"restart".to_string(),
|
||||
YamlValue::String("on-failure".to_string()),
|
||||
);
|
||||
|
||||
let mut depends = IndexMap::new();
|
||||
let mut pg_condition = IndexMap::new();
|
||||
pg_condition.insert(
|
||||
"condition".to_string(),
|
||||
YamlValue::String("service_healthy".to_string()),
|
||||
);
|
||||
depends.insert("langgraph-postgres".to_string(), YamlValue::Dict(pg_condition));
|
||||
debugger.insert("depends_on".to_string(), YamlValue::Dict(depends));
|
||||
|
||||
debugger.insert(
|
||||
"ports".to_string(),
|
||||
YamlValue::List(vec![format!("\"{port}:3968\"")]),
|
||||
);
|
||||
|
||||
if let Some(url) = base_url {
|
||||
let mut env = IndexMap::new();
|
||||
env.insert(
|
||||
"VITE_STUDIO_LOCAL_GRAPH_URL".to_string(),
|
||||
YamlValue::String(url.to_string()),
|
||||
);
|
||||
debugger.insert("environment".to_string(), YamlValue::Dict(env));
|
||||
}
|
||||
|
||||
let mut result = IndexMap::new();
|
||||
result.insert("langgraph-debugger".to_string(), YamlValue::Dict(debugger));
|
||||
result
|
||||
}
|
||||
|
||||
/// Create a docker compose file as a dictionary.
|
||||
pub fn compose_as_dict(
|
||||
capabilities: &DockerCapabilities,
|
||||
port: u16,
|
||||
debugger_port: Option<u16>,
|
||||
debugger_base_url: Option<&str>,
|
||||
postgres_uri: Option<&str>,
|
||||
image: Option<&str>,
|
||||
_base_image: Option<&str>,
|
||||
_api_version: Option<&str>,
|
||||
) -> IndexMap<String, YamlValue> {
|
||||
let include_db = postgres_uri.is_none();
|
||||
let postgres_uri = postgres_uri.unwrap_or(DEFAULT_POSTGRES_URI);
|
||||
|
||||
let mut services = IndexMap::new();
|
||||
|
||||
// Redis service
|
||||
let mut redis = IndexMap::new();
|
||||
redis.insert(
|
||||
"image".to_string(),
|
||||
YamlValue::String("redis:6".to_string()),
|
||||
);
|
||||
let mut redis_healthcheck = IndexMap::new();
|
||||
redis_healthcheck.insert(
|
||||
"test".to_string(),
|
||||
YamlValue::String("redis-cli ping".to_string()),
|
||||
);
|
||||
redis_healthcheck.insert(
|
||||
"interval".to_string(),
|
||||
YamlValue::String("5s".to_string()),
|
||||
);
|
||||
redis_healthcheck.insert(
|
||||
"timeout".to_string(),
|
||||
YamlValue::String("1s".to_string()),
|
||||
);
|
||||
redis_healthcheck.insert(
|
||||
"retries".to_string(),
|
||||
YamlValue::String("5".to_string()),
|
||||
);
|
||||
redis.insert("healthcheck".to_string(), YamlValue::Dict(redis_healthcheck));
|
||||
services.insert("langgraph-redis".to_string(), YamlValue::Dict(redis));
|
||||
|
||||
// Postgres service (if needed)
|
||||
if include_db {
|
||||
let mut postgres = IndexMap::new();
|
||||
postgres.insert(
|
||||
"image".to_string(),
|
||||
YamlValue::String("pgvector/pgvector:pg16".to_string()),
|
||||
);
|
||||
postgres.insert(
|
||||
"ports".to_string(),
|
||||
YamlValue::List(vec!["\"5433:5432\"".to_string()]),
|
||||
);
|
||||
|
||||
let mut pg_env = IndexMap::new();
|
||||
pg_env.insert(
|
||||
"POSTGRES_DB".to_string(),
|
||||
YamlValue::String("postgres".to_string()),
|
||||
);
|
||||
pg_env.insert(
|
||||
"POSTGRES_USER".to_string(),
|
||||
YamlValue::String("postgres".to_string()),
|
||||
);
|
||||
pg_env.insert(
|
||||
"POSTGRES_PASSWORD".to_string(),
|
||||
YamlValue::String("postgres".to_string()),
|
||||
);
|
||||
postgres.insert("environment".to_string(), YamlValue::Dict(pg_env));
|
||||
|
||||
postgres.insert(
|
||||
"command".to_string(),
|
||||
YamlValue::List(vec![
|
||||
"postgres".to_string(),
|
||||
"-c".to_string(),
|
||||
"shared_preload_libraries=vector".to_string(),
|
||||
]),
|
||||
);
|
||||
|
||||
postgres.insert(
|
||||
"volumes".to_string(),
|
||||
YamlValue::List(vec![
|
||||
"langgraph-data:/var/lib/postgresql/data".to_string(),
|
||||
]),
|
||||
);
|
||||
|
||||
let mut pg_healthcheck = IndexMap::new();
|
||||
pg_healthcheck.insert(
|
||||
"test".to_string(),
|
||||
YamlValue::String("pg_isready -U postgres".to_string()),
|
||||
);
|
||||
pg_healthcheck.insert(
|
||||
"start_period".to_string(),
|
||||
YamlValue::String("10s".to_string()),
|
||||
);
|
||||
pg_healthcheck.insert(
|
||||
"timeout".to_string(),
|
||||
YamlValue::String("1s".to_string()),
|
||||
);
|
||||
pg_healthcheck.insert(
|
||||
"retries".to_string(),
|
||||
YamlValue::String("5".to_string()),
|
||||
);
|
||||
|
||||
if capabilities.healthcheck_start_interval {
|
||||
pg_healthcheck.insert(
|
||||
"interval".to_string(),
|
||||
YamlValue::String("60s".to_string()),
|
||||
);
|
||||
pg_healthcheck.insert(
|
||||
"start_interval".to_string(),
|
||||
YamlValue::String("1s".to_string()),
|
||||
);
|
||||
} else {
|
||||
pg_healthcheck.insert(
|
||||
"interval".to_string(),
|
||||
YamlValue::String("5s".to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
postgres.insert("healthcheck".to_string(), YamlValue::Dict(pg_healthcheck));
|
||||
services.insert("langgraph-postgres".to_string(), YamlValue::Dict(postgres));
|
||||
}
|
||||
|
||||
// Debugger service (if port specified)
|
||||
if let Some(dbg_port) = debugger_port {
|
||||
let debugger = debugger_compose(Some(dbg_port), debugger_base_url);
|
||||
for (k, v) in debugger {
|
||||
services.insert(k, v);
|
||||
}
|
||||
}
|
||||
|
||||
// LangGraph API service
|
||||
let mut api = IndexMap::new();
|
||||
api.insert(
|
||||
"ports".to_string(),
|
||||
YamlValue::List(vec![format!("\"{port}:8000\"")]),
|
||||
);
|
||||
|
||||
let mut api_depends = IndexMap::new();
|
||||
let mut redis_condition = IndexMap::new();
|
||||
redis_condition.insert(
|
||||
"condition".to_string(),
|
||||
YamlValue::String("service_healthy".to_string()),
|
||||
);
|
||||
api_depends.insert(
|
||||
"langgraph-redis".to_string(),
|
||||
YamlValue::Dict(redis_condition),
|
||||
);
|
||||
api.insert("depends_on".to_string(), YamlValue::Dict(api_depends.clone()));
|
||||
|
||||
let mut api_env = IndexMap::new();
|
||||
api_env.insert(
|
||||
"REDIS_URI".to_string(),
|
||||
YamlValue::String("redis://langgraph-redis:6379".to_string()),
|
||||
);
|
||||
api_env.insert(
|
||||
"POSTGRES_URI".to_string(),
|
||||
YamlValue::String(postgres_uri.to_string()),
|
||||
);
|
||||
api.insert("environment".to_string(), YamlValue::Dict(api_env));
|
||||
|
||||
if let Some(img) = image {
|
||||
api.insert(
|
||||
"image".to_string(),
|
||||
YamlValue::String(img.to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
// Add postgres dependency for API service
|
||||
if include_db {
|
||||
if let YamlValue::Dict(ref mut deps) = api.get_mut("depends_on").unwrap() {
|
||||
let mut pg_condition = IndexMap::new();
|
||||
pg_condition.insert(
|
||||
"condition".to_string(),
|
||||
YamlValue::String("service_healthy".to_string()),
|
||||
);
|
||||
deps.insert(
|
||||
"langgraph-postgres".to_string(),
|
||||
YamlValue::Dict(pg_condition),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Healthcheck for API service
|
||||
if capabilities.healthcheck_start_interval {
|
||||
let mut api_healthcheck = IndexMap::new();
|
||||
api_healthcheck.insert(
|
||||
"test".to_string(),
|
||||
YamlValue::String("python /api/healthcheck.py".to_string()),
|
||||
);
|
||||
api_healthcheck.insert(
|
||||
"interval".to_string(),
|
||||
YamlValue::String("60s".to_string()),
|
||||
);
|
||||
api_healthcheck.insert(
|
||||
"start_interval".to_string(),
|
||||
YamlValue::String("1s".to_string()),
|
||||
);
|
||||
api_healthcheck.insert(
|
||||
"start_period".to_string(),
|
||||
YamlValue::String("10s".to_string()),
|
||||
);
|
||||
api.insert("healthcheck".to_string(), YamlValue::Dict(api_healthcheck));
|
||||
}
|
||||
|
||||
services.insert("langgraph-api".to_string(), YamlValue::Dict(api));
|
||||
|
||||
// Build final compose dict
|
||||
let mut compose_dict = IndexMap::new();
|
||||
if include_db {
|
||||
let mut volumes = IndexMap::new();
|
||||
let mut vol_config = IndexMap::new();
|
||||
vol_config.insert(
|
||||
"driver".to_string(),
|
||||
YamlValue::String("local".to_string()),
|
||||
);
|
||||
volumes.insert("langgraph-data".to_string(), YamlValue::Dict(vol_config));
|
||||
compose_dict.insert("volumes".to_string(), YamlValue::Dict(volumes));
|
||||
}
|
||||
compose_dict.insert("services".to_string(), YamlValue::Dict(services));
|
||||
|
||||
compose_dict
|
||||
}
|
||||
|
||||
/// Create a docker compose file as a string.
|
||||
pub fn compose(
|
||||
capabilities: &DockerCapabilities,
|
||||
port: u16,
|
||||
debugger_port: Option<u16>,
|
||||
debugger_base_url: Option<&str>,
|
||||
postgres_uri: Option<&str>,
|
||||
image: Option<&str>,
|
||||
base_image: Option<&str>,
|
||||
api_version: Option<&str>,
|
||||
) -> String {
|
||||
let compose_dict = compose_as_dict(
|
||||
capabilities,
|
||||
port,
|
||||
debugger_port,
|
||||
debugger_base_url,
|
||||
postgres_uri,
|
||||
image,
|
||||
base_image,
|
||||
api_version,
|
||||
);
|
||||
dict_to_yaml(&compose_dict, 0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::docker::capabilities::{ComposeType, Version};
|
||||
use crate::util::clean_empty_lines;
|
||||
|
||||
fn default_capabilities() -> DockerCapabilities {
|
||||
DockerCapabilities {
|
||||
version_docker: Version::new(26, 1, 1),
|
||||
version_compose: Version::new(2, 27, 0),
|
||||
healthcheck_start_interval: false,
|
||||
compose_type: ComposeType::Plugin,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dict_to_yaml_simple() {
|
||||
let mut d = IndexMap::new();
|
||||
d.insert(
|
||||
"key1".to_string(),
|
||||
YamlValue::String("value1".to_string()),
|
||||
);
|
||||
d.insert(
|
||||
"key2".to_string(),
|
||||
YamlValue::String("value2".to_string()),
|
||||
);
|
||||
let result = dict_to_yaml(&d, 0);
|
||||
// Top-level keys get an extra newline separator between them
|
||||
assert_eq!(result, "key1: value1\n\nkey2: value2\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dict_to_yaml_nested() {
|
||||
let mut inner = IndexMap::new();
|
||||
inner.insert(
|
||||
"nested_key".to_string(),
|
||||
YamlValue::String("nested_value".to_string()),
|
||||
);
|
||||
let mut d = IndexMap::new();
|
||||
d.insert("outer".to_string(), YamlValue::Dict(inner));
|
||||
let result = dict_to_yaml(&d, 0);
|
||||
assert_eq!(result, "outer:\n nested_key: nested_value\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dict_to_yaml_list() {
|
||||
let mut d = IndexMap::new();
|
||||
d.insert(
|
||||
"items".to_string(),
|
||||
YamlValue::List(vec!["a".to_string(), "b".to_string()]),
|
||||
);
|
||||
let result = dict_to_yaml(&d, 0);
|
||||
assert_eq!(result, "items:\n - a\n - b\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compose_with_no_debugger_and_custom_db() {
|
||||
let port = 8123;
|
||||
let custom_postgres_uri = "custom_postgres_uri";
|
||||
let actual = compose(
|
||||
&default_capabilities(),
|
||||
port,
|
||||
None,
|
||||
None,
|
||||
Some(custom_postgres_uri),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let expected = format!(
|
||||
"services:\n\
|
||||
\x20 langgraph-redis:\n\
|
||||
\x20 image: redis:6\n\
|
||||
\x20 healthcheck:\n\
|
||||
\x20 test: redis-cli ping\n\
|
||||
\x20 interval: 5s\n\
|
||||
\x20 timeout: 1s\n\
|
||||
\x20 retries: 5\n\
|
||||
\x20 langgraph-api:\n\
|
||||
\x20 ports:\n\
|
||||
\x20 - \"{port}:8000\"\n\
|
||||
\x20 depends_on:\n\
|
||||
\x20 langgraph-redis:\n\
|
||||
\x20 condition: service_healthy\n\
|
||||
\x20 environment:\n\
|
||||
\x20 REDIS_URI: redis://langgraph-redis:6379\n\
|
||||
\x20 POSTGRES_URI: {custom_postgres_uri}"
|
||||
);
|
||||
assert_eq!(clean_empty_lines(&actual), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compose_with_no_debugger_and_custom_db_with_healthcheck() {
|
||||
let port = 8123;
|
||||
let custom_postgres_uri = "custom_postgres_uri";
|
||||
let mut caps = default_capabilities();
|
||||
caps.healthcheck_start_interval = true;
|
||||
let actual = compose(
|
||||
&caps,
|
||||
port,
|
||||
None,
|
||||
None,
|
||||
Some(custom_postgres_uri),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let expected = format!(
|
||||
"services:\n\
|
||||
\x20 langgraph-redis:\n\
|
||||
\x20 image: redis:6\n\
|
||||
\x20 healthcheck:\n\
|
||||
\x20 test: redis-cli ping\n\
|
||||
\x20 interval: 5s\n\
|
||||
\x20 timeout: 1s\n\
|
||||
\x20 retries: 5\n\
|
||||
\x20 langgraph-api:\n\
|
||||
\x20 ports:\n\
|
||||
\x20 - \"{port}:8000\"\n\
|
||||
\x20 depends_on:\n\
|
||||
\x20 langgraph-redis:\n\
|
||||
\x20 condition: service_healthy\n\
|
||||
\x20 environment:\n\
|
||||
\x20 REDIS_URI: redis://langgraph-redis:6379\n\
|
||||
\x20 POSTGRES_URI: {custom_postgres_uri}\n\
|
||||
\x20 healthcheck:\n\
|
||||
\x20 test: python /api/healthcheck.py\n\
|
||||
\x20 interval: 60s\n\
|
||||
\x20 start_interval: 1s\n\
|
||||
\x20 start_period: 10s"
|
||||
);
|
||||
assert_eq!(clean_empty_lines(&actual), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compose_with_debugger_and_custom_db() {
|
||||
let port = 8123;
|
||||
let custom_postgres_uri = "custom_postgres_uri";
|
||||
let actual = compose(
|
||||
&default_capabilities(),
|
||||
port,
|
||||
None,
|
||||
None,
|
||||
Some(custom_postgres_uri),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let expected = format!(
|
||||
"services:\n\
|
||||
\x20 langgraph-redis:\n\
|
||||
\x20 image: redis:6\n\
|
||||
\x20 healthcheck:\n\
|
||||
\x20 test: redis-cli ping\n\
|
||||
\x20 interval: 5s\n\
|
||||
\x20 timeout: 1s\n\
|
||||
\x20 retries: 5\n\
|
||||
\x20 langgraph-api:\n\
|
||||
\x20 ports:\n\
|
||||
\x20 - \"{port}:8000\"\n\
|
||||
\x20 depends_on:\n\
|
||||
\x20 langgraph-redis:\n\
|
||||
\x20 condition: service_healthy\n\
|
||||
\x20 environment:\n\
|
||||
\x20 REDIS_URI: redis://langgraph-redis:6379\n\
|
||||
\x20 POSTGRES_URI: {custom_postgres_uri}"
|
||||
);
|
||||
assert_eq!(clean_empty_lines(&actual), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compose_with_debugger_and_default_db() {
|
||||
let port = 8123;
|
||||
let actual = compose(
|
||||
&default_capabilities(),
|
||||
port,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let expected = format!(
|
||||
"volumes:\n\
|
||||
\x20 langgraph-data:\n\
|
||||
\x20 driver: local\n\
|
||||
services:\n\
|
||||
\x20 langgraph-redis:\n\
|
||||
\x20 image: redis:6\n\
|
||||
\x20 healthcheck:\n\
|
||||
\x20 test: redis-cli ping\n\
|
||||
\x20 interval: 5s\n\
|
||||
\x20 timeout: 1s\n\
|
||||
\x20 retries: 5\n\
|
||||
\x20 langgraph-postgres:\n\
|
||||
\x20 image: pgvector/pgvector:pg16\n\
|
||||
\x20 ports:\n\
|
||||
\x20 - \"5433:5432\"\n\
|
||||
\x20 environment:\n\
|
||||
\x20 POSTGRES_DB: postgres\n\
|
||||
\x20 POSTGRES_USER: postgres\n\
|
||||
\x20 POSTGRES_PASSWORD: postgres\n\
|
||||
\x20 command:\n\
|
||||
\x20 - postgres\n\
|
||||
\x20 - -c\n\
|
||||
\x20 - shared_preload_libraries=vector\n\
|
||||
\x20 volumes:\n\
|
||||
\x20 - langgraph-data:/var/lib/postgresql/data\n\
|
||||
\x20 healthcheck:\n\
|
||||
\x20 test: pg_isready -U postgres\n\
|
||||
\x20 start_period: 10s\n\
|
||||
\x20 timeout: 1s\n\
|
||||
\x20 retries: 5\n\
|
||||
\x20 interval: 5s\n\
|
||||
\x20 langgraph-api:\n\
|
||||
\x20 ports:\n\
|
||||
\x20 - \"{port}:8000\"\n\
|
||||
\x20 depends_on:\n\
|
||||
\x20 langgraph-redis:\n\
|
||||
\x20 condition: service_healthy\n\
|
||||
\x20 langgraph-postgres:\n\
|
||||
\x20 condition: service_healthy\n\
|
||||
\x20 environment:\n\
|
||||
\x20 REDIS_URI: redis://langgraph-redis:6379\n\
|
||||
\x20 POSTGRES_URI: {DEFAULT_POSTGRES_URI}"
|
||||
);
|
||||
assert_eq!(clean_empty_lines(&actual), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compose_with_api_version() {
|
||||
let port = 8123;
|
||||
let actual = compose(
|
||||
&default_capabilities(),
|
||||
port,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some("0.2.74"),
|
||||
);
|
||||
let expected = format!(
|
||||
"volumes:\n\
|
||||
\x20 langgraph-data:\n\
|
||||
\x20 driver: local\n\
|
||||
services:\n\
|
||||
\x20 langgraph-redis:\n\
|
||||
\x20 image: redis:6\n\
|
||||
\x20 healthcheck:\n\
|
||||
\x20 test: redis-cli ping\n\
|
||||
\x20 interval: 5s\n\
|
||||
\x20 timeout: 1s\n\
|
||||
\x20 retries: 5\n\
|
||||
\x20 langgraph-postgres:\n\
|
||||
\x20 image: pgvector/pgvector:pg16\n\
|
||||
\x20 ports:\n\
|
||||
\x20 - \"5433:5432\"\n\
|
||||
\x20 environment:\n\
|
||||
\x20 POSTGRES_DB: postgres\n\
|
||||
\x20 POSTGRES_USER: postgres\n\
|
||||
\x20 POSTGRES_PASSWORD: postgres\n\
|
||||
\x20 command:\n\
|
||||
\x20 - postgres\n\
|
||||
\x20 - -c\n\
|
||||
\x20 - shared_preload_libraries=vector\n\
|
||||
\x20 volumes:\n\
|
||||
\x20 - langgraph-data:/var/lib/postgresql/data\n\
|
||||
\x20 healthcheck:\n\
|
||||
\x20 test: pg_isready -U postgres\n\
|
||||
\x20 start_period: 10s\n\
|
||||
\x20 timeout: 1s\n\
|
||||
\x20 retries: 5\n\
|
||||
\x20 interval: 5s\n\
|
||||
\x20 langgraph-api:\n\
|
||||
\x20 ports:\n\
|
||||
\x20 - \"{port}:8000\"\n\
|
||||
\x20 depends_on:\n\
|
||||
\x20 langgraph-redis:\n\
|
||||
\x20 condition: service_healthy\n\
|
||||
\x20 langgraph-postgres:\n\
|
||||
\x20 condition: service_healthy\n\
|
||||
\x20 environment:\n\
|
||||
\x20 REDIS_URI: redis://langgraph-redis:6379\n\
|
||||
\x20 POSTGRES_URI: {DEFAULT_POSTGRES_URI}"
|
||||
);
|
||||
assert_eq!(clean_empty_lines(&actual), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compose_with_api_version_and_base_image() {
|
||||
let port = 8123;
|
||||
let actual = compose(
|
||||
&default_capabilities(),
|
||||
port,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some("my-registry/custom-api"),
|
||||
Some("1.0.0"),
|
||||
);
|
||||
let expected = format!(
|
||||
"volumes:\n\
|
||||
\x20 langgraph-data:\n\
|
||||
\x20 driver: local\n\
|
||||
services:\n\
|
||||
\x20 langgraph-redis:\n\
|
||||
\x20 image: redis:6\n\
|
||||
\x20 healthcheck:\n\
|
||||
\x20 test: redis-cli ping\n\
|
||||
\x20 interval: 5s\n\
|
||||
\x20 timeout: 1s\n\
|
||||
\x20 retries: 5\n\
|
||||
\x20 langgraph-postgres:\n\
|
||||
\x20 image: pgvector/pgvector:pg16\n\
|
||||
\x20 ports:\n\
|
||||
\x20 - \"5433:5432\"\n\
|
||||
\x20 environment:\n\
|
||||
\x20 POSTGRES_DB: postgres\n\
|
||||
\x20 POSTGRES_USER: postgres\n\
|
||||
\x20 POSTGRES_PASSWORD: postgres\n\
|
||||
\x20 command:\n\
|
||||
\x20 - postgres\n\
|
||||
\x20 - -c\n\
|
||||
\x20 - shared_preload_libraries=vector\n\
|
||||
\x20 volumes:\n\
|
||||
\x20 - langgraph-data:/var/lib/postgresql/data\n\
|
||||
\x20 healthcheck:\n\
|
||||
\x20 test: pg_isready -U postgres\n\
|
||||
\x20 start_period: 10s\n\
|
||||
\x20 timeout: 1s\n\
|
||||
\x20 retries: 5\n\
|
||||
\x20 interval: 5s\n\
|
||||
\x20 langgraph-api:\n\
|
||||
\x20 ports:\n\
|
||||
\x20 - \"{port}:8000\"\n\
|
||||
\x20 depends_on:\n\
|
||||
\x20 langgraph-redis:\n\
|
||||
\x20 condition: service_healthy\n\
|
||||
\x20 langgraph-postgres:\n\
|
||||
\x20 condition: service_healthy\n\
|
||||
\x20 environment:\n\
|
||||
\x20 REDIS_URI: redis://langgraph-redis:6379\n\
|
||||
\x20 POSTGRES_URI: {DEFAULT_POSTGRES_URI}"
|
||||
);
|
||||
assert_eq!(clean_empty_lines(&actual), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compose_with_api_version_and_custom_postgres() {
|
||||
let port = 8123;
|
||||
let custom_postgres_uri = "postgresql://user:pass@external-db:5432/mydb";
|
||||
let actual = compose(
|
||||
&default_capabilities(),
|
||||
port,
|
||||
None,
|
||||
None,
|
||||
Some(custom_postgres_uri),
|
||||
None,
|
||||
None,
|
||||
Some("0.2.74"),
|
||||
);
|
||||
let expected = format!(
|
||||
"services:\n\
|
||||
\x20 langgraph-redis:\n\
|
||||
\x20 image: redis:6\n\
|
||||
\x20 healthcheck:\n\
|
||||
\x20 test: redis-cli ping\n\
|
||||
\x20 interval: 5s\n\
|
||||
\x20 timeout: 1s\n\
|
||||
\x20 retries: 5\n\
|
||||
\x20 langgraph-api:\n\
|
||||
\x20 ports:\n\
|
||||
\x20 - \"{port}:8000\"\n\
|
||||
\x20 depends_on:\n\
|
||||
\x20 langgraph-redis:\n\
|
||||
\x20 condition: service_healthy\n\
|
||||
\x20 environment:\n\
|
||||
\x20 REDIS_URI: redis://langgraph-redis:6379\n\
|
||||
\x20 POSTGRES_URI: {custom_postgres_uri}"
|
||||
);
|
||||
assert_eq!(clean_empty_lines(&actual), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compose_with_api_version_and_debugger() {
|
||||
let port = 8123;
|
||||
let debugger_port = 8001;
|
||||
let actual = compose(
|
||||
&default_capabilities(),
|
||||
port,
|
||||
Some(debugger_port),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some("0.2.74"),
|
||||
);
|
||||
let expected = format!(
|
||||
"volumes:\n\
|
||||
\x20 langgraph-data:\n\
|
||||
\x20 driver: local\n\
|
||||
services:\n\
|
||||
\x20 langgraph-redis:\n\
|
||||
\x20 image: redis:6\n\
|
||||
\x20 healthcheck:\n\
|
||||
\x20 test: redis-cli ping\n\
|
||||
\x20 interval: 5s\n\
|
||||
\x20 timeout: 1s\n\
|
||||
\x20 retries: 5\n\
|
||||
\x20 langgraph-postgres:\n\
|
||||
\x20 image: pgvector/pgvector:pg16\n\
|
||||
\x20 ports:\n\
|
||||
\x20 - \"5433:5432\"\n\
|
||||
\x20 environment:\n\
|
||||
\x20 POSTGRES_DB: postgres\n\
|
||||
\x20 POSTGRES_USER: postgres\n\
|
||||
\x20 POSTGRES_PASSWORD: postgres\n\
|
||||
\x20 command:\n\
|
||||
\x20 - postgres\n\
|
||||
\x20 - -c\n\
|
||||
\x20 - shared_preload_libraries=vector\n\
|
||||
\x20 volumes:\n\
|
||||
\x20 - langgraph-data:/var/lib/postgresql/data\n\
|
||||
\x20 healthcheck:\n\
|
||||
\x20 test: pg_isready -U postgres\n\
|
||||
\x20 start_period: 10s\n\
|
||||
\x20 timeout: 1s\n\
|
||||
\x20 retries: 5\n\
|
||||
\x20 interval: 5s\n\
|
||||
\x20 langgraph-debugger:\n\
|
||||
\x20 image: langchain/langgraph-debugger\n\
|
||||
\x20 restart: on-failure\n\
|
||||
\x20 depends_on:\n\
|
||||
\x20 langgraph-postgres:\n\
|
||||
\x20 condition: service_healthy\n\
|
||||
\x20 ports:\n\
|
||||
\x20 - \"{debugger_port}:3968\"\n\
|
||||
\x20 langgraph-api:\n\
|
||||
\x20 ports:\n\
|
||||
\x20 - \"{port}:8000\"\n\
|
||||
\x20 depends_on:\n\
|
||||
\x20 langgraph-redis:\n\
|
||||
\x20 condition: service_healthy\n\
|
||||
\x20 langgraph-postgres:\n\
|
||||
\x20 condition: service_healthy\n\
|
||||
\x20 environment:\n\
|
||||
\x20 REDIS_URI: redis://langgraph-redis:6379\n\
|
||||
\x20 POSTGRES_URI: {DEFAULT_POSTGRES_URI}"
|
||||
);
|
||||
assert_eq!(clean_empty_lines(&actual), expected);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +0,0 @@
|
||||
pub mod capabilities;
|
||||
pub mod compose;
|
||||
pub mod dockerfile;
|
||||
@@ -1,211 +0,0 @@
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
/// Run a command synchronously, optionally piping stdin, and capturing stdout/stderr.
|
||||
///
|
||||
/// If `verbose` is true, the command is echoed to stdout before execution.
|
||||
///
|
||||
/// Returns `(Option<stdout>, Option<stderr>)` on success, or an error message on failure.
|
||||
pub fn run_command(
|
||||
cmd: &str,
|
||||
args: &[&str],
|
||||
input: Option<&str>,
|
||||
verbose: bool,
|
||||
) -> Result<(Option<String>, Option<String>), String> {
|
||||
if verbose {
|
||||
let cmd_str = format!("+ {} {}", cmd, args.join(" "));
|
||||
if let Some(inp) = input {
|
||||
let filtered: Vec<&str> = inp.lines().filter(|l| !l.is_empty()).collect();
|
||||
println!("{} <\n{}", cmd_str, filtered.join("\n"));
|
||||
} else {
|
||||
println!("{cmd_str}");
|
||||
}
|
||||
}
|
||||
|
||||
let stdin_cfg = if input.is_some() {
|
||||
Stdio::piped()
|
||||
} else {
|
||||
Stdio::null()
|
||||
};
|
||||
|
||||
let mut child = Command::new(cmd)
|
||||
.args(args)
|
||||
.stdin(stdin_cfg)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to execute `{cmd}`: {e}"))?;
|
||||
|
||||
if let Some(input_data) = input {
|
||||
if let Some(ref mut stdin_handle) = child.stdin {
|
||||
stdin_handle
|
||||
.write_all(input_data.as_bytes())
|
||||
.map_err(|e| format!("Failed to write to stdin of `{cmd}`: {e}"))?;
|
||||
}
|
||||
// Drop stdin to signal EOF
|
||||
drop(child.stdin.take());
|
||||
}
|
||||
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.map_err(|e| format!("Failed to wait for `{cmd}`: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let code = output.status.code().unwrap_or(-1);
|
||||
// 130 = SIGINT (Ctrl-C), not an error
|
||||
if code == 130 {
|
||||
return Ok((None, None));
|
||||
}
|
||||
let stdout_str = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr_str = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!(
|
||||
"Command `{cmd}` exited with code {code}\nstdout: {stdout_str}\nstderr: {stderr_str}"
|
||||
));
|
||||
}
|
||||
|
||||
let stdout = if output.stdout.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
};
|
||||
let stderr = if output.stderr.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(String::from_utf8_lossy(&output.stderr).to_string())
|
||||
};
|
||||
|
||||
Ok((stdout, stderr))
|
||||
}
|
||||
|
||||
/// Run a command and stream its stdout/stderr to the parent process in real-time.
|
||||
///
|
||||
/// This is useful for long-running commands like `docker compose up` where we
|
||||
/// want to see output as it happens.
|
||||
pub fn run_command_streaming(
|
||||
cmd: &str,
|
||||
args: &[&str],
|
||||
input: Option<&str>,
|
||||
verbose: bool,
|
||||
) -> Result<(), String> {
|
||||
if verbose {
|
||||
let cmd_str = format!("+ {} {}", cmd, args.join(" "));
|
||||
if let Some(inp) = input {
|
||||
let filtered: Vec<&str> = inp.lines().filter(|l| !l.is_empty()).collect();
|
||||
println!("{} <\n{}", cmd_str, filtered.join("\n"));
|
||||
} else {
|
||||
println!("{cmd_str}");
|
||||
}
|
||||
}
|
||||
|
||||
let stdin_cfg = if input.is_some() {
|
||||
Stdio::piped()
|
||||
} else {
|
||||
Stdio::null()
|
||||
};
|
||||
|
||||
let mut child = Command::new(cmd)
|
||||
.args(args)
|
||||
.stdin(stdin_cfg)
|
||||
.stdout(Stdio::inherit())
|
||||
.stderr(Stdio::inherit())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to execute `{cmd}`: {e}"))?;
|
||||
|
||||
if let Some(input_data) = input {
|
||||
if let Some(ref mut stdin_handle) = child.stdin {
|
||||
stdin_handle
|
||||
.write_all(input_data.as_bytes())
|
||||
.map_err(|e| format!("Failed to write to stdin of `{cmd}`: {e}"))?;
|
||||
}
|
||||
drop(child.stdin.take());
|
||||
}
|
||||
|
||||
let status = child
|
||||
.wait()
|
||||
.map_err(|e| format!("Failed to wait for `{cmd}`: {e}"))?;
|
||||
|
||||
if !status.success() {
|
||||
let code = status.code().unwrap_or(-1);
|
||||
if code == 130 {
|
||||
return Ok(());
|
||||
}
|
||||
return Err(format!("Command `{cmd}` exited with code {code}"));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run a command, streaming stderr to the parent process, while intercepting
|
||||
/// stdout line-by-line through a callback. Each stdout line is forwarded to
|
||||
/// the parent's stdout after the callback processes it.
|
||||
pub fn run_command_streaming_with_callback<F>(
|
||||
cmd: &str,
|
||||
args: &[&str],
|
||||
input: Option<&str>,
|
||||
verbose: bool,
|
||||
mut on_stdout: F,
|
||||
) -> Result<(), String>
|
||||
where
|
||||
F: FnMut(&str),
|
||||
{
|
||||
if verbose {
|
||||
let cmd_str = format!("+ {} {}", cmd, args.join(" "));
|
||||
if let Some(inp) = input {
|
||||
let filtered: Vec<&str> = inp.lines().filter(|l| !l.is_empty()).collect();
|
||||
println!("{} <\n{}", cmd_str, filtered.join("\n"));
|
||||
} else {
|
||||
println!("{cmd_str}");
|
||||
}
|
||||
}
|
||||
|
||||
let stdin_cfg = if input.is_some() {
|
||||
Stdio::piped()
|
||||
} else {
|
||||
Stdio::null()
|
||||
};
|
||||
|
||||
let mut child = Command::new(cmd)
|
||||
.args(args)
|
||||
.stdin(stdin_cfg)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to execute `{cmd}`: {e}"))?;
|
||||
|
||||
if let Some(input_data) = input {
|
||||
if let Some(ref mut stdin_handle) = child.stdin {
|
||||
stdin_handle
|
||||
.write_all(input_data.as_bytes())
|
||||
.map_err(|e| format!("Failed to write to stdin of `{cmd}`: {e}"))?;
|
||||
}
|
||||
drop(child.stdin.take());
|
||||
}
|
||||
|
||||
// Read stdout line by line, forward to our stdout, and call callback
|
||||
if let Some(stdout) = child.stdout.take() {
|
||||
let reader = BufReader::new(stdout);
|
||||
for line in reader.lines() {
|
||||
match line {
|
||||
Ok(line) => {
|
||||
println!("{line}");
|
||||
on_stdout(&line);
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let status = child
|
||||
.wait()
|
||||
.map_err(|e| format!("Failed to wait for `{cmd}`: {e}"))?;
|
||||
|
||||
if !status.success() {
|
||||
let code = status.code().unwrap_or(-1);
|
||||
if code == 130 {
|
||||
return Ok(());
|
||||
}
|
||||
return Err(format!("Command `{cmd}` exited with code {code}"));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,305 +0,0 @@
|
||||
mod analytics;
|
||||
mod commands;
|
||||
mod config;
|
||||
mod constants;
|
||||
mod docker;
|
||||
mod exec;
|
||||
mod progress;
|
||||
mod templates;
|
||||
mod util;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "langgraph", version = VERSION, about = "LangGraph CLI")]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Commands,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Commands {
|
||||
/// Launch LangGraph API server with Docker
|
||||
Up {
|
||||
/// Path to configuration file declaring dependencies, graphs and environment variables
|
||||
#[arg(short, long, default_value = "langgraph.json")]
|
||||
config: String,
|
||||
|
||||
/// Port to expose
|
||||
#[arg(short, long, default_value_t = 8123)]
|
||||
port: u16,
|
||||
|
||||
/// Path to docker-compose.yml file with additional services
|
||||
#[arg(short, long)]
|
||||
docker_compose: Option<String>,
|
||||
|
||||
/// Show detailed output
|
||||
#[arg(short, long)]
|
||||
verbose: bool,
|
||||
|
||||
/// Restart on file changes using docker compose watch
|
||||
#[arg(short, long)]
|
||||
watch: bool,
|
||||
|
||||
/// Recreate containers even if configuration hasn't changed
|
||||
#[arg(long)]
|
||||
recreate: bool,
|
||||
|
||||
/// Skip pulling latest images before running
|
||||
#[arg(long)]
|
||||
no_pull: bool,
|
||||
|
||||
/// Wait for services to be healthy before returning
|
||||
#[arg(long)]
|
||||
wait: bool,
|
||||
|
||||
/// Port to expose the debugger on
|
||||
#[arg(long)]
|
||||
debugger_port: Option<u16>,
|
||||
|
||||
/// Base URL for the debugger
|
||||
#[arg(long)]
|
||||
debugger_base_url: Option<String>,
|
||||
|
||||
/// Postgres connection URI
|
||||
#[arg(long)]
|
||||
postgres_uri: Option<String>,
|
||||
|
||||
/// API version of the LangGraph server
|
||||
#[arg(long)]
|
||||
api_version: Option<String>,
|
||||
|
||||
/// Pre-built image to use instead of building
|
||||
#[arg(long)]
|
||||
image: Option<String>,
|
||||
|
||||
/// Base image for the LangGraph API server
|
||||
#[arg(long)]
|
||||
base_image: Option<String>,
|
||||
},
|
||||
|
||||
/// Build LangGraph API server Docker image
|
||||
Build {
|
||||
/// Path to configuration file
|
||||
#[arg(short, long, default_value = "langgraph.json")]
|
||||
config: String,
|
||||
|
||||
/// Tag for the docker image
|
||||
#[arg(short, long)]
|
||||
tag: String,
|
||||
|
||||
/// Skip pulling latest images before building
|
||||
#[arg(long)]
|
||||
no_pull: bool,
|
||||
|
||||
/// Base image for the LangGraph API server
|
||||
#[arg(long)]
|
||||
base_image: Option<String>,
|
||||
|
||||
/// API version of the LangGraph server
|
||||
#[arg(long)]
|
||||
api_version: Option<String>,
|
||||
|
||||
/// Custom install command
|
||||
#[arg(long)]
|
||||
install_command: Option<String>,
|
||||
|
||||
/// Custom build command
|
||||
#[arg(long)]
|
||||
build_command: Option<String>,
|
||||
|
||||
/// Additional arguments to pass to docker build
|
||||
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
|
||||
docker_build_args: Vec<String>,
|
||||
},
|
||||
|
||||
/// Generate a Dockerfile for the LangGraph API server
|
||||
Dockerfile {
|
||||
/// Path to save the generated Dockerfile
|
||||
save_path: String,
|
||||
|
||||
/// Path to configuration file
|
||||
#[arg(short, long, default_value = "langgraph.json")]
|
||||
config: String,
|
||||
|
||||
/// Add docker-compose.yml, .env, and .dockerignore files
|
||||
#[arg(long)]
|
||||
add_docker_compose: bool,
|
||||
|
||||
/// Base image for the LangGraph API server
|
||||
#[arg(long)]
|
||||
base_image: Option<String>,
|
||||
|
||||
/// API version of the LangGraph server
|
||||
#[arg(long)]
|
||||
api_version: Option<String>,
|
||||
},
|
||||
|
||||
/// Run LangGraph API server in development mode
|
||||
Dev {
|
||||
/// Network interface to bind to
|
||||
#[arg(long, default_value = "127.0.0.1")]
|
||||
host: String,
|
||||
|
||||
/// Port number
|
||||
#[arg(long, default_value_t = 2024)]
|
||||
port: u16,
|
||||
|
||||
/// Disable automatic reloading
|
||||
#[arg(long)]
|
||||
no_reload: bool,
|
||||
|
||||
/// Path to configuration file
|
||||
#[arg(short, long, default_value = "langgraph.json")]
|
||||
config: String,
|
||||
|
||||
/// Max concurrent jobs per worker
|
||||
#[arg(long)]
|
||||
n_jobs_per_worker: Option<u32>,
|
||||
|
||||
/// Skip opening browser
|
||||
#[arg(long)]
|
||||
no_browser: bool,
|
||||
|
||||
/// Enable remote debugging on specified port
|
||||
#[arg(long)]
|
||||
debug_port: Option<u16>,
|
||||
|
||||
/// Wait for debugger client to connect
|
||||
#[arg(long)]
|
||||
wait_for_client: bool,
|
||||
|
||||
/// URL of LangGraph Studio
|
||||
#[arg(long)]
|
||||
studio_url: Option<String>,
|
||||
|
||||
/// Allow synchronous I/O blocking operations
|
||||
#[arg(long)]
|
||||
allow_blocking: bool,
|
||||
|
||||
/// Expose via public tunnel
|
||||
#[arg(long)]
|
||||
tunnel: bool,
|
||||
|
||||
/// Log level for the API server
|
||||
#[arg(long, default_value = "WARNING")]
|
||||
server_log_level: String,
|
||||
},
|
||||
|
||||
/// Create a new LangGraph project from a template
|
||||
New {
|
||||
/// Path to create the project
|
||||
path: Option<String>,
|
||||
|
||||
/// Template to use
|
||||
#[arg(long)]
|
||||
template: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let cli = Cli::parse();
|
||||
|
||||
let result = match cli.command {
|
||||
Commands::Up {
|
||||
config,
|
||||
port,
|
||||
docker_compose,
|
||||
verbose,
|
||||
watch,
|
||||
recreate,
|
||||
no_pull,
|
||||
wait,
|
||||
debugger_port,
|
||||
debugger_base_url,
|
||||
postgres_uri,
|
||||
api_version,
|
||||
image,
|
||||
base_image,
|
||||
} => commands::up::run(
|
||||
&config,
|
||||
port,
|
||||
docker_compose.as_deref(),
|
||||
verbose,
|
||||
watch,
|
||||
recreate,
|
||||
!no_pull,
|
||||
wait,
|
||||
debugger_port,
|
||||
debugger_base_url.as_deref(),
|
||||
postgres_uri.as_deref(),
|
||||
api_version.as_deref(),
|
||||
image.as_deref(),
|
||||
base_image.as_deref(),
|
||||
),
|
||||
Commands::Build {
|
||||
config,
|
||||
tag,
|
||||
no_pull,
|
||||
base_image,
|
||||
api_version,
|
||||
install_command,
|
||||
build_command,
|
||||
docker_build_args,
|
||||
} => commands::build_cmd::run(
|
||||
&config,
|
||||
&tag,
|
||||
!no_pull,
|
||||
base_image.as_deref(),
|
||||
api_version.as_deref(),
|
||||
install_command.as_deref(),
|
||||
build_command.as_deref(),
|
||||
&docker_build_args,
|
||||
),
|
||||
Commands::Dockerfile {
|
||||
save_path,
|
||||
config,
|
||||
add_docker_compose,
|
||||
base_image,
|
||||
api_version,
|
||||
} => commands::dockerfile::run(
|
||||
&save_path,
|
||||
&config,
|
||||
add_docker_compose,
|
||||
base_image.as_deref(),
|
||||
api_version.as_deref(),
|
||||
),
|
||||
Commands::Dev {
|
||||
host,
|
||||
port,
|
||||
no_reload,
|
||||
config,
|
||||
n_jobs_per_worker,
|
||||
no_browser,
|
||||
debug_port,
|
||||
wait_for_client,
|
||||
studio_url,
|
||||
allow_blocking,
|
||||
tunnel,
|
||||
server_log_level,
|
||||
} => commands::dev::run(
|
||||
&host,
|
||||
port,
|
||||
no_reload,
|
||||
&config,
|
||||
n_jobs_per_worker,
|
||||
no_browser,
|
||||
debug_port,
|
||||
wait_for_client,
|
||||
studio_url.as_deref(),
|
||||
allow_blocking,
|
||||
tunnel,
|
||||
&server_log_level,
|
||||
),
|
||||
Commands::New { path, template } => {
|
||||
commands::new::run(path.as_deref(), template.as_deref())
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = result {
|
||||
eprintln!("Error: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
use indicatif::{ProgressBar, ProgressStyle};
|
||||
|
||||
/// Terminal spinner using indicatif.
|
||||
///
|
||||
/// Wraps an indicatif spinner that displays an animated progress indicator
|
||||
/// with a configurable message.
|
||||
pub struct Progress {
|
||||
spinner: ProgressBar,
|
||||
}
|
||||
|
||||
impl Progress {
|
||||
/// Create a new spinner with the given initial message.
|
||||
pub fn new(message: &str) -> Self {
|
||||
let spinner = ProgressBar::new_spinner();
|
||||
spinner.set_style(
|
||||
ProgressStyle::default_spinner()
|
||||
.tick_strings(&["|", "/", "-", "\\", ""])
|
||||
.template("{spinner} {msg}")
|
||||
.unwrap_or_else(|_| ProgressStyle::default_spinner()),
|
||||
);
|
||||
spinner.set_message(message.to_string());
|
||||
spinner.enable_steady_tick(std::time::Duration::from_millis(100));
|
||||
Self { spinner }
|
||||
}
|
||||
|
||||
/// Update the spinner message.
|
||||
///
|
||||
/// If the message is empty, the spinner is effectively hidden but still running.
|
||||
pub fn set_message(&self, msg: &str) {
|
||||
self.spinner.set_message(msg.to_string());
|
||||
}
|
||||
|
||||
/// Stop the spinner and clear it from the terminal.
|
||||
pub fn finish(&self) {
|
||||
self.spinner.finish_and_clear();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Progress {
|
||||
fn drop(&mut self) {
|
||||
self.spinner.finish_and_clear();
|
||||
}
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::io::{Cursor, Read};
|
||||
use std::path::Path;
|
||||
|
||||
use console::style;
|
||||
use dialoguer::{Input, Select};
|
||||
|
||||
/// A project template definition.
|
||||
pub struct Template {
|
||||
pub name: &'static str,
|
||||
pub description: &'static str,
|
||||
pub python: &'static str,
|
||||
pub js: &'static str,
|
||||
}
|
||||
|
||||
/// All available project templates.
|
||||
pub const TEMPLATES: &[Template] = &[
|
||||
Template {
|
||||
name: "New LangGraph Project",
|
||||
description: "A simple, minimal chatbot with memory.",
|
||||
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",
|
||||
},
|
||||
Template {
|
||||
name: "ReAct Agent",
|
||||
description: "A simple agent that can be flexibly extended to many tools.",
|
||||
python: "https://github.com/langchain-ai/react-agent/archive/refs/heads/main.zip",
|
||||
js: "https://github.com/langchain-ai/react-agent-js/archive/refs/heads/main.zip",
|
||||
},
|
||||
Template {
|
||||
name: "Memory Agent",
|
||||
description: "A ReAct-style agent with an additional tool to store memories for use across conversational threads.",
|
||||
python: "https://github.com/langchain-ai/memory-agent/archive/refs/heads/main.zip",
|
||||
js: "https://github.com/langchain-ai/memory-agent-js/archive/refs/heads/main.zip",
|
||||
},
|
||||
Template {
|
||||
name: "Retrieval Agent",
|
||||
description: "An agent that includes a retrieval-based question-answering system.",
|
||||
python: "https://github.com/langchain-ai/retrieval-agent-template/archive/refs/heads/main.zip",
|
||||
js: "https://github.com/langchain-ai/retrieval-agent-template-js/archive/refs/heads/main.zip",
|
||||
},
|
||||
Template {
|
||||
name: "Data-enrichment Agent",
|
||||
description: "An agent that performs web searches and organizes its findings into a structured format.",
|
||||
python: "https://github.com/langchain-ai/data-enrichment/archive/refs/heads/main.zip",
|
||||
js: "https://github.com/langchain-ai/data-enrichment-js/archive/refs/heads/main.zip",
|
||||
},
|
||||
];
|
||||
|
||||
/// Mapping from template ID (e.g., "react-agent-python") to (template index, language, url).
|
||||
pub fn build_template_id_map() -> HashMap<String, (usize, &'static str, &'static str)> {
|
||||
let mut map = HashMap::new();
|
||||
for (idx, tmpl) in TEMPLATES.iter().enumerate() {
|
||||
let base = tmpl.name.to_lowercase().replace(' ', "-");
|
||||
map.insert(
|
||||
format!("{base}-python"),
|
||||
(idx, "python", tmpl.python),
|
||||
);
|
||||
map.insert(format!("{base}-js"), (idx, "js", tmpl.js));
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
/// Get a sorted list of all valid template IDs.
|
||||
pub fn template_ids() -> Vec<String> {
|
||||
let map = build_template_id_map();
|
||||
let mut ids: Vec<String> = map.keys().cloned().collect();
|
||||
ids.sort();
|
||||
ids
|
||||
}
|
||||
|
||||
/// Interactively choose a template. Returns the download URL.
|
||||
fn choose_template() -> Result<String, String> {
|
||||
eprintln!("{}", style("Please select a template:").bold().yellow());
|
||||
let items: Vec<String> = TEMPLATES
|
||||
.iter()
|
||||
.map(|t| format!("{} - {}", t.name, t.description))
|
||||
.collect();
|
||||
|
||||
let selection = Select::new()
|
||||
.with_prompt("Select a template")
|
||||
.items(&items)
|
||||
.default(0)
|
||||
.interact()
|
||||
.map_err(|e| format!("Template selection failed: {e}"))?;
|
||||
|
||||
let tmpl = &TEMPLATES[selection];
|
||||
eprintln!(
|
||||
"\n{}",
|
||||
style(format!("You selected: {} - {}", tmpl.name, tmpl.description)).green()
|
||||
);
|
||||
|
||||
let lang_items = vec!["Python", "JS/TS"];
|
||||
let lang_choice = Select::new()
|
||||
.with_prompt("Choose language")
|
||||
.items(&lang_items)
|
||||
.default(0)
|
||||
.interact()
|
||||
.map_err(|e| format!("Language selection failed: {e}"))?;
|
||||
|
||||
let url = if lang_choice == 0 {
|
||||
tmpl.python
|
||||
} else {
|
||||
tmpl.js
|
||||
};
|
||||
Ok(url.to_string())
|
||||
}
|
||||
|
||||
/// Download a zip archive from `url` and extract to `path`.
|
||||
fn download_repo(url: &str, path: &str) -> Result<(), String> {
|
||||
eprintln!(
|
||||
"{}",
|
||||
style("Downloading repository as a ZIP archive...").yellow()
|
||||
);
|
||||
eprintln!("{}", style(format!("URL: {url}")).yellow());
|
||||
|
||||
// Use blocking reqwest to download
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to create HTTP client: {e}"))?;
|
||||
|
||||
let response = client
|
||||
.get(url)
|
||||
.send()
|
||||
.map_err(|e| format!("Failed to download repository: {e}"))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!(
|
||||
"Failed to download repository. HTTP status: {}",
|
||||
response.status()
|
||||
));
|
||||
}
|
||||
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.map_err(|e| format!("Failed to read response body: {e}"))?;
|
||||
|
||||
let target_path = Path::new(path);
|
||||
if !target_path.exists() {
|
||||
std::fs::create_dir_all(target_path)
|
||||
.map_err(|e| format!("Failed to create directory {path}: {e}"))?;
|
||||
}
|
||||
|
||||
let cursor = Cursor::new(bytes);
|
||||
let mut archive =
|
||||
zip::ZipArchive::new(cursor).map_err(|e| format!("Failed to open ZIP archive: {e}"))?;
|
||||
|
||||
// Extract all files
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive
|
||||
.by_index(i)
|
||||
.map_err(|e| format!("Failed to read ZIP entry: {e}"))?;
|
||||
let name = file.name().to_string();
|
||||
|
||||
// Strip the top-level directory (e.g., "repo-main/")
|
||||
let stripped = match name.split_once('/') {
|
||||
Some((_, rest)) if !rest.is_empty() => rest.to_string(),
|
||||
_ => continue, // Skip the top-level directory entry itself
|
||||
};
|
||||
|
||||
let out_path = target_path.join(&stripped);
|
||||
|
||||
if file.is_dir() {
|
||||
std::fs::create_dir_all(&out_path)
|
||||
.map_err(|e| format!("Failed to create directory {}: {e}", out_path.display()))?;
|
||||
} else {
|
||||
if let Some(parent) = out_path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| {
|
||||
format!("Failed to create directory {}: {e}", parent.display())
|
||||
})?;
|
||||
}
|
||||
let mut buf = Vec::new();
|
||||
file.read_to_end(&mut buf)
|
||||
.map_err(|e| format!("Failed to read file from ZIP: {e}"))?;
|
||||
std::fs::write(&out_path, &buf)
|
||||
.map_err(|e| format!("Failed to write {}: {e}", out_path.display()))?;
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!(
|
||||
"{}",
|
||||
style(format!("Downloaded and extracted repository to {path}")).green()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a new LangGraph project at the specified path using the chosen template.
|
||||
///
|
||||
/// If `path` is None, the user is prompted interactively.
|
||||
/// If `template` is None, the user picks from an interactive menu.
|
||||
pub fn create_new(path: Option<&str>, template: Option<&str>) -> Result<(), String> {
|
||||
// Prompt for path if not provided
|
||||
let path = match path {
|
||||
Some(p) => p.to_string(),
|
||||
None => {
|
||||
let input: String = Input::new()
|
||||
.with_prompt("Please specify the path to create the application")
|
||||
.default(".".to_string())
|
||||
.interact_text()
|
||||
.map_err(|e| format!("Input failed: {e}"))?;
|
||||
input
|
||||
}
|
||||
};
|
||||
|
||||
let abs_path = std::path::Path::new(&path)
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| std::path::PathBuf::from(&path));
|
||||
let abs_path_str = abs_path.to_string_lossy().to_string();
|
||||
|
||||
// If the path doesn't exist yet, that's fine. But if it exists and is non-empty, abort.
|
||||
if abs_path.exists() {
|
||||
let entries = std::fs::read_dir(&abs_path)
|
||||
.map_err(|e| format!("Could not read directory {abs_path_str}: {e}"))?;
|
||||
if entries.count() > 0 {
|
||||
return Err(
|
||||
"The specified directory already exists and is not empty. \
|
||||
Aborting to prevent overwriting files."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Get template URL either from command-line argument or through interactive selection
|
||||
let template_url = if let Some(tmpl_id) = template {
|
||||
let id_map = build_template_id_map();
|
||||
if let Some((_idx, _lang, url)) = id_map.get(tmpl_id) {
|
||||
url.to_string()
|
||||
} else {
|
||||
let ids = template_ids();
|
||||
let mut options = String::new();
|
||||
for id in &ids {
|
||||
let (_idx, _lang, _url) = id_map.get(id.as_str()).unwrap();
|
||||
let tmpl = &TEMPLATES[*_idx];
|
||||
options.push_str(&format!("- {id}: {}\n", tmpl.description));
|
||||
}
|
||||
return Err(format!(
|
||||
"Template '{tmpl_id}' not found.\n\
|
||||
Please select from the available options:\n{options}"
|
||||
));
|
||||
}
|
||||
} else {
|
||||
choose_template()?
|
||||
};
|
||||
|
||||
// Download and extract the template
|
||||
download_repo(&template_url, &abs_path_str)?;
|
||||
|
||||
eprintln!(
|
||||
"{}",
|
||||
style(format!("New project created at {abs_path_str}"))
|
||||
.green()
|
||||
.bold()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
use console::style;
|
||||
|
||||
/// Remove empty lines from a string.
|
||||
pub fn clean_empty_lines(input: &str) -> String {
|
||||
input
|
||||
.lines()
|
||||
.filter(|line| !line.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/// Show warning if image_distro is not set to 'wolfi'.
|
||||
pub fn warn_non_wolfi_distro(config: &serde_json::Value) {
|
||||
let image_distro = config
|
||||
.get("image_distro")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("debian");
|
||||
|
||||
if image_distro != "wolfi" {
|
||||
eprintln!(
|
||||
"{}",
|
||||
style(
|
||||
"Warning: Security Recommendation: Consider switching to Wolfi Linux for enhanced security."
|
||||
)
|
||||
.yellow()
|
||||
.bold()
|
||||
);
|
||||
eprintln!(
|
||||
"{}",
|
||||
style(
|
||||
" Wolfi is a security-oriented, minimal Linux distribution designed for containers."
|
||||
)
|
||||
.yellow()
|
||||
);
|
||||
eprintln!(
|
||||
"{}",
|
||||
style(
|
||||
" To switch, add '\"image_distro\": \"wolfi\"' to your langgraph.json config file."
|
||||
)
|
||||
.yellow()
|
||||
);
|
||||
eprintln!();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_clean_empty_lines() {
|
||||
assert_eq!(clean_empty_lines("line1\n\nline2\n\nline3"), "line1\nline2\nline3");
|
||||
assert_eq!(clean_empty_lines("line1\nline2\nline3"), "line1\nline2\nline3");
|
||||
assert_eq!(clean_empty_lines("\n\n\n"), "");
|
||||
assert_eq!(clean_empty_lines(""), "");
|
||||
}
|
||||
}
|
||||
Generated
+449
-378
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
.PHONY: all format lint test test_watch integration_tests spell_check spell_fix benchmark profile start-dev-server integration_tests
|
||||
.PHONY: all format lint type test test_watch integration_tests spell_check spell_fix benchmark profile start-dev-server integration_tests
|
||||
|
||||
# Default target executed when no arguments are given to make.
|
||||
all: help
|
||||
@@ -87,15 +87,15 @@ integration_tests:
|
||||
|
||||
WORKERS ?= auto
|
||||
XDIST_ARGS := $(if $(WORKERS),-n $(WORKERS) --dist worksteal,)
|
||||
MAXFAIL ?=
|
||||
MAXFAIL_ARGS := $(if $(MAXFAIL),--maxfail $(MAXFAIL),)
|
||||
MAXFAIL ?= 1
|
||||
MAXFAIL_ARGS = $(if $(MAXFAIL),--maxfail $(MAXFAIL),)
|
||||
# Add an '-x' if xdist is enabled
|
||||
XDIST_ARGS := $(if $(WORKERS),-x $(XDIST_ARGS),)
|
||||
|
||||
test_watch:
|
||||
make start-services &&\
|
||||
make start-dev-server &&\
|
||||
uv run ptw . -- --ff -vv $(XDIST_ARGS) $(MAXFAIL_ARGS) $(TEST); \
|
||||
uv run ptw -- --ff -vv $(XDIST_ARGS) $(MAXFAIL_ARGS) $(TEST); \
|
||||
EXIT_CODE=$$?; \
|
||||
make stop-services; \
|
||||
make stop-dev-server; \
|
||||
@@ -125,9 +125,12 @@ lint lint_diff lint_package lint_tests:
|
||||
[ "$(PYTHON_FILES)" = "" ] || mkdir -p $(MYPY_CACHE)
|
||||
[ "$(PYTHON_FILES)" = "" ] || uv run mypy langgraph --cache-dir $(MYPY_CACHE)
|
||||
|
||||
type:
|
||||
mkdir -p $(MYPY_CACHE) && uv run mypy langgraph --cache-dir $(MYPY_CACHE)
|
||||
|
||||
format format_diff:
|
||||
uv run ruff format $(PYTHON_FILES)
|
||||
uv run ruff check --select I --fix $(PYTHON_FILES)
|
||||
uv run ruff check --fix $(PYTHON_FILES)
|
||||
|
||||
spell_check:
|
||||
uv run codespell --toml pyproject.toml
|
||||
@@ -147,6 +150,7 @@ help:
|
||||
@echo '-- LINTING --'
|
||||
@echo 'format - run code formatters'
|
||||
@echo 'lint - run linters'
|
||||
@echo 'type - run type checking'
|
||||
@echo 'spell_check - run codespell on the project'
|
||||
@echo 'spell_fix - run codespell on the project and fix the errors'
|
||||
@echo '-- TESTS --'
|
||||
|
||||
@@ -10,6 +10,7 @@ from bench.fanout_to_subgraph import fanout_to_subgraph, fanout_to_subgraph_sync
|
||||
from bench.pydantic_state import pydantic_state
|
||||
from bench.react_agent import react_agent
|
||||
from bench.sequential import create_sequential
|
||||
from bench.serde_allowlist import collect_allowlist_large, collect_allowlist_small
|
||||
from bench.wide_dict import wide_dict
|
||||
from bench.wide_state import wide_state
|
||||
from langgraph.graph import StateGraph
|
||||
@@ -513,3 +514,7 @@ compilation_benchmarks = (
|
||||
|
||||
for name, graph in compilation_benchmarks:
|
||||
r.bench_func(name + "_compilation", compile_graph, graph)
|
||||
|
||||
# Serde allowlist collection
|
||||
r.bench_func("serde_allowlist_small", collect_allowlist_small)
|
||||
r.bench_func("serde_allowlist_large", collect_allowlist_large)
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import BaseModel
|
||||
from typing_extensions import NotRequired, TypedDict
|
||||
|
||||
from langgraph._internal._serde import collect_allowlist_from_schemas
|
||||
|
||||
|
||||
class Color(Enum):
|
||||
RED = "red"
|
||||
BLUE = "blue"
|
||||
|
||||
|
||||
@dataclass
|
||||
class InnerDataclass:
|
||||
value: int
|
||||
|
||||
|
||||
class InnerModel(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
class InnerTyped(TypedDict):
|
||||
payload: InnerDataclass
|
||||
optional: NotRequired[InnerModel]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Node:
|
||||
value: int
|
||||
child: Node | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class NestedDataclass:
|
||||
inner: InnerDataclass
|
||||
items: list[InnerModel]
|
||||
mapping: dict[str, InnerDataclass]
|
||||
optional: InnerModel | None
|
||||
union: InnerDataclass | InnerModel
|
||||
queue: deque[InnerDataclass]
|
||||
frozen: frozenset[InnerModel]
|
||||
|
||||
|
||||
AnnotatedList = Annotated[list[InnerDataclass], "meta"]
|
||||
|
||||
|
||||
class DummyChannel:
|
||||
@property
|
||||
def ValueType(self) -> type[InnerDataclass]:
|
||||
return InnerDataclass
|
||||
|
||||
@property
|
||||
def UpdateType(self) -> type[InnerModel]:
|
||||
return InnerModel
|
||||
|
||||
|
||||
SCHEMAS_SMALL = [InnerDataclass, InnerModel, Color]
|
||||
SCHEMAS_LARGE = [
|
||||
InnerDataclass,
|
||||
InnerModel,
|
||||
Color,
|
||||
InnerTyped,
|
||||
Node,
|
||||
NestedDataclass,
|
||||
AnnotatedList,
|
||||
]
|
||||
CHANNELS = {"a": DummyChannel(), "b": DummyChannel()}
|
||||
|
||||
|
||||
def collect_allowlist_small() -> None:
|
||||
collect_allowlist_from_schemas(schemas=SCHEMAS_SMALL, channels=CHANNELS)
|
||||
|
||||
|
||||
def collect_allowlist_large() -> None:
|
||||
collect_allowlist_from_schemas(schemas=SCHEMAS_LARGE, channels=CHANNELS)
|
||||
@@ -41,6 +41,8 @@ CONFIG_KEY_CACHE = sys.intern("__pregel_cache")
|
||||
# holds a `BaseCache` made available to subgraphs
|
||||
CONFIG_KEY_RESUMING = sys.intern("__pregel_resuming")
|
||||
# holds a boolean indicating if subgraphs should resume from a previous checkpoint
|
||||
CONFIG_KEY_REPLAYING = sys.intern("__pregel_replaying")
|
||||
# holds a boolean indicating if subgraphs should replay (re-run tasks, drop cached RESUME writes)
|
||||
CONFIG_KEY_TASK_ID = sys.intern("__pregel_task_id")
|
||||
# holds the task ID for the current task
|
||||
CONFIG_KEY_THREAD_ID = sys.intern("thread_id")
|
||||
@@ -98,6 +100,7 @@ RESERVED = {
|
||||
CONFIG_KEY_STREAM,
|
||||
CONFIG_KEY_CHECKPOINT_MAP,
|
||||
CONFIG_KEY_RESUMING,
|
||||
CONFIG_KEY_REPLAYING,
|
||||
CONFIG_KEY_TASK_ID,
|
||||
CONFIG_KEY_CHECKPOINT_MAP,
|
||||
CONFIG_KEY_CHECKPOINT_ID,
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import logging
|
||||
import sys
|
||||
import types
|
||||
from collections import deque
|
||||
from enum import Enum
|
||||
from typing import (
|
||||
Annotated,
|
||||
Any,
|
||||
Literal,
|
||||
Union,
|
||||
get_args,
|
||||
get_origin,
|
||||
get_type_hints,
|
||||
)
|
||||
|
||||
from langchain_core import messages as lc_messages
|
||||
from langgraph.checkpoint.base import BaseCheckpointSaver
|
||||
from pydantic import BaseModel
|
||||
from typing_extensions import NotRequired, Required, is_typeddict
|
||||
|
||||
try:
|
||||
from langgraph.checkpoint.serde._msgpack import ( # noqa: F401
|
||||
STRICT_MSGPACK_ENABLED,
|
||||
)
|
||||
except ImportError:
|
||||
STRICT_MSGPACK_ENABLED = False
|
||||
|
||||
_warned_allowlist_unsupported = False
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _supports_checkpointer_allowlist() -> bool:
|
||||
return hasattr(BaseCheckpointSaver, "with_allowlist")
|
||||
|
||||
|
||||
_SUPPORTS_ALLOWLIST = _supports_checkpointer_allowlist()
|
||||
|
||||
|
||||
def apply_checkpointer_allowlist(
|
||||
checkpointer: Any, allowlist: set[tuple[str, ...]] | None
|
||||
) -> Any:
|
||||
if not checkpointer or allowlist is None or checkpointer in (True, False):
|
||||
return checkpointer
|
||||
if not _SUPPORTS_ALLOWLIST:
|
||||
global _warned_allowlist_unsupported
|
||||
if not _warned_allowlist_unsupported:
|
||||
logger.warning(
|
||||
"Checkpointer does not support with_allowlist; strict msgpack "
|
||||
"allowlist will be skipped."
|
||||
)
|
||||
_warned_allowlist_unsupported = True
|
||||
return checkpointer
|
||||
return checkpointer.with_allowlist(allowlist)
|
||||
|
||||
|
||||
def curated_core_allowlist() -> set[tuple[str, ...]]:
|
||||
allowlist: set[tuple[str, ...]] = set()
|
||||
for name in (
|
||||
"BaseMessage",
|
||||
"BaseMessageChunk",
|
||||
"HumanMessage",
|
||||
"HumanMessageChunk",
|
||||
"AIMessage",
|
||||
"AIMessageChunk",
|
||||
"SystemMessage",
|
||||
"SystemMessageChunk",
|
||||
"ChatMessage",
|
||||
"ChatMessageChunk",
|
||||
"ToolMessage",
|
||||
"ToolMessageChunk",
|
||||
"FunctionMessage",
|
||||
"FunctionMessageChunk",
|
||||
"RemoveMessage",
|
||||
):
|
||||
cls = getattr(lc_messages, name, None)
|
||||
if cls is None:
|
||||
continue
|
||||
allowlist.add((cls.__module__, cls.__name__))
|
||||
|
||||
return allowlist
|
||||
|
||||
|
||||
def build_serde_allowlist(
|
||||
*,
|
||||
schemas: list[type[Any]] | None = None,
|
||||
channels: dict[str, Any] | None = None,
|
||||
) -> set[tuple[str, ...]]:
|
||||
allowlist = curated_core_allowlist()
|
||||
if schemas:
|
||||
schemas = [schema for schema in schemas if schema is not None]
|
||||
return allowlist | collect_allowlist_from_schemas(
|
||||
schemas=schemas,
|
||||
channels=channels,
|
||||
)
|
||||
|
||||
|
||||
def collect_allowlist_from_schemas(
|
||||
*,
|
||||
schemas: list[type[Any]] | None = None,
|
||||
channels: dict[str, Any] | None = None,
|
||||
) -> set[tuple[str, ...]]:
|
||||
allowlist: set[tuple[str, ...]] = set()
|
||||
seen: set[Any] = set()
|
||||
seen_ids: set[int] = set()
|
||||
|
||||
if schemas:
|
||||
for schema in schemas:
|
||||
_collect_from_type(schema, allowlist, seen, seen_ids)
|
||||
|
||||
if channels:
|
||||
for channel in channels.values():
|
||||
value_type = getattr(channel, "ValueType", None)
|
||||
if value_type is not None:
|
||||
_collect_from_type(value_type, allowlist, seen, seen_ids)
|
||||
update_type = getattr(channel, "UpdateType", None)
|
||||
if update_type is not None:
|
||||
_collect_from_type(update_type, allowlist, seen, seen_ids)
|
||||
|
||||
return allowlist
|
||||
|
||||
|
||||
def _collect_from_type(
|
||||
typ: Any,
|
||||
allowlist: set[tuple[str, ...]],
|
||||
seen: set[Any],
|
||||
seen_ids: set[int],
|
||||
) -> None:
|
||||
if _already_seen(typ, seen, seen_ids):
|
||||
return
|
||||
|
||||
if typ is Any or typ is None:
|
||||
return
|
||||
|
||||
if typ is Literal:
|
||||
return
|
||||
|
||||
if isinstance(typ, types.UnionType):
|
||||
for arg in typ.__args__:
|
||||
_collect_from_type(arg, allowlist, seen, seen_ids)
|
||||
return
|
||||
|
||||
origin = get_origin(typ)
|
||||
if origin is Union:
|
||||
for arg in get_args(typ):
|
||||
_collect_from_type(arg, allowlist, seen, seen_ids)
|
||||
return
|
||||
if origin is Annotated or origin in (Required, NotRequired):
|
||||
args = get_args(typ)
|
||||
if args:
|
||||
_collect_from_type(args[0], allowlist, seen, seen_ids)
|
||||
return
|
||||
|
||||
if origin is Literal:
|
||||
return
|
||||
|
||||
if origin in (list, set, tuple, dict, deque, frozenset):
|
||||
for arg in get_args(typ):
|
||||
_collect_from_type(arg, allowlist, seen, seen_ids)
|
||||
return
|
||||
|
||||
if hasattr(typ, "__supertype__"):
|
||||
_collect_from_type(typ.__supertype__, allowlist, seen, seen_ids)
|
||||
return
|
||||
|
||||
if is_typeddict(typ):
|
||||
for field_type in _safe_get_type_hints(typ).values():
|
||||
_collect_from_type(field_type, allowlist, seen, seen_ids)
|
||||
return
|
||||
|
||||
if _is_pydantic_model(typ):
|
||||
allowlist.add((typ.__module__, typ.__name__))
|
||||
field_types = _safe_get_type_hints(typ)
|
||||
if field_types:
|
||||
for field_type in field_types.values():
|
||||
_collect_from_type(field_type, allowlist, seen, seen_ids)
|
||||
else:
|
||||
for field_type in _pydantic_field_types(typ):
|
||||
_collect_from_type(field_type, allowlist, seen, seen_ids)
|
||||
return
|
||||
|
||||
if dataclasses.is_dataclass(typ):
|
||||
if typ_name := getattr(typ, "__name__", None):
|
||||
allowlist.add((typ.__module__, typ_name))
|
||||
field_types = _safe_get_type_hints(typ)
|
||||
if field_types:
|
||||
for field_type in field_types.values():
|
||||
_collect_from_type(field_type, allowlist, seen, seen_ids)
|
||||
else:
|
||||
for field in dataclasses.fields(typ):
|
||||
_collect_from_type(field.type, allowlist, seen, seen_ids)
|
||||
return
|
||||
|
||||
if isinstance(typ, type) and issubclass(typ, Enum):
|
||||
allowlist.add((typ.__module__, typ.__name__))
|
||||
return
|
||||
|
||||
|
||||
def _already_seen(typ: Any, seen: set[Any], seen_ids: set[int]) -> bool:
|
||||
try:
|
||||
if typ in seen:
|
||||
return True
|
||||
seen.add(typ)
|
||||
return False
|
||||
except TypeError:
|
||||
typ_id = id(typ)
|
||||
if typ_id in seen_ids:
|
||||
return True
|
||||
seen_ids.add(typ_id)
|
||||
return False
|
||||
|
||||
|
||||
def _safe_get_type_hints(typ: Any) -> dict[str, Any]:
|
||||
try:
|
||||
module = sys.modules.get(getattr(typ, "__module__", ""))
|
||||
globalns = module.__dict__ if module else None
|
||||
localns = dict(vars(typ)) if hasattr(typ, "__dict__") else None
|
||||
return get_type_hints(
|
||||
typ, globalns=globalns, localns=localns, include_extras=True
|
||||
)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _is_pydantic_model(typ: Any) -> bool:
|
||||
if not isinstance(typ, type):
|
||||
return False
|
||||
if issubclass(typ, BaseModel):
|
||||
return True
|
||||
try:
|
||||
from pydantic.v1 import BaseModel as BaseModelV1
|
||||
except Exception:
|
||||
return False
|
||||
return issubclass(typ, BaseModelV1)
|
||||
|
||||
|
||||
def _pydantic_field_types(typ: type[Any]) -> list[Any]:
|
||||
if hasattr(typ, "model_fields"):
|
||||
return [
|
||||
field.annotation
|
||||
for field in typ.model_fields.values()
|
||||
if getattr(field, "annotation", None) is not None
|
||||
]
|
||||
if hasattr(typ, "__fields__"):
|
||||
return [
|
||||
field.outer_type_
|
||||
for field in typ.__fields__.values()
|
||||
if getattr(field, "outer_type_", None) is not None
|
||||
]
|
||||
return []
|
||||
@@ -20,6 +20,7 @@ from langgraph.checkpoint.base import BaseCheckpointSaver
|
||||
from langgraph.store.base import BaseStore
|
||||
from typing_extensions import Unpack
|
||||
|
||||
from langgraph._internal import _serde
|
||||
from langgraph._internal._constants import CACHE_NS_WRITES, PREVIOUS
|
||||
from langgraph._internal._typing import MISSING, DeprecatedKwargs
|
||||
from langgraph.channels.ephemeral_value import EphemeralValue
|
||||
@@ -528,7 +529,7 @@ class entrypoint(Generic[ContextT]):
|
||||
else:
|
||||
output_type = save_type = sig.return_annotation
|
||||
|
||||
return Pregel(
|
||||
graph: Pregel[Any, ContextT, Any, Any] = Pregel(
|
||||
nodes={
|
||||
func.__name__: PregelNode(
|
||||
bound=bound,
|
||||
@@ -559,5 +560,16 @@ class entrypoint(Generic[ContextT]):
|
||||
cache=self.cache,
|
||||
cache_policy=self.cache_policy,
|
||||
retry_policy=self.retry_policy or (),
|
||||
context_schema=self.context_schema, # type: ignore[arg-type]
|
||||
context_schema=self.context_schema,
|
||||
)
|
||||
if _serde.STRICT_MSGPACK_ENABLED:
|
||||
serde_allowlist = _serde.build_serde_allowlist(
|
||||
schemas=[input_type, output_type, save_type]
|
||||
+ ([self.context_schema] if self.context_schema is not None else []),
|
||||
channels=graph.channels,
|
||||
)
|
||||
graph._serde_allowlist = serde_allowlist
|
||||
graph.checkpointer = _serde.apply_checkpointer_allowlist(
|
||||
graph.checkpointer, serde_allowlist
|
||||
)
|
||||
return graph
|
||||
|
||||
@@ -29,6 +29,7 @@ from langgraph.store.base import BaseStore
|
||||
from pydantic import BaseModel, TypeAdapter
|
||||
from typing_extensions import NotRequired, Required, Self, Unpack, is_typeddict
|
||||
|
||||
from langgraph._internal import _serde
|
||||
from langgraph._internal._constants import (
|
||||
INTERRUPT,
|
||||
NS_END,
|
||||
@@ -1079,6 +1080,28 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
|
||||
CompiledStateGraph: The compiled `StateGraph`.
|
||||
"""
|
||||
checkpointer = ensure_valid_checkpointer(checkpointer)
|
||||
serde_allowlist: set[tuple[str, ...]] | None = None
|
||||
if _serde.STRICT_MSGPACK_ENABLED:
|
||||
schema_types: list[type[Any]] = [
|
||||
self.state_schema,
|
||||
self.input_schema,
|
||||
self.output_schema,
|
||||
]
|
||||
if self.context_schema is not None:
|
||||
schema_types.append(self.context_schema)
|
||||
for node in self.nodes.values():
|
||||
schema_types.append(node.input_schema)
|
||||
for branches in self.branches.values():
|
||||
for branch in branches.values():
|
||||
if branch.input_schema is not None:
|
||||
schema_types.append(branch.input_schema)
|
||||
serde_allowlist = _serde.build_serde_allowlist(
|
||||
schemas=schema_types,
|
||||
channels=self.channels,
|
||||
)
|
||||
checkpointer = _serde.apply_checkpointer_allowlist(
|
||||
checkpointer, serde_allowlist
|
||||
)
|
||||
|
||||
# assign default values
|
||||
interrupt_before = interrupt_before or []
|
||||
@@ -1135,6 +1158,7 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
|
||||
cache=cache,
|
||||
name=name or "LangGraph",
|
||||
)
|
||||
compiled._serde_allowlist = serde_allowlist
|
||||
|
||||
compiled.attach_node(START, None)
|
||||
for key, node in self.nodes.items():
|
||||
|
||||
@@ -42,6 +42,7 @@ from langgraph._internal._constants import (
|
||||
CONFIG_KEY_CHECKPOINT_ID,
|
||||
CONFIG_KEY_CHECKPOINT_MAP,
|
||||
CONFIG_KEY_CHECKPOINT_NS,
|
||||
CONFIG_KEY_REPLAYING,
|
||||
CONFIG_KEY_RESUME_MAP,
|
||||
CONFIG_KEY_RESUMING,
|
||||
CONFIG_KEY_SCRATCHPAD,
|
||||
@@ -152,7 +153,7 @@ class PregelLoop:
|
||||
input_keys: str | Sequence[str]
|
||||
output_keys: str | Sequence[str]
|
||||
stream_keys: str | Sequence[str]
|
||||
skip_done_tasks: bool
|
||||
is_replaying: bool
|
||||
is_nested: bool
|
||||
manager: None | AsyncParentRunManager | ParentRunManager
|
||||
interrupt_after: All | Sequence[str]
|
||||
@@ -244,7 +245,9 @@ class PregelLoop:
|
||||
self.interrupt_before = interrupt_before
|
||||
self.manager = manager
|
||||
self.is_nested = CONFIG_KEY_TASK_ID in self.config.get(CONF, {})
|
||||
self.skip_done_tasks = CONFIG_KEY_CHECKPOINT_ID not in config[CONF]
|
||||
self.is_replaying = CONFIG_KEY_CHECKPOINT_ID in config[CONF] or config[
|
||||
CONF
|
||||
].get(CONFIG_KEY_REPLAYING, False)
|
||||
self._migrate_checkpoint = migrate_checkpoint
|
||||
self.trigger_to_nodes = trigger_to_nodes
|
||||
self.retry_policy = retry_policy
|
||||
@@ -451,7 +454,7 @@ class PregelLoop:
|
||||
# save the new task
|
||||
self.tasks[pushed.id] = pushed
|
||||
# match any pending writes to the new task
|
||||
if self.skip_done_tasks:
|
||||
if not self.is_replaying:
|
||||
self._match_writes({pushed.id: pushed})
|
||||
# return the new task, to be started if not run before
|
||||
return pushed
|
||||
@@ -515,7 +518,7 @@ class PregelLoop:
|
||||
return False
|
||||
|
||||
# if there are pending writes from a previous loop, apply them
|
||||
if self.skip_done_tasks and self.checkpoint_pending_writes:
|
||||
if not self.is_replaying and self.checkpoint_pending_writes:
|
||||
self._match_writes(self.tasks)
|
||||
|
||||
# before execution, check if we should interrupt
|
||||
@@ -557,8 +560,8 @@ class PregelLoop:
|
||||
)
|
||||
# clear pending writes
|
||||
self.checkpoint_pending_writes.clear()
|
||||
# "not skip_done_tasks" only applies to first tick after resuming
|
||||
self.skip_done_tasks = True
|
||||
# only replay (re-execute) done tasks on the first tick
|
||||
self.is_replaying = False
|
||||
# save checkpoint
|
||||
self._put_checkpoint({"source": "loop"})
|
||||
# after execution, check if we should interrupt
|
||||
@@ -567,8 +570,9 @@ class PregelLoop:
|
||||
):
|
||||
self.status = "interrupt_after"
|
||||
raise GraphInterrupt()
|
||||
# unset resuming flag
|
||||
# unset resuming/replaying flags
|
||||
self.config[CONF].pop(CONFIG_KEY_RESUMING, None)
|
||||
self.config[CONF].pop(CONFIG_KEY_REPLAYING, None)
|
||||
|
||||
def match_cached_writes(self) -> Sequence[PregelExecutableTask]:
|
||||
raise NotImplementedError
|
||||
@@ -618,22 +622,42 @@ class PregelLoop:
|
||||
def _first(
|
||||
self, *, input_keys: str | Sequence[str], updated_channels: set[str] | None
|
||||
) -> set[str] | None:
|
||||
# resuming from previous checkpoint requires
|
||||
# - finding a previous checkpoint
|
||||
# - receiving None input (outer graph) or RESUMING flag (subgraph)
|
||||
# Resuming from a previous checkpoint requires two things:
|
||||
# 1. A prior checkpoint exists (channel_versions is non-empty)
|
||||
# 2. The input signals continuation (not a fresh run with new input)
|
||||
configurable = self.config.get(CONF, {})
|
||||
is_resuming = bool(self.checkpoint["channel_versions"]) and bool(
|
||||
configurable.get(
|
||||
CONFIG_KEY_RESUMING,
|
||||
self.input is None
|
||||
or isinstance(self.input, Command)
|
||||
or (
|
||||
not self.is_nested
|
||||
and self.config.get("metadata", {}).get("run_id")
|
||||
== self.checkpoint_metadata.get("run_id", MISSING)
|
||||
),
|
||||
has_prior_checkpoint = bool(self.checkpoint["channel_versions"])
|
||||
# For subgraphs, the parent explicitly sets CONFIG_KEY_RESUMING.
|
||||
# For the outer graph, we infer from the input:
|
||||
# - None input: resume after interrupt (invoke(None, config))
|
||||
# - Command input: any Command operates on existing state
|
||||
# - Same run_id: re-entry into an ongoing run (e.g. stream reconnect)
|
||||
input_signals_resume = (
|
||||
self.input is None
|
||||
or isinstance(self.input, Command)
|
||||
or (
|
||||
not self.is_nested
|
||||
and self.config.get("metadata", {}).get("run_id")
|
||||
== self.checkpoint_metadata.get("run_id", MISSING)
|
||||
)
|
||||
)
|
||||
is_resuming = has_prior_checkpoint and bool(
|
||||
configurable.get(CONFIG_KEY_RESUMING, input_signals_resume)
|
||||
)
|
||||
|
||||
# When replaying from a specific checkpoint, drop cached RESUME
|
||||
# writes so that interrupt() calls re-fire instead of returning
|
||||
# stale values. But if a resume value is being provided (e.g.
|
||||
# Command(resume=...) or CONFIG_KEY_RESUMING), keep them —
|
||||
# multi-interrupt scenarios need previously resolved values preserved.
|
||||
if self.is_replaying:
|
||||
is_resume_with_value = (
|
||||
isinstance(self.input, Command) and self.input.resume is not None
|
||||
) or configurable.get(CONFIG_KEY_RESUMING, False)
|
||||
if not is_resume_with_value:
|
||||
self.checkpoint_pending_writes = [
|
||||
w for w in self.checkpoint_pending_writes if w[1] != RESUME
|
||||
]
|
||||
|
||||
# map command to writes
|
||||
if isinstance(self.input, Command):
|
||||
@@ -723,10 +747,14 @@ class PregelLoop:
|
||||
self._put_checkpoint({"source": "input"})
|
||||
elif CONFIG_KEY_RESUMING not in configurable:
|
||||
raise EmptyInputError(f"Received no input for {input_keys}")
|
||||
# update config
|
||||
# Propagate resuming and replaying flags to subgraphs.
|
||||
if not self.is_nested:
|
||||
self.config = patch_configurable(
|
||||
self.config, {CONFIG_KEY_RESUMING: is_resuming}
|
||||
self.config,
|
||||
{
|
||||
CONFIG_KEY_RESUMING: is_resuming,
|
||||
CONFIG_KEY_REPLAYING: self.is_replaying,
|
||||
},
|
||||
)
|
||||
# set flag
|
||||
self.status = "pending"
|
||||
@@ -1078,6 +1106,59 @@ class SyncPregelLoop(PregelLoop, AbstractContextManager):
|
||||
},
|
||||
)
|
||||
|
||||
def _get_parent_checkpoint_id(self) -> str | None:
|
||||
"""Get the parent checkpoint_id to use as an upper bound for finding
|
||||
the subgraph's checkpoint. For forks, we need the original parent
|
||||
checkpoint (not the fork), so we look up the parent checkpoint's
|
||||
parent_config."""
|
||||
checkpoint_map = self.config[CONF].get(CONFIG_KEY_CHECKPOINT_MAP, {})
|
||||
parent_ns = NS_SEP.join(self.checkpoint_ns[:-1]) if self.checkpoint_ns else ""
|
||||
parent_checkpoint_id = checkpoint_map.get(parent_ns)
|
||||
if not parent_checkpoint_id or not self.checkpointer:
|
||||
return None
|
||||
# Check if this is a fork (source=update) — if so, use the fork's
|
||||
# parent checkpoint_id instead, since the fork was created after
|
||||
# the subgraph's checkpoints from the original execution.
|
||||
parent_config: RunnableConfig = {
|
||||
**self.checkpoint_config,
|
||||
CONF: {
|
||||
**self.checkpoint_config.get(CONF, {}),
|
||||
CONFIG_KEY_CHECKPOINT_NS: parent_ns,
|
||||
CONFIG_KEY_CHECKPOINT_ID: parent_checkpoint_id,
|
||||
},
|
||||
}
|
||||
parent_saved = self.checkpointer.get_tuple(parent_config)
|
||||
if parent_saved and parent_saved.metadata.get("source") == "update":
|
||||
if parent_saved.parent_config:
|
||||
return parent_saved.parent_config[CONF].get(CONFIG_KEY_CHECKPOINT_ID)
|
||||
return parent_checkpoint_id
|
||||
|
||||
def _get_checkpoint_before_parent(self) -> CheckpointTuple | None:
|
||||
"""Find the subgraph checkpoint that was current at the parent's
|
||||
checkpoint time, using the parent checkpoint_id as an upper bound.
|
||||
|
||||
Returns a CheckpointTuple with the historical channel_values but fresh
|
||||
execution state (empty channel_versions/versions_seen) so the subgraph
|
||||
re-runs its nodes while retaining accumulated data.
|
||||
|
||||
Returns None to start fresh if no such checkpoint exists."""
|
||||
parent_checkpoint_id = self._get_parent_checkpoint_id()
|
||||
if parent_checkpoint_id and self.checkpointer:
|
||||
before_config: RunnableConfig = {
|
||||
CONF: {"checkpoint_id": parent_checkpoint_id}
|
||||
}
|
||||
for saved in self.checkpointer.list(
|
||||
self.checkpoint_config, before=before_config, limit=1
|
||||
):
|
||||
checkpoint = empty_checkpoint()
|
||||
checkpoint["channel_values"] = saved.checkpoint.get(
|
||||
"channel_values", {}
|
||||
)
|
||||
return CheckpointTuple(
|
||||
self.checkpoint_config, checkpoint, {"step": -2}, None, []
|
||||
)
|
||||
return None
|
||||
|
||||
# context manager
|
||||
|
||||
def __enter__(self) -> Self:
|
||||
@@ -1085,6 +1166,16 @@ class SyncPregelLoop(PregelLoop, AbstractContextManager):
|
||||
saved = self.checkpointer.get_tuple(self.checkpoint_config)
|
||||
else:
|
||||
saved = None
|
||||
# When replaying a subgraph that wasn't in the checkpoint map
|
||||
# (parent checkpoint predates this subgraph), start fresh.
|
||||
# For stateful subgraphs (checkpointer=True), find the checkpoint
|
||||
# that was current at the parent's checkpoint time.
|
||||
if (
|
||||
saved is not None
|
||||
and self.config[CONF].get(CONFIG_KEY_REPLAYING)
|
||||
and not self.checkpoint_config.get(CONF, {}).get(CONFIG_KEY_CHECKPOINT_ID)
|
||||
):
|
||||
saved = self._get_checkpoint_before_parent()
|
||||
if saved is None:
|
||||
saved = CheckpointTuple(
|
||||
self.checkpoint_config, empty_checkpoint(), {"step": -2}, None, []
|
||||
@@ -1109,7 +1200,6 @@ class SyncPregelLoop(PregelLoop, AbstractContextManager):
|
||||
if saved.pending_writes is not None
|
||||
else []
|
||||
)
|
||||
|
||||
self.submit = self.stack.enter_context(BackgroundExecutor(self.config))
|
||||
self.channels, self.managed = channels_from_checkpoint(
|
||||
self.specs, self.checkpoint
|
||||
@@ -1257,6 +1347,48 @@ class AsyncPregelLoop(PregelLoop, AbstractAsyncContextManager):
|
||||
},
|
||||
)
|
||||
|
||||
async def _aget_parent_checkpoint_id(self) -> str | None:
|
||||
checkpoint_map = self.config[CONF].get(CONFIG_KEY_CHECKPOINT_MAP, {})
|
||||
parent_ns = NS_SEP.join(self.checkpoint_ns[:-1]) if self.checkpoint_ns else ""
|
||||
parent_checkpoint_id = checkpoint_map.get(parent_ns)
|
||||
if not parent_checkpoint_id or not self.checkpointer:
|
||||
return None
|
||||
parent_config: RunnableConfig = {
|
||||
**self.checkpoint_config,
|
||||
CONF: {
|
||||
**self.checkpoint_config.get(CONF, {}),
|
||||
CONFIG_KEY_CHECKPOINT_NS: parent_ns,
|
||||
CONFIG_KEY_CHECKPOINT_ID: parent_checkpoint_id,
|
||||
},
|
||||
}
|
||||
parent_saved = await self.checkpointer.aget_tuple(parent_config)
|
||||
if parent_saved and parent_saved.metadata.get("source") == "update":
|
||||
if parent_saved.parent_config:
|
||||
return parent_saved.parent_config[CONF].get(CONFIG_KEY_CHECKPOINT_ID)
|
||||
return parent_checkpoint_id
|
||||
|
||||
async def _aget_checkpoint_before_parent(self) -> CheckpointTuple | None:
|
||||
parent_checkpoint_id = await self._aget_parent_checkpoint_id()
|
||||
if parent_checkpoint_id and self.checkpointer:
|
||||
before_config: RunnableConfig = {
|
||||
CONF: {"checkpoint_id": parent_checkpoint_id}
|
||||
}
|
||||
async for saved in self.checkpointer.alist(
|
||||
self.checkpoint_config, before=before_config, limit=1
|
||||
):
|
||||
checkpoint = empty_checkpoint()
|
||||
checkpoint["channel_values"] = saved.checkpoint.get(
|
||||
"channel_values", {}
|
||||
)
|
||||
return CheckpointTuple(
|
||||
self.checkpoint_config,
|
||||
checkpoint,
|
||||
{"step": -2},
|
||||
None,
|
||||
[],
|
||||
)
|
||||
return None
|
||||
|
||||
# context manager
|
||||
|
||||
async def __aenter__(self) -> Self:
|
||||
@@ -1264,6 +1396,16 @@ class AsyncPregelLoop(PregelLoop, AbstractAsyncContextManager):
|
||||
saved = await self.checkpointer.aget_tuple(self.checkpoint_config)
|
||||
else:
|
||||
saved = None
|
||||
# When replaying a subgraph that wasn't in the checkpoint map
|
||||
# (parent checkpoint predates this subgraph), start fresh.
|
||||
# For stateful subgraphs (checkpointer=True), find the checkpoint
|
||||
# that was current at the parent's checkpoint time.
|
||||
if (
|
||||
saved is not None
|
||||
and self.config[CONF].get(CONFIG_KEY_REPLAYING)
|
||||
and not self.checkpoint_config.get(CONF, {}).get(CONFIG_KEY_CHECKPOINT_ID)
|
||||
):
|
||||
saved = await self._aget_checkpoint_before_parent()
|
||||
if saved is None:
|
||||
saved = CheckpointTuple(
|
||||
self.checkpoint_config, empty_checkpoint(), {"step": -2}, None, []
|
||||
@@ -1288,7 +1430,6 @@ class AsyncPregelLoop(PregelLoop, AbstractAsyncContextManager):
|
||||
if saved.pending_writes is not None
|
||||
else []
|
||||
)
|
||||
|
||||
self.submit = await self.stack.enter_async_context(
|
||||
AsyncBackgroundExecutor(self.config)
|
||||
)
|
||||
|
||||
@@ -23,6 +23,35 @@ logger = logging.getLogger(__name__)
|
||||
SUPPORTS_EXC_NOTES = sys.version_info >= (3, 11)
|
||||
|
||||
|
||||
def _checkpoint_ns_for_parent_command(ns: str) -> str:
|
||||
"""Return the checkpoint namespace for the parent graph.
|
||||
|
||||
The checkpoint namespace is a `|`-separated path. Each segment is usually
|
||||
of the form `name:task_id` (e.g. `parent_first:<uuid>|node:<uuid>`), but the
|
||||
runtime may also insert a purely-numeric segment (e.g. `|1`) to disambiguate
|
||||
concurrent tasks (e.g. `parent_first:<uuid>|1|node:<uuid>`).
|
||||
|
||||
Numeric segments are not real path levels, so we drop them before computing
|
||||
the parent namespace.
|
||||
"""
|
||||
|
||||
parts = ns.split(NS_SEP)
|
||||
|
||||
# Drop any trailing numeric selectors for the current frame (e.g. `...|node:<id>|1`).
|
||||
while parts and parts[-1].isdigit():
|
||||
parts.pop()
|
||||
|
||||
# Drop the current frame segment itself (e.g. the `node:<id>`).
|
||||
if parts:
|
||||
parts.pop()
|
||||
|
||||
# Drop any trailing numeric selectors for the parent frame (e.g. `...|1|node:<id>`).
|
||||
while parts and parts[-1].isdigit():
|
||||
parts.pop()
|
||||
|
||||
return NS_SEP.join(parts)
|
||||
|
||||
|
||||
def run_with_retry(
|
||||
task: PregelExecutableTask,
|
||||
retry_policy: Sequence[RetryPolicy] | None,
|
||||
@@ -50,12 +79,8 @@ def run_with_retry(
|
||||
w.invoke(cmd, config)
|
||||
break
|
||||
elif cmd.graph == Command.PARENT:
|
||||
# this command is for the parent graph, assign it to the parent
|
||||
parts = ns.split(NS_SEP)
|
||||
if parts[-1].isdigit():
|
||||
parts.pop()
|
||||
parent_ns = NS_SEP.join(parts[:-1])
|
||||
exc.args = (replace(cmd, graph=parent_ns),)
|
||||
# this command is for the parent graph, assign it to the parent.
|
||||
exc.args = (replace(cmd, graph=_checkpoint_ns_for_parent_command(ns)),)
|
||||
# bubble up
|
||||
raise
|
||||
except GraphBubbleUp:
|
||||
@@ -146,12 +171,8 @@ async def arun_with_retry(
|
||||
w.invoke(cmd, config)
|
||||
break
|
||||
elif cmd.graph == Command.PARENT:
|
||||
# this command is for the parent graph, assign it to the parent
|
||||
parts = ns.split(NS_SEP)
|
||||
if parts[-1].isdigit():
|
||||
parts.pop()
|
||||
parent_ns = NS_SEP.join(parts[:-1])
|
||||
exc.args = (replace(cmd, graph=parent_ns),)
|
||||
# this command is for the parent graph, assign it to the parent.
|
||||
exc.args = (replace(cmd, graph=_checkpoint_ns_for_parent_command(ns)),)
|
||||
# bubble up
|
||||
raise
|
||||
except GraphBubbleUp:
|
||||
|
||||
@@ -728,7 +728,6 @@ async def _acall_impl(
|
||||
)
|
||||
else:
|
||||
fut.set_result(None)
|
||||
futures()[fut] = next_task # type: ignore[index]
|
||||
else:
|
||||
# schedule the next task
|
||||
fut = cast(
|
||||
|
||||
@@ -48,6 +48,7 @@ from langgraph.store.base import BaseStore
|
||||
from pydantic import BaseModel, TypeAdapter
|
||||
from typing_extensions import Self, Unpack, deprecated, is_typeddict
|
||||
|
||||
from langgraph._internal import _serde
|
||||
from langgraph._internal._config import (
|
||||
ensure_config,
|
||||
merge_configs,
|
||||
@@ -698,9 +699,17 @@ class Pregel(
|
||||
self.config = config
|
||||
self.trigger_to_nodes = trigger_to_nodes or {}
|
||||
self.name = name
|
||||
self._serde_allowlist: set[tuple[str, ...]] | None = None
|
||||
if auto_validate:
|
||||
self.validate()
|
||||
|
||||
def _apply_checkpointer_allowlist(
|
||||
self, checkpointer: BaseCheckpointSaver | None
|
||||
) -> BaseCheckpointSaver | None:
|
||||
if not _serde.STRICT_MSGPACK_ENABLED:
|
||||
return checkpointer
|
||||
return _serde.apply_checkpointer_allowlist(checkpointer, self._serde_allowlist)
|
||||
|
||||
def get_graph(
|
||||
self, config: RunnableConfig | None = None, *, xray: int | bool = False
|
||||
) -> Graph:
|
||||
@@ -1239,6 +1248,8 @@ class Pregel(
|
||||
checkpointer: BaseCheckpointSaver | None = ensure_config(config)[CONF].get(
|
||||
CONFIG_KEY_CHECKPOINTER, self.checkpointer
|
||||
)
|
||||
if isinstance(checkpointer, BaseCheckpointSaver):
|
||||
checkpointer = self._apply_checkpointer_allowlist(checkpointer)
|
||||
if not checkpointer:
|
||||
raise ValueError("No checkpointer set")
|
||||
|
||||
@@ -1281,6 +1292,8 @@ class Pregel(
|
||||
checkpointer: BaseCheckpointSaver | None = ensure_config(config)[CONF].get(
|
||||
CONFIG_KEY_CHECKPOINTER, self.checkpointer
|
||||
)
|
||||
if isinstance(checkpointer, BaseCheckpointSaver):
|
||||
checkpointer = self._apply_checkpointer_allowlist(checkpointer)
|
||||
if not checkpointer:
|
||||
raise ValueError("No checkpointer set")
|
||||
|
||||
@@ -1329,6 +1342,8 @@ class Pregel(
|
||||
checkpointer: BaseCheckpointSaver | None = config[CONF].get(
|
||||
CONFIG_KEY_CHECKPOINTER, self.checkpointer
|
||||
)
|
||||
if isinstance(checkpointer, BaseCheckpointSaver):
|
||||
checkpointer = self._apply_checkpointer_allowlist(checkpointer)
|
||||
if not checkpointer:
|
||||
raise ValueError("No checkpointer set")
|
||||
|
||||
@@ -1380,6 +1395,8 @@ class Pregel(
|
||||
checkpointer: BaseCheckpointSaver | None = ensure_config(config)[CONF].get(
|
||||
CONFIG_KEY_CHECKPOINTER, self.checkpointer
|
||||
)
|
||||
if isinstance(checkpointer, BaseCheckpointSaver):
|
||||
checkpointer = self._apply_checkpointer_allowlist(checkpointer)
|
||||
if not checkpointer:
|
||||
raise ValueError("No checkpointer set")
|
||||
|
||||
@@ -1446,6 +1463,8 @@ class Pregel(
|
||||
checkpointer: BaseCheckpointSaver | None = ensure_config(config)[CONF].get(
|
||||
CONFIG_KEY_CHECKPOINTER, self.checkpointer
|
||||
)
|
||||
if isinstance(checkpointer, BaseCheckpointSaver):
|
||||
checkpointer = self._apply_checkpointer_allowlist(checkpointer)
|
||||
if not checkpointer:
|
||||
raise ValueError("No checkpointer set")
|
||||
|
||||
@@ -1890,6 +1909,8 @@ class Pregel(
|
||||
checkpointer: BaseCheckpointSaver | None = ensure_config(config)[CONF].get(
|
||||
CONFIG_KEY_CHECKPOINTER, self.checkpointer
|
||||
)
|
||||
if isinstance(checkpointer, BaseCheckpointSaver):
|
||||
checkpointer = self._apply_checkpointer_allowlist(checkpointer)
|
||||
if not checkpointer:
|
||||
raise ValueError("No checkpointer set")
|
||||
|
||||
@@ -2378,6 +2399,8 @@ class Pregel(
|
||||
raise RuntimeError("checkpointer=True cannot be used for root graphs.")
|
||||
else:
|
||||
checkpointer = self.checkpointer
|
||||
if isinstance(checkpointer, BaseCheckpointSaver):
|
||||
checkpointer = self._apply_checkpointer_allowlist(checkpointer)
|
||||
if checkpointer and not config.get(CONF):
|
||||
raise ValueError(
|
||||
"Checkpointer requires one or more of the following 'configurable' "
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "langgraph"
|
||||
version = "1.0.8"
|
||||
version = "1.0.10"
|
||||
description = "Building stateful, multi-actor applications with LLMs"
|
||||
authors = []
|
||||
requires-python = ">=3.10"
|
||||
@@ -27,7 +27,7 @@ dependencies = [
|
||||
"langchain-core>=0.1",
|
||||
"langgraph-checkpoint>=2.1.0,<5.0.0",
|
||||
"langgraph-sdk>=0.3.0,<0.4.0",
|
||||
"langgraph-prebuilt>=1.0.7,<1.1.0",
|
||||
"langgraph-prebuilt>=1.0.8,<1.1.0",
|
||||
"xxhash>=3.5.0",
|
||||
"pydantic>=2.7.4",
|
||||
]
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,3 +1,4 @@
|
||||
import os
|
||||
from contextlib import asynccontextmanager, contextmanager
|
||||
from uuid import uuid4
|
||||
|
||||
@@ -5,6 +6,7 @@ import pytest
|
||||
from langgraph.checkpoint.postgres import PostgresSaver
|
||||
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
|
||||
from langgraph.checkpoint.serde.encrypted import EncryptedSerializer
|
||||
from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
|
||||
from langgraph.checkpoint.sqlite import SqliteSaver
|
||||
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
|
||||
from psycopg import AsyncConnection, Connection
|
||||
@@ -18,30 +20,60 @@ from tests.memory_assert import ( # noqa: E402
|
||||
)
|
||||
|
||||
DEFAULT_POSTGRES_URI = "postgres://postgres:postgres@localhost:5442/"
|
||||
STRICT_MSGPACK = os.getenv("LANGGRAPH_STRICT_MSGPACK", "false").lower() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
)
|
||||
|
||||
|
||||
def _strict_msgpack_serde() -> JsonPlusSerializer:
|
||||
return JsonPlusSerializer(allowed_msgpack_modules=None)
|
||||
|
||||
|
||||
def _apply_strict_msgpack(checkpointer) -> None:
|
||||
if not STRICT_MSGPACK:
|
||||
return
|
||||
serde = _strict_msgpack_serde()
|
||||
if hasattr(checkpointer, "serde"):
|
||||
checkpointer.serde = serde
|
||||
if hasattr(checkpointer, "saver") and hasattr(checkpointer.saver, "serde"):
|
||||
checkpointer.saver.serde = serde
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _checkpointer_memory():
|
||||
yield MemorySaverAssertImmutable()
|
||||
if STRICT_MSGPACK:
|
||||
yield MemorySaverAssertImmutable(serde=_strict_msgpack_serde())
|
||||
else:
|
||||
yield MemorySaverAssertImmutable()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _checkpointer_memory_migrate_sends():
|
||||
yield MemorySaverNeedsPendingSendsMigration()
|
||||
checkpointer = MemorySaverNeedsPendingSendsMigration()
|
||||
_apply_strict_msgpack(checkpointer)
|
||||
yield checkpointer
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _checkpointer_sqlite():
|
||||
with SqliteSaver.from_conn_string(":memory:") as checkpointer:
|
||||
_apply_strict_msgpack(checkpointer)
|
||||
yield checkpointer
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _checkpointer_sqlite_aes():
|
||||
with SqliteSaver.from_conn_string(":memory:") as checkpointer:
|
||||
checkpointer.serde = EncryptedSerializer.from_pycryptodome_aes(
|
||||
key=b"1234567890123456"
|
||||
)
|
||||
if STRICT_MSGPACK:
|
||||
checkpointer.serde = EncryptedSerializer.from_pycryptodome_aes(
|
||||
serde=_strict_msgpack_serde(), key=b"1234567890123456"
|
||||
)
|
||||
else:
|
||||
checkpointer.serde = EncryptedSerializer.from_pycryptodome_aes(
|
||||
key=b"1234567890123456"
|
||||
)
|
||||
yield checkpointer
|
||||
|
||||
|
||||
@@ -57,6 +89,7 @@ def _checkpointer_postgres():
|
||||
DEFAULT_POSTGRES_URI + database
|
||||
) as checkpointer:
|
||||
checkpointer.setup()
|
||||
_apply_strict_msgpack(checkpointer)
|
||||
yield checkpointer
|
||||
finally:
|
||||
# drop unique db
|
||||
@@ -79,6 +112,7 @@ def _checkpointer_postgres_pipe():
|
||||
# setup can't run inside pipeline because of implicit transaction
|
||||
with checkpointer.conn.pipeline() as pipe:
|
||||
checkpointer.pipe = pipe
|
||||
_apply_strict_msgpack(checkpointer)
|
||||
yield checkpointer
|
||||
finally:
|
||||
# drop unique db
|
||||
@@ -99,6 +133,7 @@ def _checkpointer_postgres_pool():
|
||||
) as pool:
|
||||
checkpointer = PostgresSaver(pool)
|
||||
checkpointer.setup()
|
||||
_apply_strict_msgpack(checkpointer)
|
||||
yield checkpointer
|
||||
finally:
|
||||
# drop unique db
|
||||
@@ -109,6 +144,7 @@ def _checkpointer_postgres_pool():
|
||||
@asynccontextmanager
|
||||
async def _checkpointer_sqlite_aio():
|
||||
async with AsyncSqliteSaver.from_conn_string(":memory:") as checkpointer:
|
||||
_apply_strict_msgpack(checkpointer)
|
||||
yield checkpointer
|
||||
|
||||
|
||||
@@ -126,6 +162,7 @@ async def _checkpointer_postgres_aio():
|
||||
DEFAULT_POSTGRES_URI + database
|
||||
) as checkpointer:
|
||||
await checkpointer.setup()
|
||||
_apply_strict_msgpack(checkpointer)
|
||||
yield checkpointer
|
||||
finally:
|
||||
# drop unique db
|
||||
@@ -152,6 +189,7 @@ async def _checkpointer_postgres_aio_pipe():
|
||||
# setup can't run inside pipeline because of implicit transaction
|
||||
async with checkpointer.conn.pipeline() as pipe:
|
||||
checkpointer.pipe = pipe
|
||||
_apply_strict_msgpack(checkpointer)
|
||||
yield checkpointer
|
||||
finally:
|
||||
# drop unique db
|
||||
@@ -176,6 +214,7 @@ async def _checkpointer_postgres_aio_pool():
|
||||
) as pool:
|
||||
checkpointer = AsyncPostgresSaver(pool)
|
||||
await checkpointer.setup()
|
||||
_apply_strict_msgpack(checkpointer)
|
||||
yield checkpointer
|
||||
finally:
|
||||
# drop unique db
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from langgraph.graph import END, START, StateGraph
|
||||
from langgraph.types import Command
|
||||
|
||||
|
||||
def test_parent_command_from_nested_subgraph() -> None:
|
||||
class ParentState(TypedDict):
|
||||
jump_from_idx: int
|
||||
|
||||
class ChildState(TypedDict):
|
||||
jump: bool
|
||||
|
||||
child_builder: StateGraph[ChildState] = StateGraph(ChildState)
|
||||
|
||||
def child_node(state: ChildState) -> Command | ChildState:
|
||||
if state["jump"]:
|
||||
return Command(graph=Command.PARENT, goto="parent_second")
|
||||
return state
|
||||
|
||||
child_builder.add_node("node", child_node)
|
||||
child_builder.add_edge(START, "node")
|
||||
|
||||
child_0 = child_builder.compile()
|
||||
child_1 = child_builder.compile()
|
||||
|
||||
parent_builder: StateGraph[ParentState] = StateGraph(ParentState)
|
||||
|
||||
def parent_first(state: ParentState) -> ParentState:
|
||||
child_0.invoke({"jump": state["jump_from_idx"] == 1})
|
||||
if state["jump_from_idx"] == 1:
|
||||
raise AssertionError("Shouldn't be here")
|
||||
|
||||
child_1.invoke({"jump": state["jump_from_idx"] == 2})
|
||||
if state["jump_from_idx"] == 2:
|
||||
raise AssertionError("Shouldn't be here")
|
||||
|
||||
return state
|
||||
|
||||
def parent_second(state: ParentState) -> ParentState:
|
||||
return state
|
||||
|
||||
parent_builder.add_node("parent_first", parent_first)
|
||||
parent_builder.add_node("parent_second", parent_second)
|
||||
parent_builder.add_edge(START, "parent_first")
|
||||
parent_builder.add_edge("parent_second", END)
|
||||
|
||||
graph = parent_builder.compile()
|
||||
|
||||
assert graph.invoke({"jump_from_idx": 1}) == {"jump_from_idx": 1}
|
||||
assert graph.invoke({"jump_from_idx": 2}) == {"jump_from_idx": 2}
|
||||
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from langgraph.graph import END, START, StateGraph
|
||||
from langgraph.types import Command
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
async def test_parent_command_from_nested_subgraph() -> None:
|
||||
class ParentState(TypedDict):
|
||||
jump_from_idx: int
|
||||
|
||||
class ChildState(TypedDict):
|
||||
jump: bool
|
||||
|
||||
child_builder: StateGraph[ChildState] = StateGraph(ChildState)
|
||||
|
||||
async def child_node(state: ChildState) -> Command | ChildState:
|
||||
if state["jump"]:
|
||||
return Command(graph=Command.PARENT, goto="parent_second")
|
||||
return state
|
||||
|
||||
child_builder.add_node("node", child_node)
|
||||
child_builder.add_edge(START, "node")
|
||||
|
||||
child_0 = child_builder.compile()
|
||||
child_1 = child_builder.compile()
|
||||
|
||||
parent_builder: StateGraph[ParentState] = StateGraph(ParentState)
|
||||
|
||||
async def parent_first(state: ParentState, config: RunnableConfig) -> ParentState:
|
||||
await child_0.ainvoke({"jump": state["jump_from_idx"] == 1}, config)
|
||||
if state["jump_from_idx"] == 1:
|
||||
raise AssertionError("Shouldn't be here")
|
||||
|
||||
await child_1.ainvoke({"jump": state["jump_from_idx"] == 2}, config)
|
||||
if state["jump_from_idx"] == 2:
|
||||
raise AssertionError("Shouldn't be here")
|
||||
|
||||
return state
|
||||
|
||||
async def parent_second(state: ParentState) -> ParentState:
|
||||
return state
|
||||
|
||||
parent_builder.add_node("parent_first", parent_first)
|
||||
parent_builder.add_node("parent_second", parent_second)
|
||||
parent_builder.add_edge(START, "parent_first")
|
||||
parent_builder.add_edge("parent_second", END)
|
||||
|
||||
graph = parent_builder.compile().with_config(recursion_limit=10)
|
||||
|
||||
assert await graph.ainvoke({"jump_from_idx": 1}) == {"jump_from_idx": 1}
|
||||
assert await graph.ainvoke({"jump_from_idx": 2}) == {"jump_from_idx": 2}
|
||||
@@ -5800,6 +5800,284 @@ def test_multiple_interrupts_functional_cache(
|
||||
assert counter == 6
|
||||
|
||||
|
||||
def test_task_before_interrupt_resume(
|
||||
sync_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Test that Command(resume=value) works correctly when a @task runs
|
||||
before interrupt-producing tasks in an @entrypoint.
|
||||
|
||||
The @task wrapper on both setup and ask is essential to reproduce the bug:
|
||||
- @task on setup triggers a mid-step put_writes (creating a new pending_writes list)
|
||||
- @task on ask means interrupt() runs in a child scratchpad that must
|
||||
delegate to the parent for null resume consumption tracking
|
||||
"""
|
||||
|
||||
@entrypoint(checkpointer=sync_checkpointer)
|
||||
def workflow(number_of_topics: int) -> dict:
|
||||
@task
|
||||
def setup() -> int:
|
||||
return number_of_topics
|
||||
|
||||
@task
|
||||
def ask(question: str) -> str:
|
||||
return interrupt(question)
|
||||
|
||||
n = setup().result()
|
||||
|
||||
answers = []
|
||||
for i in range(n):
|
||||
q = f"Whats the answer for topic {i + 1}?"
|
||||
answers.append(ask(q).result())
|
||||
|
||||
return {"answers": answers}
|
||||
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
|
||||
# First invocation - should get first interrupt
|
||||
result = workflow.invoke(2, config=config)
|
||||
assert "__interrupt__" in result
|
||||
assert len(result["__interrupt__"]) == 1
|
||||
assert result["__interrupt__"][0].value == "Whats the answer for topic 1?"
|
||||
|
||||
# Resume with answer for topic 1 - should get second interrupt
|
||||
result = workflow.invoke(Command(resume="answer1"), config=config)
|
||||
assert "__interrupt__" in result, f"Expected interrupt for topic 2, got: {result}"
|
||||
assert len(result["__interrupt__"]) == 1
|
||||
assert result["__interrupt__"][0].value == "Whats the answer for topic 2?"
|
||||
|
||||
# Resume with answer for topic 2 - should get final result
|
||||
result = workflow.invoke(Command(resume="answer2"), config=config)
|
||||
assert result == {"answers": ["answer1", "answer2"]}
|
||||
|
||||
|
||||
def test_multiple_tasks_before_interrupt_resume(
|
||||
sync_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Test that Command(resume=value) works correctly when multiple @tasks
|
||||
run before an interrupt-producing task in an @entrypoint."""
|
||||
|
||||
@entrypoint(checkpointer=sync_checkpointer)
|
||||
def workflow(inputs: dict) -> dict:
|
||||
@task
|
||||
def step_a(x: int) -> int:
|
||||
return x + 1
|
||||
|
||||
@task
|
||||
def step_b(x: int) -> int:
|
||||
return x * 2
|
||||
|
||||
@task
|
||||
def ask(question: str) -> str:
|
||||
return interrupt(question)
|
||||
|
||||
a = step_a(inputs["x"]).result()
|
||||
b = step_b(a).result()
|
||||
|
||||
answer = ask(f"Result so far is {b}. What next?").result()
|
||||
|
||||
return {"computed": b, "answer": answer}
|
||||
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
|
||||
# First invocation - should get interrupt
|
||||
result = workflow.invoke({"x": 5}, config=config)
|
||||
assert "__interrupt__" in result
|
||||
assert result["__interrupt__"][0].value == "Result so far is 12. What next?"
|
||||
|
||||
# Resume
|
||||
result = workflow.invoke(Command(resume="continue"), config=config)
|
||||
assert result == {"computed": 12, "answer": "continue"}
|
||||
|
||||
|
||||
def test_no_redundant_put_writes_for_cached_task(
|
||||
sync_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Cached @tasks on resume must not trigger redundant put_writes."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from langgraph.pregel._loop import PregelLoop
|
||||
|
||||
@task
|
||||
def setup(x: int) -> int:
|
||||
return x
|
||||
|
||||
@task
|
||||
def ask(question: str) -> str:
|
||||
return interrupt(question)
|
||||
|
||||
@entrypoint(checkpointer=sync_checkpointer)
|
||||
def workflow(x: int) -> dict:
|
||||
n = setup(x).result()
|
||||
answer = ask(f"q{n}").result()
|
||||
return {"answer": answer}
|
||||
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
result = workflow.invoke(1, config=config)
|
||||
assert "__interrupt__" in result
|
||||
|
||||
put_writes_task_ids: list[str] = []
|
||||
orig = PregelLoop.put_writes
|
||||
|
||||
def spy(self, task_id, writes):
|
||||
put_writes_task_ids.append(task_id)
|
||||
return orig(self, task_id, writes)
|
||||
|
||||
with patch.object(PregelLoop, "put_writes", spy):
|
||||
result = workflow.invoke(Command(resume="ans"), config=config)
|
||||
|
||||
assert result == {"answer": "ans"}
|
||||
# Count unique non-null task IDs that got put_writes.
|
||||
# Should be exactly 2: the ask task and the entrypoint task.
|
||||
# If 3, the cached setup task is being redundantly re-committed.
|
||||
non_null = set(tid for tid in put_writes_task_ids if not tid.startswith("00000000"))
|
||||
assert len(non_null) == 2, (
|
||||
f"Expected 2 task IDs in put_writes (ask + entrypoint), got {len(non_null)}"
|
||||
)
|
||||
|
||||
|
||||
def test_node_before_interrupt_resume_graph_api(
|
||||
sync_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Test that Command(resume=value) works correctly in a StateGraph when a
|
||||
node runs before a node that calls interrupt(). This is the graph-API
|
||||
analog of test_task_before_interrupt_resume (entrypoint API)."""
|
||||
|
||||
class State(TypedDict):
|
||||
topics: list[str]
|
||||
answers: Annotated[list[str], operator.add]
|
||||
|
||||
def setup(state: State) -> dict:
|
||||
return {"topics": [f"topic {i + 1}" for i in range(len(state["topics"]))]}
|
||||
|
||||
def ask(state: State) -> dict:
|
||||
answers = []
|
||||
for topic in state["topics"]:
|
||||
answer = interrupt(f"Whats the answer for {topic}?")
|
||||
answers.append(answer)
|
||||
return {"answers": answers}
|
||||
|
||||
graph = (
|
||||
StateGraph(State)
|
||||
.add_node("setup", setup)
|
||||
.add_node("ask", ask)
|
||||
.add_edge(START, "setup")
|
||||
.add_edge("setup", "ask")
|
||||
.add_edge("ask", END)
|
||||
.compile(checkpointer=sync_checkpointer)
|
||||
)
|
||||
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
|
||||
# First invocation - setup runs, then ask interrupts on the first topic
|
||||
result = graph.invoke({"topics": ["a", "b"], "answers": []}, config=config)
|
||||
assert "__interrupt__" in result
|
||||
assert len(result["__interrupt__"]) == 1
|
||||
assert result["__interrupt__"][0].value == "Whats the answer for topic 1?"
|
||||
|
||||
# Resume with answer for topic 1 - should get second interrupt
|
||||
result = graph.invoke(Command(resume="answer1"), config=config)
|
||||
assert "__interrupt__" in result, f"Expected interrupt for topic 2, got: {result}"
|
||||
assert len(result["__interrupt__"]) == 1
|
||||
assert result["__interrupt__"][0].value == "Whats the answer for topic 2?"
|
||||
|
||||
# Resume with answer for topic 2 - should complete
|
||||
result = graph.invoke(Command(resume="answer2"), config=config)
|
||||
assert result == {
|
||||
"topics": ["topic 1", "topic 2"],
|
||||
"answers": ["answer1", "answer2"],
|
||||
}
|
||||
|
||||
|
||||
def test_multiple_nodes_before_interrupt_resume_graph_api(
|
||||
sync_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Test that Command(resume=value) works correctly in a StateGraph when
|
||||
multiple nodes run before a node that calls interrupt(). This is the
|
||||
graph-API analog of test_multiple_tasks_before_interrupt_resume."""
|
||||
|
||||
class State(TypedDict):
|
||||
value: int
|
||||
answer: str
|
||||
|
||||
def step_a(state: State) -> dict:
|
||||
return {"value": state["value"] + 1}
|
||||
|
||||
def step_b(state: State) -> dict:
|
||||
return {"value": state["value"] * 2}
|
||||
|
||||
def ask(state: State) -> dict:
|
||||
answer = interrupt(f"Result so far is {state['value']}. What next?")
|
||||
return {"answer": answer}
|
||||
|
||||
graph = (
|
||||
StateGraph(State)
|
||||
.add_node("step_a", step_a)
|
||||
.add_node("step_b", step_b)
|
||||
.add_node("ask", ask)
|
||||
.add_edge(START, "step_a")
|
||||
.add_edge("step_a", "step_b")
|
||||
.add_edge("step_b", "ask")
|
||||
.add_edge("ask", END)
|
||||
.compile(checkpointer=sync_checkpointer)
|
||||
)
|
||||
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
|
||||
# First invocation - step_a and step_b run, then ask interrupts
|
||||
result = graph.invoke({"value": 5, "answer": ""}, config=config)
|
||||
assert "__interrupt__" in result
|
||||
assert result["__interrupt__"][0].value == "Result so far is 12. What next?"
|
||||
|
||||
# Resume - should complete
|
||||
result = graph.invoke(Command(resume="continue"), config=config)
|
||||
assert result == {"value": 12, "answer": "continue"}
|
||||
|
||||
|
||||
def test_node_before_multiple_interrupt_cycles_graph_api(
|
||||
sync_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Test that a node running before an interrupt node does not interfere
|
||||
with multiple interrupt/resume cycles in a StateGraph."""
|
||||
|
||||
class State(TypedDict):
|
||||
count: int
|
||||
data: str
|
||||
|
||||
def prepare(state: State) -> dict:
|
||||
return {"count": state["count"] + 10}
|
||||
|
||||
def multi_interrupt(state: State) -> dict:
|
||||
first = interrupt("First question?")
|
||||
second = interrupt("Second question?")
|
||||
return {"data": f"{first},{second}"}
|
||||
|
||||
graph = (
|
||||
StateGraph(State)
|
||||
.add_node("prepare", prepare)
|
||||
.add_node("multi_interrupt", multi_interrupt)
|
||||
.add_edge(START, "prepare")
|
||||
.add_edge("prepare", "multi_interrupt")
|
||||
.add_edge("multi_interrupt", END)
|
||||
.compile(checkpointer=sync_checkpointer)
|
||||
)
|
||||
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
|
||||
# First invocation - prepare runs, multi_interrupt hits first interrupt
|
||||
result = graph.invoke({"count": 0, "data": ""}, config=config)
|
||||
assert "__interrupt__" in result
|
||||
assert result["__interrupt__"][0].value == "First question?"
|
||||
|
||||
# Resume first interrupt - hits second interrupt
|
||||
result = graph.invoke(Command(resume="first_answer"), config=config)
|
||||
assert "__interrupt__" in result
|
||||
assert result["__interrupt__"][0].value == "Second question?"
|
||||
|
||||
# Resume second interrupt - completes
|
||||
result = graph.invoke(Command(resume="second_answer"), config=config)
|
||||
assert result == {"count": 10, "data": "first_answer,second_answer"}
|
||||
|
||||
|
||||
def test_double_interrupt_subgraph(sync_checkpointer: BaseCheckpointSaver) -> None:
|
||||
class AgentState(TypedDict):
|
||||
input: str
|
||||
|
||||
@@ -7920,6 +7920,290 @@ async def test_interrupts_in_tasks_surfaced_once(
|
||||
assert result[1] == "Added Will!"
|
||||
|
||||
|
||||
@NEEDS_CONTEXTVARS
|
||||
async def test_task_before_interrupt_resume(
|
||||
async_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Test that Command(resume=value) works correctly when a @task runs
|
||||
before interrupt-producing tasks in an @entrypoint.
|
||||
|
||||
The @task wrapper on both setup and ask is essential to reproduce the bug:
|
||||
- @task on setup triggers a mid-step put_writes (creating a new pending_writes list)
|
||||
- @task on ask means interrupt() runs in a child scratchpad that must
|
||||
delegate to the parent for null resume consumption tracking
|
||||
"""
|
||||
|
||||
@entrypoint(checkpointer=async_checkpointer)
|
||||
async def workflow(number_of_topics: int) -> dict:
|
||||
@task
|
||||
async def setup() -> int:
|
||||
return number_of_topics
|
||||
|
||||
@task
|
||||
async def ask(question: str) -> str:
|
||||
return interrupt(question)
|
||||
|
||||
n = await setup()
|
||||
|
||||
answers = []
|
||||
for i in range(n):
|
||||
q = f"Whats the answer for topic {i + 1}?"
|
||||
answers.append(await ask(q))
|
||||
|
||||
return {"answers": answers}
|
||||
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
|
||||
# First invocation - should get first interrupt
|
||||
result = await workflow.ainvoke(2, config=config)
|
||||
assert "__interrupt__" in result
|
||||
assert len(result["__interrupt__"]) == 1
|
||||
assert result["__interrupt__"][0].value == "Whats the answer for topic 1?"
|
||||
|
||||
# Resume with answer for topic 1 - should get second interrupt
|
||||
result = await workflow.ainvoke(Command(resume="answer1"), config=config)
|
||||
assert "__interrupt__" in result, f"Expected interrupt for topic 2, got: {result}"
|
||||
assert len(result["__interrupt__"]) == 1
|
||||
assert result["__interrupt__"][0].value == "Whats the answer for topic 2?"
|
||||
|
||||
# Resume with answer for topic 2 - should get final result
|
||||
result = await workflow.ainvoke(Command(resume="answer2"), config=config)
|
||||
assert result == {"answers": ["answer1", "answer2"]}
|
||||
|
||||
|
||||
@NEEDS_CONTEXTVARS
|
||||
async def test_multiple_tasks_before_interrupt_resume(
|
||||
async_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Test that Command(resume=value) works correctly when multiple @tasks
|
||||
run before an interrupt-producing task in an @entrypoint."""
|
||||
|
||||
@entrypoint(checkpointer=async_checkpointer)
|
||||
async def workflow(inputs: dict) -> dict:
|
||||
@task
|
||||
async def step_a(x: int) -> int:
|
||||
return x + 1
|
||||
|
||||
@task
|
||||
async def step_b(x: int) -> int:
|
||||
return x * 2
|
||||
|
||||
@task
|
||||
async def ask(question: str) -> str:
|
||||
return interrupt(question)
|
||||
|
||||
a = await step_a(inputs["x"])
|
||||
b = await step_b(a)
|
||||
|
||||
answer = await ask(f"Result so far is {b}. What next?")
|
||||
|
||||
return {"computed": b, "answer": answer}
|
||||
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
|
||||
# First invocation - should get interrupt
|
||||
result = await workflow.ainvoke({"x": 5}, config=config)
|
||||
assert "__interrupt__" in result
|
||||
assert result["__interrupt__"][0].value == "Result so far is 12. What next?"
|
||||
|
||||
# Resume
|
||||
result = await workflow.ainvoke(Command(resume="continue"), config=config)
|
||||
assert result == {"computed": 12, "answer": "continue"}
|
||||
|
||||
|
||||
@NEEDS_CONTEXTVARS
|
||||
async def test_no_redundant_put_writes_for_cached_task(
|
||||
async_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Cached @tasks on resume must not trigger redundant put_writes."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from langgraph.pregel._loop import PregelLoop
|
||||
|
||||
@task
|
||||
async def setup(x: int) -> int:
|
||||
return x
|
||||
|
||||
@task
|
||||
async def ask(question: str) -> str:
|
||||
return interrupt(question)
|
||||
|
||||
@entrypoint(checkpointer=async_checkpointer)
|
||||
async def workflow(x: int) -> dict:
|
||||
n = await setup(x)
|
||||
answer = await ask(f"q{n}")
|
||||
return {"answer": answer}
|
||||
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
result = await workflow.ainvoke(1, config=config)
|
||||
assert "__interrupt__" in result
|
||||
|
||||
put_writes_task_ids: list[str] = []
|
||||
orig = PregelLoop.put_writes
|
||||
|
||||
def spy(self, task_id, writes):
|
||||
put_writes_task_ids.append(task_id)
|
||||
return orig(self, task_id, writes)
|
||||
|
||||
with patch.object(PregelLoop, "put_writes", spy):
|
||||
result = await workflow.ainvoke(Command(resume="ans"), config=config)
|
||||
|
||||
assert result == {"answer": "ans"}
|
||||
# Count unique non-null task IDs that got put_writes.
|
||||
# Should be exactly 2: the ask task and the entrypoint task.
|
||||
# If 3, the cached setup task is being redundantly re-committed.
|
||||
non_null = set(tid for tid in put_writes_task_ids if not tid.startswith("00000000"))
|
||||
assert len(non_null) == 2, (
|
||||
f"Expected 2 task IDs in put_writes (ask + entrypoint), got {len(non_null)}"
|
||||
)
|
||||
|
||||
|
||||
@NEEDS_CONTEXTVARS
|
||||
async def test_node_before_interrupt_resume_graph_api(
|
||||
async_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Test that Command(resume=value) works correctly in a StateGraph when a
|
||||
node runs before a node that calls interrupt(). This is the graph-API
|
||||
analog of test_task_before_interrupt_resume (entrypoint API)."""
|
||||
|
||||
class State(TypedDict):
|
||||
topics: list[str]
|
||||
answers: Annotated[list[str], operator.add]
|
||||
|
||||
def setup(state: State) -> dict:
|
||||
return {"topics": [f"topic {i + 1}" for i in range(len(state["topics"]))]}
|
||||
|
||||
def ask(state: State) -> dict:
|
||||
answers = []
|
||||
for topic in state["topics"]:
|
||||
answer = interrupt(f"Whats the answer for {topic}?")
|
||||
answers.append(answer)
|
||||
return {"answers": answers}
|
||||
|
||||
graph = (
|
||||
StateGraph(State)
|
||||
.add_node("setup", setup)
|
||||
.add_node("ask", ask)
|
||||
.add_edge(START, "setup")
|
||||
.add_edge("setup", "ask")
|
||||
.add_edge("ask", END)
|
||||
.compile(checkpointer=async_checkpointer)
|
||||
)
|
||||
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
|
||||
# First invocation - setup runs, then ask interrupts on the first topic
|
||||
result = await graph.ainvoke({"topics": ["a", "b"], "answers": []}, config=config)
|
||||
assert "__interrupt__" in result
|
||||
assert len(result["__interrupt__"]) == 1
|
||||
assert result["__interrupt__"][0].value == "Whats the answer for topic 1?"
|
||||
|
||||
# Resume with answer for topic 1 - should get second interrupt
|
||||
result = await graph.ainvoke(Command(resume="answer1"), config=config)
|
||||
assert "__interrupt__" in result, f"Expected interrupt for topic 2, got: {result}"
|
||||
assert len(result["__interrupt__"]) == 1
|
||||
assert result["__interrupt__"][0].value == "Whats the answer for topic 2?"
|
||||
|
||||
# Resume with answer for topic 2 - should complete
|
||||
result = await graph.ainvoke(Command(resume="answer2"), config=config)
|
||||
assert result == {
|
||||
"topics": ["topic 1", "topic 2"],
|
||||
"answers": ["answer1", "answer2"],
|
||||
}
|
||||
|
||||
|
||||
@NEEDS_CONTEXTVARS
|
||||
async def test_multiple_nodes_before_interrupt_resume_graph_api(
|
||||
async_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Test that Command(resume=value) works correctly in a StateGraph when
|
||||
multiple nodes run before a node that calls interrupt(). This is the
|
||||
graph-API analog of test_multiple_tasks_before_interrupt_resume."""
|
||||
|
||||
class State(TypedDict):
|
||||
value: int
|
||||
answer: str
|
||||
|
||||
def step_a(state: State) -> dict:
|
||||
return {"value": state["value"] + 1}
|
||||
|
||||
def step_b(state: State) -> dict:
|
||||
return {"value": state["value"] * 2}
|
||||
|
||||
def ask(state: State) -> dict:
|
||||
answer = interrupt(f"Result so far is {state['value']}. What next?")
|
||||
return {"answer": answer}
|
||||
|
||||
graph = (
|
||||
StateGraph(State)
|
||||
.add_node("step_a", step_a)
|
||||
.add_node("step_b", step_b)
|
||||
.add_node("ask", ask)
|
||||
.add_edge(START, "step_a")
|
||||
.add_edge("step_a", "step_b")
|
||||
.add_edge("step_b", "ask")
|
||||
.add_edge("ask", END)
|
||||
.compile(checkpointer=async_checkpointer)
|
||||
)
|
||||
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
|
||||
# First invocation - step_a and step_b run, then ask interrupts
|
||||
result = await graph.ainvoke({"value": 5, "answer": ""}, config=config)
|
||||
assert "__interrupt__" in result
|
||||
assert result["__interrupt__"][0].value == "Result so far is 12. What next?"
|
||||
|
||||
# Resume - should complete
|
||||
result = await graph.ainvoke(Command(resume="continue"), config=config)
|
||||
assert result == {"value": 12, "answer": "continue"}
|
||||
|
||||
|
||||
@NEEDS_CONTEXTVARS
|
||||
async def test_node_before_multiple_interrupt_cycles_graph_api(
|
||||
async_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Test that a node running before an interrupt node does not interfere
|
||||
with multiple interrupt/resume cycles in a StateGraph."""
|
||||
|
||||
class State(TypedDict):
|
||||
count: int
|
||||
data: str
|
||||
|
||||
def prepare(state: State) -> dict:
|
||||
return {"count": state["count"] + 10}
|
||||
|
||||
def multi_interrupt(state: State) -> dict:
|
||||
first = interrupt("First question?")
|
||||
second = interrupt("Second question?")
|
||||
return {"data": f"{first},{second}"}
|
||||
|
||||
graph = (
|
||||
StateGraph(State)
|
||||
.add_node("prepare", prepare)
|
||||
.add_node("multi_interrupt", multi_interrupt)
|
||||
.add_edge(START, "prepare")
|
||||
.add_edge("prepare", "multi_interrupt")
|
||||
.add_edge("multi_interrupt", END)
|
||||
.compile(checkpointer=async_checkpointer)
|
||||
)
|
||||
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
|
||||
# First invocation - prepare runs, multi_interrupt hits first interrupt
|
||||
result = await graph.ainvoke({"count": 0, "data": ""}, config=config)
|
||||
assert "__interrupt__" in result
|
||||
assert result["__interrupt__"][0].value == "First question?"
|
||||
|
||||
# Resume first interrupt - hits second interrupt
|
||||
result = await graph.ainvoke(Command(resume="first_answer"), config=config)
|
||||
assert "__interrupt__" in result
|
||||
assert result["__interrupt__"][0].value == "Second question?"
|
||||
|
||||
# Resume second interrupt - completes
|
||||
result = await graph.ainvoke(Command(resume="second_answer"), config=config)
|
||||
assert result == {"count": 10, "data": "first_answer,second_answer"}
|
||||
|
||||
|
||||
async def test_pregel_loop_refcount():
|
||||
gc.collect()
|
||||
try:
|
||||
|
||||
@@ -8,6 +8,7 @@ import uuid
|
||||
from enum import Enum
|
||||
from typing import Annotated, Literal, Optional
|
||||
|
||||
from langgraph.checkpoint.base import BaseCheckpointSaver
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
ByteSize,
|
||||
@@ -23,7 +24,10 @@ from pydantic import (
|
||||
|
||||
from langgraph._internal._pydantic import is_supported_by_pydantic
|
||||
from langgraph.constants import END, START
|
||||
from langgraph.func import entrypoint, task
|
||||
from langgraph.graph.state import StateGraph
|
||||
from langgraph.types import Command, Interrupt, interrupt
|
||||
from tests.any_str import AnyStr
|
||||
|
||||
|
||||
def test_is_supported_by_pydantic() -> None:
|
||||
@@ -312,3 +316,47 @@ def test_pydantic_state_field_validator():
|
||||
g = builder.compile()
|
||||
res = g.invoke(input_state)
|
||||
assert res["text"] == "Hello, Validated John!"
|
||||
|
||||
|
||||
class FunctionalState(BaseModel):
|
||||
a: str
|
||||
b: str | None = None
|
||||
|
||||
|
||||
def test_interrupt_functional_pydantic(sync_checkpointer: BaseCheckpointSaver) -> None:
|
||||
called_count = 0
|
||||
|
||||
@task
|
||||
def foo(state: FunctionalState) -> FunctionalState:
|
||||
nonlocal called_count
|
||||
called_count += 1
|
||||
return FunctionalState(**{"a": state.a + "foo"})
|
||||
|
||||
@task
|
||||
def bar(state: FunctionalState) -> dict:
|
||||
return {"a": state.a + "bar", "b": state.b}
|
||||
|
||||
@entrypoint(checkpointer=sync_checkpointer)
|
||||
def graph(inputs: FunctionalState) -> FunctionalState:
|
||||
fut_foo = foo(inputs)
|
||||
value = interrupt("Provide value for bar:")
|
||||
foo_res = fut_foo.result()
|
||||
assert isinstance(foo_res, FunctionalState)
|
||||
bar_input = FunctionalState(a=foo_res.a, b=value)
|
||||
fut_bar = bar(bar_input)
|
||||
return fut_bar.result()
|
||||
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
# First run, interrupted at bar
|
||||
assert graph.invoke(FunctionalState(a=""), config) == {
|
||||
"__interrupt__": [
|
||||
Interrupt(
|
||||
value="Provide value for bar:",
|
||||
id=AnyStr(),
|
||||
)
|
||||
]
|
||||
}
|
||||
# Resume with an answer
|
||||
res = graph.invoke(Command(resume="bar"), config)
|
||||
assert res == {"a": "foobar", "b": "bar"}
|
||||
assert called_count == 1
|
||||
|
||||
@@ -4,7 +4,7 @@ import pytest
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from langgraph.graph import START, StateGraph
|
||||
from langgraph.pregel._retry import _should_retry_on
|
||||
from langgraph.pregel._retry import _checkpoint_ns_for_parent_command, _should_retry_on
|
||||
from langgraph.types import RetryPolicy
|
||||
|
||||
|
||||
@@ -78,6 +78,22 @@ def test_should_retry_on_empty_sequence():
|
||||
assert _should_retry_on(policy, ValueError("test error")) is False
|
||||
|
||||
|
||||
def test_checkpoint_ns_for_parent_command() -> None:
|
||||
assert _checkpoint_ns_for_parent_command("") == ""
|
||||
assert _checkpoint_ns_for_parent_command("node:1") == ""
|
||||
assert _checkpoint_ns_for_parent_command("node:1|child:2") == "node:1"
|
||||
assert _checkpoint_ns_for_parent_command("node:1|1|child:2") == "node:1"
|
||||
assert _checkpoint_ns_for_parent_command("node:1|1|child:2|1") == "node:1"
|
||||
assert (
|
||||
_checkpoint_ns_for_parent_command("parent:1|1|child:1|1|node:1|1")
|
||||
== "parent:1|1|child:1"
|
||||
)
|
||||
assert (
|
||||
_checkpoint_ns_for_parent_command("parent:1|1|child:1|1|node:1")
|
||||
== "parent:1|1|child:1"
|
||||
)
|
||||
|
||||
|
||||
def test_should_retry_default_retry_on():
|
||||
"""Test the default retry_on function."""
|
||||
import httpx
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, Annotated, Any, Literal, NewType, Optional, Union
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
from typing_extensions import NotRequired, Required, TypedDict
|
||||
|
||||
from langgraph._internal._serde import (
|
||||
collect_allowlist_from_schemas,
|
||||
curated_core_allowlist,
|
||||
)
|
||||
|
||||
|
||||
class Color(Enum):
|
||||
RED = "red"
|
||||
BLUE = "blue"
|
||||
|
||||
|
||||
@dataclass
|
||||
class InnerDataclass:
|
||||
value: int
|
||||
|
||||
|
||||
class InnerModel(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class Node:
|
||||
value: int
|
||||
child: Node | None = None
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
class MissingType:
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class MissingRefDataclass:
|
||||
payload: MissingType
|
||||
|
||||
|
||||
class Payload(TypedDict):
|
||||
item: InnerDataclass
|
||||
maybe: NotRequired[InnerModel]
|
||||
required: Required[str]
|
||||
|
||||
|
||||
@dataclass
|
||||
class NestedDataclass:
|
||||
inner: InnerDataclass
|
||||
items: list[InnerModel]
|
||||
mapping: dict[str, InnerDataclass]
|
||||
optional: InnerModel | None
|
||||
union: InnerDataclass | InnerModel
|
||||
queue: deque[InnerDataclass]
|
||||
frozen: frozenset[InnerModel]
|
||||
|
||||
|
||||
AnnotatedList = Annotated[list[InnerDataclass], "meta"]
|
||||
UserId = NewType("UserId", int)
|
||||
|
||||
|
||||
class DummyChannel:
|
||||
@property
|
||||
def ValueType(self) -> type[InnerDataclass]:
|
||||
return InnerDataclass
|
||||
|
||||
@property
|
||||
def UpdateType(self) -> type[InnerModel]:
|
||||
return InnerModel
|
||||
|
||||
|
||||
def test_curated_core_allowlist_includes_messages() -> None:
|
||||
try:
|
||||
from langchain_core.messages import BaseMessage
|
||||
except Exception:
|
||||
pytest.skip("langchain_core not available")
|
||||
allowlist = curated_core_allowlist()
|
||||
assert (BaseMessage.__module__, BaseMessage.__name__) in allowlist
|
||||
|
||||
|
||||
def test_collect_allowlist_basic_models() -> None:
|
||||
allowlist = collect_allowlist_from_schemas(
|
||||
schemas=[InnerDataclass, InnerModel, Color]
|
||||
)
|
||||
assert (InnerDataclass.__module__, InnerDataclass.__name__) in allowlist
|
||||
assert (InnerModel.__module__, InnerModel.__name__) in allowlist
|
||||
assert (Color.__module__, Color.__name__) in allowlist
|
||||
|
||||
|
||||
def test_collect_allowlist_nested_containers() -> None:
|
||||
allowlist = collect_allowlist_from_schemas(schemas=[NestedDataclass])
|
||||
assert (NestedDataclass.__module__, NestedDataclass.__name__) in allowlist
|
||||
assert (InnerDataclass.__module__, InnerDataclass.__name__) in allowlist
|
||||
assert (InnerModel.__module__, InnerModel.__name__) in allowlist
|
||||
|
||||
|
||||
def test_collect_allowlist_annotated_and_union() -> None:
|
||||
allowlist = collect_allowlist_from_schemas(
|
||||
schemas=[AnnotatedList, InnerModel | None, InnerDataclass | None]
|
||||
)
|
||||
assert (InnerDataclass.__module__, InnerDataclass.__name__) in allowlist
|
||||
assert (InnerModel.__module__, InnerModel.__name__) in allowlist
|
||||
|
||||
|
||||
def test_collect_allowlist_literal_and_any() -> None:
|
||||
allowlist = collect_allowlist_from_schemas(schemas=[Any, Literal["a"]])
|
||||
assert allowlist == set()
|
||||
|
||||
|
||||
def test_collect_allowlist_typeddict_fields_only() -> None:
|
||||
allowlist = collect_allowlist_from_schemas(schemas=[Payload])
|
||||
assert (InnerDataclass.__module__, InnerDataclass.__name__) in allowlist
|
||||
assert (InnerModel.__module__, InnerModel.__name__) in allowlist
|
||||
assert (Payload.__module__, Payload.__name__) not in allowlist
|
||||
|
||||
|
||||
def test_collect_allowlist_forward_refs() -> None:
|
||||
allowlist = collect_allowlist_from_schemas(schemas=[Node])
|
||||
assert (Node.__module__, Node.__name__) in allowlist
|
||||
|
||||
|
||||
def test_collect_allowlist_missing_forward_ref() -> None:
|
||||
allowlist = collect_allowlist_from_schemas(schemas=[MissingRefDataclass])
|
||||
assert allowlist == {(MissingRefDataclass.__module__, MissingRefDataclass.__name__)}
|
||||
|
||||
|
||||
def test_collect_allowlist_newtype_supertype() -> None:
|
||||
allowlist = collect_allowlist_from_schemas(schemas=[UserId])
|
||||
assert allowlist == set()
|
||||
|
||||
|
||||
def test_collect_allowlist_channels() -> None:
|
||||
channels = {"a": DummyChannel(), "b": DummyChannel()}
|
||||
allowlist = collect_allowlist_from_schemas(channels=channels)
|
||||
assert (InnerDataclass.__module__, InnerDataclass.__name__) in allowlist
|
||||
assert (InnerModel.__module__, InnerModel.__name__) in allowlist
|
||||
|
||||
|
||||
def test_collect_allowlist_pep604_union() -> None:
|
||||
schema = InnerDataclass | InnerModel
|
||||
allowlist = collect_allowlist_from_schemas(schemas=[schema])
|
||||
assert (InnerDataclass.__module__, InnerDataclass.__name__) in allowlist
|
||||
assert (InnerModel.__module__, InnerModel.__name__) in allowlist
|
||||
|
||||
|
||||
def test_collect_allowlist_typing_union_optional() -> None:
|
||||
typing_optional = Optional[InnerDataclass] # noqa: UP045
|
||||
typing_union = Union[InnerDataclass, InnerModel] # noqa: UP007
|
||||
allowlist = collect_allowlist_from_schemas(schemas=[typing_optional, typing_union])
|
||||
assert (InnerDataclass.__module__, InnerDataclass.__name__) in allowlist
|
||||
assert (InnerModel.__module__, InnerModel.__name__) in allowlist
|
||||
@@ -0,0 +1,641 @@
|
||||
"""Tests for subgraph persistence behavior (sync).
|
||||
|
||||
Covers three checkpointer settings for subgraph state:
|
||||
- checkpointer=False: no persistence, even when parent has a checkpointer
|
||||
- checkpointer=None (default): "stateless" — inherits parent checkpointer for
|
||||
interrupt support, but state resets each invocation. This is the common case
|
||||
when an agent is invoked from inside a tool used by another agent.
|
||||
- checkpointer=True: "stateful" — state accumulates across invocations on the same thread id
|
||||
"""
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
from langgraph.checkpoint.base import BaseCheckpointSaver
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from langgraph.graph import START, StateGraph
|
||||
from langgraph.graph.message import MessagesState
|
||||
from langgraph.types import Command, Interrupt, interrupt
|
||||
from tests.any_str import AnyStr
|
||||
|
||||
|
||||
class ParentState(TypedDict):
|
||||
result: str
|
||||
|
||||
|
||||
# -- checkpointer=None (stateless) --
|
||||
|
||||
|
||||
def test_stateless_interrupt_resume(
|
||||
sync_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Tests that a subgraph compiled with checkpointer=None (the default) can
|
||||
still support interrupt/resume when invoked from inside a parent graph that
|
||||
has a checkpointer. This is the "stateless" pattern — the subgraph inherits
|
||||
the parent's checkpointer just enough to pause and resume, but does not
|
||||
retain any state across separate parent invocations. This pattern commonly
|
||||
appears when an agent is invoked from inside a tool used by another agent.
|
||||
"""
|
||||
|
||||
# Build a subgraph that interrupts before echoing.
|
||||
# Two nodes: "process" interrupts then echoes, "respond" returns "Done".
|
||||
def process(state: MessagesState) -> dict:
|
||||
interrupt("continue?")
|
||||
return {
|
||||
"messages": [AIMessage(content=f"Processing: {state['messages'][-1].text}")]
|
||||
}
|
||||
|
||||
def respond(state: MessagesState) -> dict:
|
||||
return {"messages": [AIMessage(content="Done")]}
|
||||
|
||||
inner = (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("process", process)
|
||||
.add_node("respond", respond)
|
||||
.add_edge(START, "process")
|
||||
.add_edge("process", "respond")
|
||||
.compile()
|
||||
)
|
||||
|
||||
def call_inner(state: ParentState) -> dict:
|
||||
resp = inner.invoke({"messages": [HumanMessage(content="apples")]})
|
||||
return {"result": resp["messages"][-1].text}
|
||||
|
||||
parent = (
|
||||
StateGraph(ParentState)
|
||||
.add_node("call_inner", call_inner)
|
||||
.add_edge(START, "call_inner")
|
||||
.compile(checkpointer=sync_checkpointer)
|
||||
)
|
||||
config = {"configurable": {"thread_id": str(uuid4())}}
|
||||
|
||||
# First invoke hits the interrupt
|
||||
result = parent.invoke({"result": ""}, config)
|
||||
assert result == {
|
||||
"result": "",
|
||||
"__interrupt__": [Interrupt(value="continue?", id=AnyStr())],
|
||||
}
|
||||
|
||||
# Resume completes the subgraph
|
||||
result = parent.invoke(Command(resume=True), config)
|
||||
assert result == {"result": "Done"}
|
||||
|
||||
|
||||
def test_stateless_state_resets(
|
||||
sync_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Tests that a subgraph compiled with checkpointer=None (the default) does
|
||||
not retain any message history between separate parent invocations. Each time
|
||||
the parent graph invokes the subgraph, it starts with a clean slate. This
|
||||
confirms the "stateless" behavior: even though the parent has a checkpointer,
|
||||
the subgraph state is not persisted across calls.
|
||||
"""
|
||||
|
||||
# Build a simple echo subgraph: echoes "Processing: <input>"
|
||||
def echo(state: MessagesState) -> dict:
|
||||
return {
|
||||
"messages": [AIMessage(content=f"Processing: {state['messages'][-1].text}")]
|
||||
}
|
||||
|
||||
inner = (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("echo", echo)
|
||||
.add_edge(START, "echo")
|
||||
.compile()
|
||||
)
|
||||
|
||||
subgraph_messages: list[list[str]] = []
|
||||
call_count = 0
|
||||
|
||||
def call_inner(state: ParentState) -> dict:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
topic = "apples" if call_count == 1 else "bananas"
|
||||
resp = inner.invoke(
|
||||
{"messages": [HumanMessage(content=f"tell me about {topic}")]}
|
||||
)
|
||||
subgraph_messages.append([m.text for m in resp["messages"]])
|
||||
return {"result": resp["messages"][-1].text}
|
||||
|
||||
parent = (
|
||||
StateGraph(ParentState)
|
||||
.add_node("call_inner", call_inner)
|
||||
.add_edge(START, "call_inner")
|
||||
.compile(checkpointer=sync_checkpointer)
|
||||
)
|
||||
config = {"configurable": {"thread_id": str(uuid4())}}
|
||||
|
||||
result1 = parent.invoke({"result": ""}, config)
|
||||
assert result1 == {"result": "Processing: tell me about apples"}
|
||||
|
||||
result2 = parent.invoke({"result": ""}, config)
|
||||
assert result2 == {"result": "Processing: tell me about bananas"}
|
||||
|
||||
# Both invocations produce fresh history — no memory of prior call
|
||||
assert subgraph_messages[0] == [
|
||||
"tell me about apples",
|
||||
"Processing: tell me about apples",
|
||||
]
|
||||
assert subgraph_messages[1] == [
|
||||
"tell me about bananas",
|
||||
"Processing: tell me about bananas",
|
||||
]
|
||||
|
||||
|
||||
def test_stateless_state_resets_with_interrupt(
|
||||
sync_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Tests that a subgraph compiled with checkpointer=None resets its state
|
||||
between parent invocations even when interrupt/resume is used. The subgraph
|
||||
is invoked twice from the parent, each time with an interrupt that must be
|
||||
resumed. After both invoke+resume cycles, each subgraph run should only
|
||||
contain its own messages — no bleed-over from the previous run.
|
||||
"""
|
||||
|
||||
# Build a subgraph that interrupts before echoing, then responds "Done"
|
||||
def process(state: MessagesState) -> dict:
|
||||
interrupt("continue?")
|
||||
return {
|
||||
"messages": [AIMessage(content=f"Processing: {state['messages'][-1].text}")]
|
||||
}
|
||||
|
||||
def respond(state: MessagesState) -> dict:
|
||||
return {"messages": [AIMessage(content="Done")]}
|
||||
|
||||
inner = (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("process", process)
|
||||
.add_node("respond", respond)
|
||||
.add_edge(START, "process")
|
||||
.add_edge("process", "respond")
|
||||
.compile()
|
||||
)
|
||||
|
||||
subgraph_messages: list[list[str]] = []
|
||||
call_count = 0
|
||||
|
||||
def call_inner(state: ParentState) -> dict:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
topic = "apples" if call_count == 1 else "bananas"
|
||||
resp = inner.invoke(
|
||||
{"messages": [HumanMessage(content=f"tell me about {topic}")]}
|
||||
)
|
||||
subgraph_messages.append([m.text for m in resp["messages"]])
|
||||
return {"result": resp["messages"][-1].text}
|
||||
|
||||
parent = (
|
||||
StateGraph(ParentState)
|
||||
.add_node("call_inner", call_inner)
|
||||
.add_edge(START, "call_inner")
|
||||
.compile(checkpointer=sync_checkpointer)
|
||||
)
|
||||
config = {"configurable": {"thread_id": str(uuid4())}}
|
||||
|
||||
# First invoke+resume cycle
|
||||
result = parent.invoke({"result": ""}, config)
|
||||
assert result == {
|
||||
"result": "",
|
||||
"__interrupt__": [Interrupt(value="continue?", id=AnyStr())],
|
||||
}
|
||||
result = parent.invoke(Command(resume=True), config)
|
||||
assert result == {"result": "Done"}
|
||||
|
||||
# Second invoke+resume cycle
|
||||
result = parent.invoke({"result": ""}, config)
|
||||
assert result == {
|
||||
"result": "",
|
||||
"__interrupt__": [Interrupt(value="continue?", id=AnyStr())],
|
||||
}
|
||||
result = parent.invoke(Command(resume=True), config)
|
||||
assert result == {"result": "Done"}
|
||||
|
||||
# Both invocations produce fresh history — no memory of prior call
|
||||
assert subgraph_messages[0] == [
|
||||
"tell me about apples",
|
||||
"Processing: tell me about apples",
|
||||
"Done",
|
||||
]
|
||||
assert subgraph_messages[1] == [
|
||||
"tell me about bananas",
|
||||
"Processing: tell me about bananas",
|
||||
"Done",
|
||||
]
|
||||
|
||||
|
||||
# -- checkpointer=False --
|
||||
|
||||
|
||||
def test_checkpointer_false_no_persistence(
|
||||
sync_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Tests that a subgraph compiled with checkpointer=False gets no
|
||||
persistence at all, even when the parent graph has a checkpointer. Unlike
|
||||
the default (checkpointer=None) which inherits just enough from the parent
|
||||
to support interrupt/resume, checkpointer=False explicitly opts out of all
|
||||
checkpoint behavior. Each invocation starts completely fresh.
|
||||
"""
|
||||
|
||||
# Build a simple echo subgraph with checkpointer=False
|
||||
def echo(state: MessagesState) -> dict:
|
||||
return {
|
||||
"messages": [AIMessage(content=f"Processed: {state['messages'][-1].text}")]
|
||||
}
|
||||
|
||||
inner = (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("echo", echo)
|
||||
.add_edge(START, "echo")
|
||||
.compile(checkpointer=False)
|
||||
)
|
||||
|
||||
subgraph_messages: list[list[str]] = []
|
||||
call_count = 0
|
||||
|
||||
def call_inner(state: ParentState) -> dict:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
topic = "apples" if call_count == 1 else "bananas"
|
||||
resp = inner.invoke(
|
||||
{"messages": [HumanMessage(content=f"tell me about {topic}")]}
|
||||
)
|
||||
subgraph_messages.append([m.text for m in resp["messages"]])
|
||||
return {"result": resp["messages"][-1].text}
|
||||
|
||||
parent = (
|
||||
StateGraph(ParentState)
|
||||
.add_node("call_inner", call_inner)
|
||||
.add_edge(START, "call_inner")
|
||||
.compile(checkpointer=sync_checkpointer)
|
||||
)
|
||||
config = {"configurable": {"thread_id": str(uuid4())}}
|
||||
|
||||
result1 = parent.invoke({"result": ""}, config)
|
||||
assert result1 == {"result": "Processed: tell me about apples"}
|
||||
|
||||
result2 = parent.invoke({"result": ""}, config)
|
||||
assert result2 == {"result": "Processed: tell me about bananas"}
|
||||
|
||||
# Both start fresh — no history from first call
|
||||
assert subgraph_messages[0] == [
|
||||
"tell me about apples",
|
||||
"Processed: tell me about apples",
|
||||
]
|
||||
assert subgraph_messages[1] == [
|
||||
"tell me about bananas",
|
||||
"Processed: tell me about bananas",
|
||||
]
|
||||
|
||||
|
||||
# -- checkpointer=True (stateful) --
|
||||
|
||||
|
||||
def test_stateful_state_accumulates(
|
||||
sync_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Tests that a subgraph compiled with checkpointer=True ("stateful")
|
||||
retains its message history across separate parent invocations. To enable
|
||||
this, the subgraph is wrapped in an outer graph compiled with
|
||||
checkpointer=True — this wrapper gives the inner subgraph its own persistent
|
||||
checkpoint namespace. After two parent calls, the second subgraph invocation
|
||||
should see messages from both the first and second calls.
|
||||
"""
|
||||
|
||||
# Build a simple echo subgraph
|
||||
def echo(state: MessagesState) -> dict:
|
||||
return {
|
||||
"messages": [AIMessage(content=f"Processing: {state['messages'][-1].text}")]
|
||||
}
|
||||
|
||||
inner = (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("echo", echo)
|
||||
.add_edge(START, "echo")
|
||||
.compile()
|
||||
)
|
||||
|
||||
# Wrap the inner subgraph with checkpointer=True to enable stateful.
|
||||
# The wrapper graph gives the subgraph its own persistent checkpoint
|
||||
# namespace, keyed by the node name ("agent").
|
||||
wrapper = (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("agent", inner)
|
||||
.add_edge(START, "agent")
|
||||
.compile(checkpointer=True)
|
||||
)
|
||||
|
||||
subgraph_messages: list[list[str]] = []
|
||||
topics = ["apples", "bananas"]
|
||||
|
||||
def call_inner(state: ParentState) -> dict:
|
||||
topic = topics[len(subgraph_messages)]
|
||||
resp = wrapper.invoke(
|
||||
{"messages": [HumanMessage(content=f"tell me about {topic}")]}
|
||||
)
|
||||
subgraph_messages.append([m.text for m in resp["messages"]])
|
||||
return {"result": resp["messages"][-1].text}
|
||||
|
||||
parent = (
|
||||
StateGraph(ParentState)
|
||||
.add_node("call_inner", call_inner)
|
||||
.add_edge(START, "call_inner")
|
||||
.compile(checkpointer=sync_checkpointer)
|
||||
)
|
||||
config = {"configurable": {"thread_id": str(uuid4())}}
|
||||
|
||||
result1 = parent.invoke({"result": ""}, config)
|
||||
assert result1 == {"result": "Processing: tell me about apples"}
|
||||
|
||||
result2 = parent.invoke({"result": ""}, config)
|
||||
assert result2 == {"result": "Processing: tell me about bananas"}
|
||||
|
||||
# First call: fresh history
|
||||
assert subgraph_messages[0] == [
|
||||
"tell me about apples",
|
||||
"Processing: tell me about apples",
|
||||
]
|
||||
# Second call: retains messages from first call
|
||||
assert subgraph_messages[1] == [
|
||||
"tell me about apples",
|
||||
"Processing: tell me about apples",
|
||||
"tell me about bananas",
|
||||
"Processing: tell me about bananas",
|
||||
]
|
||||
|
||||
|
||||
def test_stateful_state_accumulates_with_interrupt(
|
||||
sync_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Tests that a stateful subgraph (checkpointer=True) retains its
|
||||
message history across parent invocations even when interrupt/resume is
|
||||
involved. The subgraph interrupts before echoing, then responds "Done".
|
||||
After two invoke+resume cycles, the second run should contain the full
|
||||
accumulated history from both calls.
|
||||
"""
|
||||
|
||||
# Build a subgraph that interrupts before echoing, then responds "Done"
|
||||
def process(state: MessagesState) -> dict:
|
||||
interrupt("continue?")
|
||||
return {
|
||||
"messages": [AIMessage(content=f"Processing: {state['messages'][-1].text}")]
|
||||
}
|
||||
|
||||
def respond(state: MessagesState) -> dict:
|
||||
return {"messages": [AIMessage(content="Done")]}
|
||||
|
||||
inner = (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("process", process)
|
||||
.add_node("respond", respond)
|
||||
.add_edge(START, "process")
|
||||
.add_edge("process", "respond")
|
||||
.compile()
|
||||
)
|
||||
|
||||
# Wrap with checkpointer=True for stateful
|
||||
wrapper = (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("agent", inner)
|
||||
.add_edge(START, "agent")
|
||||
.compile(checkpointer=True)
|
||||
)
|
||||
|
||||
subgraph_messages: list[list[str]] = []
|
||||
topics = ["apples", "bananas"]
|
||||
|
||||
def call_inner(state: ParentState) -> dict:
|
||||
topic = topics[len(subgraph_messages)]
|
||||
resp = wrapper.invoke(
|
||||
{"messages": [HumanMessage(content=f"tell me about {topic}")]}
|
||||
)
|
||||
subgraph_messages.append([m.text for m in resp["messages"]])
|
||||
return {"result": resp["messages"][-1].text}
|
||||
|
||||
parent = (
|
||||
StateGraph(ParentState)
|
||||
.add_node("call_inner", call_inner)
|
||||
.add_edge(START, "call_inner")
|
||||
.compile(checkpointer=sync_checkpointer)
|
||||
)
|
||||
config = {"configurable": {"thread_id": str(uuid4())}}
|
||||
|
||||
# First invoke+resume cycle
|
||||
result = parent.invoke({"result": ""}, config)
|
||||
assert result == {
|
||||
"result": "",
|
||||
"__interrupt__": [Interrupt(value="continue?", id=AnyStr())],
|
||||
}
|
||||
result = parent.invoke(Command(resume=True), config)
|
||||
assert result == {"result": "Done"}
|
||||
|
||||
# Second invoke+resume cycle
|
||||
result = parent.invoke({"result": ""}, config)
|
||||
assert result == {
|
||||
"result": "",
|
||||
"__interrupt__": [Interrupt(value="continue?", id=AnyStr())],
|
||||
}
|
||||
result = parent.invoke(Command(resume=True), config)
|
||||
assert result == {"result": "Done"}
|
||||
|
||||
# First call: fresh history
|
||||
assert subgraph_messages[0] == [
|
||||
"tell me about apples",
|
||||
"Processing: tell me about apples",
|
||||
"Done",
|
||||
]
|
||||
# Second call: retains messages from first call
|
||||
assert subgraph_messages[1] == [
|
||||
"tell me about apples",
|
||||
"Processing: tell me about apples",
|
||||
"Done",
|
||||
"tell me about bananas",
|
||||
"Processing: tell me about bananas",
|
||||
"Done",
|
||||
]
|
||||
|
||||
|
||||
def test_stateful_interrupt_resume(
|
||||
sync_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Tests that a stateful subgraph (checkpointer=True) correctly
|
||||
supports interrupt/resume while also accumulating state. Each invoke+resume
|
||||
pair triggers the subgraph, and after the second pair completes we verify
|
||||
both the per-step invoke outputs and the accumulated message history. This
|
||||
exercises the full lifecycle: interrupt, resume, state accumulation.
|
||||
"""
|
||||
|
||||
# Build a subgraph that interrupts before echoing, then responds "Done"
|
||||
def process(state: MessagesState) -> dict:
|
||||
interrupt("continue?")
|
||||
return {
|
||||
"messages": [AIMessage(content=f"Processing: {state['messages'][-1].text}")]
|
||||
}
|
||||
|
||||
def respond(state: MessagesState) -> dict:
|
||||
return {"messages": [AIMessage(content="Done")]}
|
||||
|
||||
inner = (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("process", process)
|
||||
.add_node("respond", respond)
|
||||
.add_edge(START, "process")
|
||||
.add_edge("process", "respond")
|
||||
.compile()
|
||||
)
|
||||
|
||||
# Wrap with checkpointer=True for stateful
|
||||
wrapper = (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("agent", inner)
|
||||
.add_edge(START, "agent")
|
||||
.compile(checkpointer=True)
|
||||
)
|
||||
|
||||
subgraph_messages: list[list[str]] = []
|
||||
topics = ["apples", "bananas"]
|
||||
|
||||
def call_inner(state: ParentState) -> dict:
|
||||
topic = topics[len(subgraph_messages)]
|
||||
resp = wrapper.invoke(
|
||||
{"messages": [HumanMessage(content=f"tell me about {topic}")]}
|
||||
)
|
||||
subgraph_messages.append([m.text for m in resp["messages"]])
|
||||
return {"result": resp["messages"][-1].text}
|
||||
|
||||
parent = (
|
||||
StateGraph(ParentState)
|
||||
.add_node("call_inner", call_inner)
|
||||
.add_edge(START, "call_inner")
|
||||
.compile(checkpointer=sync_checkpointer)
|
||||
)
|
||||
config = {"configurable": {"thread_id": str(uuid4())}}
|
||||
|
||||
# First invocation: hits interrupt
|
||||
result = parent.invoke({"result": ""}, config)
|
||||
assert result == {
|
||||
"result": "",
|
||||
"__interrupt__": [Interrupt(value="continue?", id=AnyStr())],
|
||||
}
|
||||
|
||||
# Resume: completes first call
|
||||
result = parent.invoke(Command(resume=True), config)
|
||||
assert result == {"result": "Done"}
|
||||
assert subgraph_messages[0] == [
|
||||
"tell me about apples",
|
||||
"Processing: tell me about apples",
|
||||
"Done",
|
||||
]
|
||||
|
||||
# Second invocation: hits interrupt, state accumulated from first call
|
||||
result = parent.invoke({"result": ""}, config)
|
||||
assert result == {
|
||||
"result": "",
|
||||
"__interrupt__": [Interrupt(value="continue?", id=AnyStr())],
|
||||
}
|
||||
|
||||
# Resume: completes second call with accumulated state
|
||||
result = parent.invoke(Command(resume=True), config)
|
||||
assert result == {"result": "Done"}
|
||||
assert subgraph_messages[1] == [
|
||||
"tell me about apples",
|
||||
"Processing: tell me about apples",
|
||||
"Done",
|
||||
"tell me about bananas",
|
||||
"Processing: tell me about bananas",
|
||||
"Done",
|
||||
]
|
||||
|
||||
|
||||
def test_stateful_namespace_isolation(
|
||||
sync_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Tests that two different stateful subgraphs (checkpointer=True)
|
||||
maintain completely independent state when they use different wrapper node
|
||||
names. A "fruit_agent" and "veggie_agent" are each wrapped in their own
|
||||
stateful graph. After two parent invocations, each agent should only
|
||||
see its own accumulated history with no cross-contamination between them.
|
||||
"""
|
||||
|
||||
# Build two simple echo subgraphs with different prefixes
|
||||
def fruit_echo(state: MessagesState) -> dict:
|
||||
return {"messages": [AIMessage(content=f"Fruit: {state['messages'][-1].text}")]}
|
||||
|
||||
def veggie_echo(state: MessagesState) -> dict:
|
||||
return {
|
||||
"messages": [AIMessage(content=f"Veggie: {state['messages'][-1].text}")]
|
||||
}
|
||||
|
||||
fruit_inner = (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("echo", fruit_echo)
|
||||
.add_edge(START, "echo")
|
||||
.compile()
|
||||
)
|
||||
veggie_inner = (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("echo", veggie_echo)
|
||||
.add_edge(START, "echo")
|
||||
.compile()
|
||||
)
|
||||
|
||||
# Wrap each with checkpointer=True, using different node names to get
|
||||
# independent checkpoint namespaces
|
||||
fruit = (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("fruit_agent", fruit_inner)
|
||||
.add_edge(START, "fruit_agent")
|
||||
.compile(checkpointer=True)
|
||||
)
|
||||
veggie = (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("veggie_agent", veggie_inner)
|
||||
.add_edge(START, "veggie_agent")
|
||||
.compile(checkpointer=True)
|
||||
)
|
||||
|
||||
fruit_msgs: list[list[str]] = []
|
||||
veggie_msgs: list[list[str]] = []
|
||||
call_count = 0
|
||||
|
||||
def call_both(state: ParentState) -> dict:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
suffix = "round 1" if call_count == 1 else "round 2"
|
||||
f = fruit.invoke({"messages": [HumanMessage(content=f"cherries {suffix}")]})
|
||||
v = veggie.invoke({"messages": [HumanMessage(content=f"broccoli {suffix}")]})
|
||||
fruit_msgs.append([m.text for m in f["messages"]])
|
||||
veggie_msgs.append([m.text for m in v["messages"]])
|
||||
return {"result": f["messages"][-1].text}
|
||||
|
||||
parent = (
|
||||
StateGraph(ParentState)
|
||||
.add_node("call_both", call_both)
|
||||
.add_edge(START, "call_both")
|
||||
.compile(checkpointer=sync_checkpointer)
|
||||
)
|
||||
config = {"configurable": {"thread_id": str(uuid4())}}
|
||||
|
||||
result1 = parent.invoke({"result": ""}, config)
|
||||
assert result1 == {"result": "Fruit: cherries round 1"}
|
||||
|
||||
result2 = parent.invoke({"result": ""}, config)
|
||||
assert result2 == {"result": "Fruit: cherries round 2"}
|
||||
|
||||
# First call: each agent sees only its own history
|
||||
assert fruit_msgs[0] == ["cherries round 1", "Fruit: cherries round 1"]
|
||||
assert veggie_msgs[0] == ["broccoli round 1", "Veggie: broccoli round 1"]
|
||||
|
||||
# Second call: each accumulated independently — no cross-contamination
|
||||
assert fruit_msgs[1] == [
|
||||
"cherries round 1",
|
||||
"Fruit: cherries round 1",
|
||||
"cherries round 2",
|
||||
"Fruit: cherries round 2",
|
||||
]
|
||||
assert veggie_msgs[1] == [
|
||||
"broccoli round 1",
|
||||
"Veggie: broccoli round 1",
|
||||
"broccoli round 2",
|
||||
"Veggie: broccoli round 2",
|
||||
]
|
||||
@@ -0,0 +1,662 @@
|
||||
"""Tests for subgraph persistence behavior (async).
|
||||
|
||||
Covers three checkpointer settings for subgraph state:
|
||||
- checkpointer=False: no persistence, even when parent has a checkpointer
|
||||
- checkpointer=None (default): "stateless" — inherits parent checkpointer for
|
||||
interrupt support, but state resets each invocation. This is the common case
|
||||
when an agent is invoked from inside a tool used by another agent.
|
||||
- checkpointer=True: "stateful" — state accumulates across invocations on the same thread id
|
||||
"""
|
||||
|
||||
import sys
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
from langgraph.checkpoint.base import BaseCheckpointSaver
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from langgraph.graph import START, StateGraph
|
||||
from langgraph.graph.message import MessagesState
|
||||
from langgraph.types import Command, Interrupt, interrupt
|
||||
from tests.any_str import AnyStr
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
NEEDS_CONTEXTVARS = pytest.mark.skipif(
|
||||
sys.version_info < (3, 11),
|
||||
reason="Python 3.11+ is required for async contextvars support",
|
||||
)
|
||||
|
||||
|
||||
class ParentState(TypedDict):
|
||||
result: str
|
||||
|
||||
|
||||
# -- checkpointer=None (stateless) --
|
||||
|
||||
|
||||
@NEEDS_CONTEXTVARS
|
||||
async def test_stateless_interrupt_resume_async(
|
||||
async_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Tests that a subgraph compiled with checkpointer=None (the default) can
|
||||
still support interrupt/resume when invoked from inside a parent graph that
|
||||
has a checkpointer. This is the "stateless" pattern — the subgraph inherits
|
||||
the parent's checkpointer just enough to pause and resume, but does not
|
||||
retain any state across separate parent invocations. This pattern commonly
|
||||
appears when an agent is invoked from inside a tool used by another agent.
|
||||
"""
|
||||
|
||||
# Build a subgraph that interrupts before echoing.
|
||||
# Two nodes: "process" interrupts then echoes, "respond" returns "Done".
|
||||
def process(state: MessagesState) -> dict:
|
||||
interrupt("continue?")
|
||||
return {
|
||||
"messages": [AIMessage(content=f"Processing: {state['messages'][-1].text}")]
|
||||
}
|
||||
|
||||
def respond(state: MessagesState) -> dict:
|
||||
return {"messages": [AIMessage(content="Done")]}
|
||||
|
||||
inner = (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("process", process)
|
||||
.add_node("respond", respond)
|
||||
.add_edge(START, "process")
|
||||
.add_edge("process", "respond")
|
||||
.compile()
|
||||
)
|
||||
|
||||
async def call_inner(state: ParentState) -> dict:
|
||||
resp = await inner.ainvoke({"messages": [HumanMessage(content="apples")]})
|
||||
return {"result": resp["messages"][-1].text}
|
||||
|
||||
parent = (
|
||||
StateGraph(ParentState)
|
||||
.add_node("call_inner", call_inner)
|
||||
.add_edge(START, "call_inner")
|
||||
.compile(checkpointer=async_checkpointer)
|
||||
)
|
||||
config = {"configurable": {"thread_id": str(uuid4())}}
|
||||
|
||||
# First invoke hits the interrupt
|
||||
result = await parent.ainvoke({"result": ""}, config)
|
||||
assert result == {
|
||||
"result": "",
|
||||
"__interrupt__": [Interrupt(value="continue?", id=AnyStr())],
|
||||
}
|
||||
|
||||
# Resume completes the subgraph
|
||||
result = await parent.ainvoke(Command(resume=True), config)
|
||||
assert result == {"result": "Done"}
|
||||
|
||||
|
||||
@NEEDS_CONTEXTVARS
|
||||
async def test_stateless_state_resets_async(
|
||||
async_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Tests that a subgraph compiled with checkpointer=None (the default) does
|
||||
not retain any message history between separate parent invocations. Each time
|
||||
the parent graph invokes the subgraph, it starts with a clean slate. This
|
||||
confirms the "stateless" behavior: even though the parent has a checkpointer,
|
||||
the subgraph state is not persisted across calls.
|
||||
"""
|
||||
|
||||
# Build a simple echo subgraph: echoes "Processing: <input>"
|
||||
def echo(state: MessagesState) -> dict:
|
||||
return {
|
||||
"messages": [AIMessage(content=f"Processing: {state['messages'][-1].text}")]
|
||||
}
|
||||
|
||||
inner = (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("echo", echo)
|
||||
.add_edge(START, "echo")
|
||||
.compile()
|
||||
)
|
||||
|
||||
subgraph_messages: list[list[str]] = []
|
||||
call_count = 0
|
||||
|
||||
async def call_inner(state: ParentState) -> dict:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
topic = "apples" if call_count == 1 else "bananas"
|
||||
resp = await inner.ainvoke(
|
||||
{"messages": [HumanMessage(content=f"tell me about {topic}")]}
|
||||
)
|
||||
subgraph_messages.append([m.text for m in resp["messages"]])
|
||||
return {"result": resp["messages"][-1].text}
|
||||
|
||||
parent = (
|
||||
StateGraph(ParentState)
|
||||
.add_node("call_inner", call_inner)
|
||||
.add_edge(START, "call_inner")
|
||||
.compile(checkpointer=async_checkpointer)
|
||||
)
|
||||
config = {"configurable": {"thread_id": str(uuid4())}}
|
||||
|
||||
result1 = await parent.ainvoke({"result": ""}, config)
|
||||
assert result1 == {"result": "Processing: tell me about apples"}
|
||||
|
||||
result2 = await parent.ainvoke({"result": ""}, config)
|
||||
assert result2 == {"result": "Processing: tell me about bananas"}
|
||||
|
||||
# Both invocations produce fresh history — no memory of prior call
|
||||
assert subgraph_messages[0] == [
|
||||
"tell me about apples",
|
||||
"Processing: tell me about apples",
|
||||
]
|
||||
assert subgraph_messages[1] == [
|
||||
"tell me about bananas",
|
||||
"Processing: tell me about bananas",
|
||||
]
|
||||
|
||||
|
||||
@NEEDS_CONTEXTVARS
|
||||
async def test_stateless_state_resets_with_interrupt_async(
|
||||
async_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Tests that a subgraph compiled with checkpointer=None resets its state
|
||||
between parent invocations even when interrupt/resume is used. The subgraph
|
||||
is invoked twice from the parent, each time with an interrupt that must be
|
||||
resumed. After both invoke+resume cycles, each subgraph run should only
|
||||
contain its own messages — no bleed-over from the previous run.
|
||||
"""
|
||||
|
||||
# Build a subgraph that interrupts before echoing, then responds "Done"
|
||||
def process(state: MessagesState) -> dict:
|
||||
interrupt("continue?")
|
||||
return {
|
||||
"messages": [AIMessage(content=f"Processing: {state['messages'][-1].text}")]
|
||||
}
|
||||
|
||||
def respond(state: MessagesState) -> dict:
|
||||
return {"messages": [AIMessage(content="Done")]}
|
||||
|
||||
inner = (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("process", process)
|
||||
.add_node("respond", respond)
|
||||
.add_edge(START, "process")
|
||||
.add_edge("process", "respond")
|
||||
.compile()
|
||||
)
|
||||
|
||||
subgraph_messages: list[list[str]] = []
|
||||
call_count = 0
|
||||
|
||||
async def call_inner(state: ParentState) -> dict:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
topic = "apples" if call_count == 1 else "bananas"
|
||||
resp = await inner.ainvoke(
|
||||
{"messages": [HumanMessage(content=f"tell me about {topic}")]}
|
||||
)
|
||||
subgraph_messages.append([m.text for m in resp["messages"]])
|
||||
return {"result": resp["messages"][-1].text}
|
||||
|
||||
parent = (
|
||||
StateGraph(ParentState)
|
||||
.add_node("call_inner", call_inner)
|
||||
.add_edge(START, "call_inner")
|
||||
.compile(checkpointer=async_checkpointer)
|
||||
)
|
||||
config = {"configurable": {"thread_id": str(uuid4())}}
|
||||
|
||||
# First invoke+resume cycle
|
||||
result = await parent.ainvoke({"result": ""}, config)
|
||||
assert result == {
|
||||
"result": "",
|
||||
"__interrupt__": [Interrupt(value="continue?", id=AnyStr())],
|
||||
}
|
||||
result = await parent.ainvoke(Command(resume=True), config)
|
||||
assert result == {"result": "Done"}
|
||||
|
||||
# Second invoke+resume cycle
|
||||
result = await parent.ainvoke({"result": ""}, config)
|
||||
assert result == {
|
||||
"result": "",
|
||||
"__interrupt__": [Interrupt(value="continue?", id=AnyStr())],
|
||||
}
|
||||
result = await parent.ainvoke(Command(resume=True), config)
|
||||
assert result == {"result": "Done"}
|
||||
|
||||
# Both invocations produce fresh history — no memory of prior call
|
||||
assert subgraph_messages[0] == [
|
||||
"tell me about apples",
|
||||
"Processing: tell me about apples",
|
||||
"Done",
|
||||
]
|
||||
assert subgraph_messages[1] == [
|
||||
"tell me about bananas",
|
||||
"Processing: tell me about bananas",
|
||||
"Done",
|
||||
]
|
||||
|
||||
|
||||
# -- checkpointer=False --
|
||||
|
||||
|
||||
@NEEDS_CONTEXTVARS
|
||||
async def test_checkpointer_false_no_persistence_async(
|
||||
async_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Tests that a subgraph compiled with checkpointer=False gets no
|
||||
persistence at all, even when the parent graph has a checkpointer. Unlike
|
||||
the default (checkpointer=None) which inherits just enough from the parent
|
||||
to support interrupt/resume, checkpointer=False explicitly opts out of all
|
||||
checkpoint behavior. Each invocation starts completely fresh.
|
||||
"""
|
||||
|
||||
# Build a simple echo subgraph with checkpointer=False
|
||||
def echo(state: MessagesState) -> dict:
|
||||
return {
|
||||
"messages": [AIMessage(content=f"Processed: {state['messages'][-1].text}")]
|
||||
}
|
||||
|
||||
inner = (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("echo", echo)
|
||||
.add_edge(START, "echo")
|
||||
.compile(checkpointer=False)
|
||||
)
|
||||
|
||||
subgraph_messages: list[list[str]] = []
|
||||
call_count = 0
|
||||
|
||||
async def call_inner(state: ParentState) -> dict:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
topic = "apples" if call_count == 1 else "bananas"
|
||||
resp = await inner.ainvoke(
|
||||
{"messages": [HumanMessage(content=f"tell me about {topic}")]}
|
||||
)
|
||||
subgraph_messages.append([m.text for m in resp["messages"]])
|
||||
return {"result": resp["messages"][-1].text}
|
||||
|
||||
parent = (
|
||||
StateGraph(ParentState)
|
||||
.add_node("call_inner", call_inner)
|
||||
.add_edge(START, "call_inner")
|
||||
.compile(checkpointer=async_checkpointer)
|
||||
)
|
||||
config = {"configurable": {"thread_id": str(uuid4())}}
|
||||
|
||||
result1 = await parent.ainvoke({"result": ""}, config)
|
||||
assert result1 == {"result": "Processed: tell me about apples"}
|
||||
|
||||
result2 = await parent.ainvoke({"result": ""}, config)
|
||||
assert result2 == {"result": "Processed: tell me about bananas"}
|
||||
|
||||
# Both start fresh — no history from first call
|
||||
assert subgraph_messages[0] == [
|
||||
"tell me about apples",
|
||||
"Processed: tell me about apples",
|
||||
]
|
||||
assert subgraph_messages[1] == [
|
||||
"tell me about bananas",
|
||||
"Processed: tell me about bananas",
|
||||
]
|
||||
|
||||
|
||||
# -- checkpointer=True (stateful) --
|
||||
|
||||
|
||||
@NEEDS_CONTEXTVARS
|
||||
async def test_stateful_state_accumulates_async(
|
||||
async_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Tests that a subgraph compiled with checkpointer=True ("stateful")
|
||||
retains its message history across separate parent invocations. To enable
|
||||
this, the subgraph is wrapped in an outer graph compiled with
|
||||
checkpointer=True — this wrapper gives the inner subgraph its own persistent
|
||||
checkpoint namespace. After two parent calls, the second subgraph invocation
|
||||
should see messages from both the first and second calls.
|
||||
"""
|
||||
|
||||
# Build a simple echo subgraph
|
||||
def echo(state: MessagesState) -> dict:
|
||||
return {
|
||||
"messages": [AIMessage(content=f"Processing: {state['messages'][-1].text}")]
|
||||
}
|
||||
|
||||
inner = (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("echo", echo)
|
||||
.add_edge(START, "echo")
|
||||
.compile()
|
||||
)
|
||||
|
||||
# Wrap the inner subgraph with checkpointer=True to enable stateful.
|
||||
# The wrapper graph gives the subgraph its own persistent checkpoint
|
||||
# namespace, keyed by the node name ("agent").
|
||||
wrapper = (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("agent", inner)
|
||||
.add_edge(START, "agent")
|
||||
.compile(checkpointer=True)
|
||||
)
|
||||
|
||||
subgraph_messages: list[list[str]] = []
|
||||
topics = ["apples", "bananas"]
|
||||
|
||||
async def call_inner(state: ParentState) -> dict:
|
||||
topic = topics[len(subgraph_messages)]
|
||||
resp = await wrapper.ainvoke(
|
||||
{"messages": [HumanMessage(content=f"tell me about {topic}")]}
|
||||
)
|
||||
subgraph_messages.append([m.text for m in resp["messages"]])
|
||||
return {"result": resp["messages"][-1].text}
|
||||
|
||||
parent = (
|
||||
StateGraph(ParentState)
|
||||
.add_node("call_inner", call_inner)
|
||||
.add_edge(START, "call_inner")
|
||||
.compile(checkpointer=async_checkpointer)
|
||||
)
|
||||
config = {"configurable": {"thread_id": str(uuid4())}}
|
||||
|
||||
result1 = await parent.ainvoke({"result": ""}, config)
|
||||
assert result1 == {"result": "Processing: tell me about apples"}
|
||||
|
||||
result2 = await parent.ainvoke({"result": ""}, config)
|
||||
assert result2 == {"result": "Processing: tell me about bananas"}
|
||||
|
||||
# First call: fresh history
|
||||
assert subgraph_messages[0] == [
|
||||
"tell me about apples",
|
||||
"Processing: tell me about apples",
|
||||
]
|
||||
# Second call: retains messages from first call
|
||||
assert subgraph_messages[1] == [
|
||||
"tell me about apples",
|
||||
"Processing: tell me about apples",
|
||||
"tell me about bananas",
|
||||
"Processing: tell me about bananas",
|
||||
]
|
||||
|
||||
|
||||
@NEEDS_CONTEXTVARS
|
||||
async def test_stateful_state_accumulates_with_interrupt_async(
|
||||
async_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Tests that a stateful subgraph (checkpointer=True) retains its
|
||||
message history across parent invocations even when interrupt/resume is
|
||||
involved. The subgraph interrupts before echoing, then responds "Done".
|
||||
After two invoke+resume cycles, the second run should contain the full
|
||||
accumulated history from both calls.
|
||||
"""
|
||||
|
||||
# Build a subgraph that interrupts before echoing, then responds "Done"
|
||||
def process(state: MessagesState) -> dict:
|
||||
interrupt("continue?")
|
||||
return {
|
||||
"messages": [AIMessage(content=f"Processing: {state['messages'][-1].text}")]
|
||||
}
|
||||
|
||||
def respond(state: MessagesState) -> dict:
|
||||
return {"messages": [AIMessage(content="Done")]}
|
||||
|
||||
inner = (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("process", process)
|
||||
.add_node("respond", respond)
|
||||
.add_edge(START, "process")
|
||||
.add_edge("process", "respond")
|
||||
.compile()
|
||||
)
|
||||
|
||||
# Wrap with checkpointer=True for stateful
|
||||
wrapper = (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("agent", inner)
|
||||
.add_edge(START, "agent")
|
||||
.compile(checkpointer=True)
|
||||
)
|
||||
|
||||
subgraph_messages: list[list[str]] = []
|
||||
topics = ["apples", "bananas"]
|
||||
|
||||
async def call_inner(state: ParentState) -> dict:
|
||||
topic = topics[len(subgraph_messages)]
|
||||
resp = await wrapper.ainvoke(
|
||||
{"messages": [HumanMessage(content=f"tell me about {topic}")]}
|
||||
)
|
||||
subgraph_messages.append([m.text for m in resp["messages"]])
|
||||
return {"result": resp["messages"][-1].text}
|
||||
|
||||
parent = (
|
||||
StateGraph(ParentState)
|
||||
.add_node("call_inner", call_inner)
|
||||
.add_edge(START, "call_inner")
|
||||
.compile(checkpointer=async_checkpointer)
|
||||
)
|
||||
config = {"configurable": {"thread_id": str(uuid4())}}
|
||||
|
||||
# First invoke+resume cycle
|
||||
result = await parent.ainvoke({"result": ""}, config)
|
||||
assert result == {
|
||||
"result": "",
|
||||
"__interrupt__": [Interrupt(value="continue?", id=AnyStr())],
|
||||
}
|
||||
result = await parent.ainvoke(Command(resume=True), config)
|
||||
assert result == {"result": "Done"}
|
||||
|
||||
# Second invoke+resume cycle
|
||||
result = await parent.ainvoke({"result": ""}, config)
|
||||
assert result == {
|
||||
"result": "",
|
||||
"__interrupt__": [Interrupt(value="continue?", id=AnyStr())],
|
||||
}
|
||||
result = await parent.ainvoke(Command(resume=True), config)
|
||||
assert result == {"result": "Done"}
|
||||
|
||||
# First call: fresh history
|
||||
assert subgraph_messages[0] == [
|
||||
"tell me about apples",
|
||||
"Processing: tell me about apples",
|
||||
"Done",
|
||||
]
|
||||
# Second call: retains messages from first call
|
||||
assert subgraph_messages[1] == [
|
||||
"tell me about apples",
|
||||
"Processing: tell me about apples",
|
||||
"Done",
|
||||
"tell me about bananas",
|
||||
"Processing: tell me about bananas",
|
||||
"Done",
|
||||
]
|
||||
|
||||
|
||||
@NEEDS_CONTEXTVARS
|
||||
async def test_stateful_interrupt_resume_async(
|
||||
async_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Tests that a stateful subgraph (checkpointer=True) correctly
|
||||
supports interrupt/resume while also accumulating state. Each invoke+resume
|
||||
pair triggers the subgraph, and after the second pair completes we verify
|
||||
both the per-step invoke outputs and the accumulated message history. This
|
||||
exercises the full lifecycle: interrupt, resume, state accumulation.
|
||||
"""
|
||||
|
||||
# Build a subgraph that interrupts before echoing, then responds "Done"
|
||||
def process(state: MessagesState) -> dict:
|
||||
interrupt("continue?")
|
||||
return {
|
||||
"messages": [AIMessage(content=f"Processing: {state['messages'][-1].text}")]
|
||||
}
|
||||
|
||||
def respond(state: MessagesState) -> dict:
|
||||
return {"messages": [AIMessage(content="Done")]}
|
||||
|
||||
inner = (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("process", process)
|
||||
.add_node("respond", respond)
|
||||
.add_edge(START, "process")
|
||||
.add_edge("process", "respond")
|
||||
.compile()
|
||||
)
|
||||
|
||||
# Wrap with checkpointer=True for stateful
|
||||
wrapper = (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("agent", inner)
|
||||
.add_edge(START, "agent")
|
||||
.compile(checkpointer=True)
|
||||
)
|
||||
|
||||
subgraph_messages: list[list[str]] = []
|
||||
topics = ["apples", "bananas"]
|
||||
|
||||
async def call_inner(state: ParentState) -> dict:
|
||||
topic = topics[len(subgraph_messages)]
|
||||
resp = await wrapper.ainvoke(
|
||||
{"messages": [HumanMessage(content=f"tell me about {topic}")]}
|
||||
)
|
||||
subgraph_messages.append([m.text for m in resp["messages"]])
|
||||
return {"result": resp["messages"][-1].text}
|
||||
|
||||
parent = (
|
||||
StateGraph(ParentState)
|
||||
.add_node("call_inner", call_inner)
|
||||
.add_edge(START, "call_inner")
|
||||
.compile(checkpointer=async_checkpointer)
|
||||
)
|
||||
config = {"configurable": {"thread_id": str(uuid4())}}
|
||||
|
||||
# First invocation: hits interrupt
|
||||
result = await parent.ainvoke({"result": ""}, config)
|
||||
assert result == {
|
||||
"result": "",
|
||||
"__interrupt__": [Interrupt(value="continue?", id=AnyStr())],
|
||||
}
|
||||
|
||||
# Resume: completes first call
|
||||
result = await parent.ainvoke(Command(resume=True), config)
|
||||
assert result == {"result": "Done"}
|
||||
assert subgraph_messages[0] == [
|
||||
"tell me about apples",
|
||||
"Processing: tell me about apples",
|
||||
"Done",
|
||||
]
|
||||
|
||||
# Second invocation: hits interrupt, state accumulated from first call
|
||||
result = await parent.ainvoke({"result": ""}, config)
|
||||
assert result == {
|
||||
"result": "",
|
||||
"__interrupt__": [Interrupt(value="continue?", id=AnyStr())],
|
||||
}
|
||||
|
||||
# Resume: completes second call with accumulated state
|
||||
result = await parent.ainvoke(Command(resume=True), config)
|
||||
assert result == {"result": "Done"}
|
||||
assert subgraph_messages[1] == [
|
||||
"tell me about apples",
|
||||
"Processing: tell me about apples",
|
||||
"Done",
|
||||
"tell me about bananas",
|
||||
"Processing: tell me about bananas",
|
||||
"Done",
|
||||
]
|
||||
|
||||
|
||||
@NEEDS_CONTEXTVARS
|
||||
async def test_stateful_namespace_isolation_async(
|
||||
async_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Tests that two different stateful subgraphs (checkpointer=True)
|
||||
maintain completely independent state when they use different wrapper node
|
||||
names. A "fruit_agent" and "veggie_agent" are each wrapped in their own
|
||||
stateful graph. After two parent invocations, each agent should only
|
||||
see its own accumulated history with no cross-contamination between them.
|
||||
"""
|
||||
|
||||
# Build two simple echo subgraphs with different prefixes
|
||||
def fruit_echo(state: MessagesState) -> dict:
|
||||
return {"messages": [AIMessage(content=f"Fruit: {state['messages'][-1].text}")]}
|
||||
|
||||
def veggie_echo(state: MessagesState) -> dict:
|
||||
return {
|
||||
"messages": [AIMessage(content=f"Veggie: {state['messages'][-1].text}")]
|
||||
}
|
||||
|
||||
fruit_inner = (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("echo", fruit_echo)
|
||||
.add_edge(START, "echo")
|
||||
.compile()
|
||||
)
|
||||
veggie_inner = (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("echo", veggie_echo)
|
||||
.add_edge(START, "echo")
|
||||
.compile()
|
||||
)
|
||||
|
||||
# Wrap each with checkpointer=True, using different node names to get
|
||||
# independent checkpoint namespaces
|
||||
fruit = (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("fruit_agent", fruit_inner)
|
||||
.add_edge(START, "fruit_agent")
|
||||
.compile(checkpointer=True)
|
||||
)
|
||||
veggie = (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("veggie_agent", veggie_inner)
|
||||
.add_edge(START, "veggie_agent")
|
||||
.compile(checkpointer=True)
|
||||
)
|
||||
|
||||
fruit_msgs: list[list[str]] = []
|
||||
veggie_msgs: list[list[str]] = []
|
||||
call_count = 0
|
||||
|
||||
async def call_both(state: ParentState) -> dict:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
suffix = "round 1" if call_count == 1 else "round 2"
|
||||
f = await fruit.ainvoke(
|
||||
{"messages": [HumanMessage(content=f"cherries {suffix}")]}
|
||||
)
|
||||
v = await veggie.ainvoke(
|
||||
{"messages": [HumanMessage(content=f"broccoli {suffix}")]}
|
||||
)
|
||||
fruit_msgs.append([m.text for m in f["messages"]])
|
||||
veggie_msgs.append([m.text for m in v["messages"]])
|
||||
return {"result": f["messages"][-1].text}
|
||||
|
||||
parent = (
|
||||
StateGraph(ParentState)
|
||||
.add_node("call_both", call_both)
|
||||
.add_edge(START, "call_both")
|
||||
.compile(checkpointer=async_checkpointer)
|
||||
)
|
||||
config = {"configurable": {"thread_id": str(uuid4())}}
|
||||
|
||||
result1 = await parent.ainvoke({"result": ""}, config)
|
||||
assert result1 == {"result": "Fruit: cherries round 1"}
|
||||
|
||||
result2 = await parent.ainvoke({"result": ""}, config)
|
||||
assert result2 == {"result": "Fruit: cherries round 2"}
|
||||
|
||||
# First call: each agent sees only its own history
|
||||
assert fruit_msgs[0] == ["cherries round 1", "Fruit: cherries round 1"]
|
||||
assert veggie_msgs[0] == ["broccoli round 1", "Veggie: broccoli round 1"]
|
||||
|
||||
# Second call: each accumulated independently — no cross-contamination
|
||||
assert fruit_msgs[1] == [
|
||||
"cherries round 1",
|
||||
"Fruit: cherries round 1",
|
||||
"cherries round 2",
|
||||
"Fruit: cherries round 2",
|
||||
]
|
||||
assert veggie_msgs[1] == [
|
||||
"broccoli round 1",
|
||||
"Veggie: broccoli round 1",
|
||||
"broccoli round 2",
|
||||
"Veggie: broccoli round 2",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Generated
+31
-30
@@ -1348,7 +1348,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langchain-core"
|
||||
version = "1.2.13"
|
||||
version = "1.2.16"
|
||||
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/fb/bb/c501ca60556c11ac80d1454bdcac63cb33583ce4e64fc4535ad5a7d5c6ba/langchain_core-1.2.13.tar.gz", hash = "sha256:d2773d0d0130a356378db9a858cfeef64c3d64bc03722f1d4d6c40eb46fdf01b", size = 831612, upload-time = "2026-02-15T07:45:57.014Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2e/a7/4c992456dae89a8704afec03e3c2a0149ccc5f29c1cbdd5f4aa77628e921/langchain_core-1.2.16.tar.gz", hash = "sha256:055a4bfe7d62f4ac45ed49fd759ee2e6bdd15abf998fbeea695fda5da2de6413", size = 835286, upload-time = "2026-02-25T16:27:30.551Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/12/ab/60fd69e5d55f67d422baefddaaca523c42cd7510ab6aeb17db6ae57fb107/langchain_core-1.2.13-py3-none-any.whl", hash = "sha256:b31823e28d3eff1e237096d0bd3bf80c6f9624eb471a9496dbfbd427779f8d82", size = 500485, upload-time = "2026-02-15T07:45:55.422Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/a1/57d5feaa11dc2ebb40f3bc3d7bf4294b6703e152e56edea9d4c622475a6a/langchain_core-1.2.16-py3-none-any.whl", hash = "sha256:2768add9aa97232a7712580f678e0ba045ee1036c71fe471355be0434fcb6e30", size = 502219, upload-time = "2026-02-25T16:27:29.379Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langgraph"
|
||||
version = "1.0.8"
|
||||
version = "1.0.10"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -1548,7 +1548,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-checkpoint"
|
||||
version = "4.0.0"
|
||||
version = "4.0.1"
|
||||
source = { editable = "../checkpoint" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -1569,6 +1569,7 @@ dev = [
|
||||
{ name = "numpy" },
|
||||
{ name = "pandas" },
|
||||
{ name = "pandas-stubs", specifier = ">=2.2.2.240807" },
|
||||
{ name = "pycryptodome", specifier = ">=3.23.0" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "pytest-mock" },
|
||||
@@ -1607,7 +1608,7 @@ dependencies = [
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
|
||||
{ name = "orjson", specifier = ">=3.10.1" },
|
||||
{ name = "orjson", specifier = ">=3.11.5" },
|
||||
{ name = "psycopg", specifier = ">=3.2.0" },
|
||||
{ name = "psycopg-pool", specifier = ">=3.2.0" },
|
||||
]
|
||||
@@ -1735,7 +1736,7 @@ test = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-prebuilt"
|
||||
version = "1.0.7"
|
||||
version = "1.0.8"
|
||||
source = { editable = "../prebuilt" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -1812,7 +1813,7 @@ dependencies = [
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "httpx", specifier = ">=0.25.2" },
|
||||
{ name = "orjson", specifier = ">=3.10.1" },
|
||||
{ name = "orjson", specifier = ">=3.11.5" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
@@ -3180,14 +3181,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "redis"
|
||||
version = "7.2.0"
|
||||
version = "7.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "async-timeout", marker = "python_full_version < '3.11.3'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9f/32/6fac13a11e73e1bc67a2ae821a72bfe4c2d8c4c48f0267e4a952be0f1bae/redis-7.2.0.tar.gz", hash = "sha256:4dd5bf4bd4ae80510267f14185a15cba2a38666b941aff68cccf0256b51c1f26", size = 4901247, upload-time = "2026-02-16T17:16:22.797Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e9/31/1476f206482dd9bc53fdbbe9f6fbd5e05d153f18e54667ce839df331f2e6/redis-7.2.1.tar.gz", hash = "sha256:6163c1a47ee2d9d01221d8456bc1c75ab953cbda18cfbc15e7140e9ba16ca3a5", size = 4906735, upload-time = "2026-02-25T20:05:18.171Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/86/cf/f6180b67f99688d83e15c84c5beda831d1d341e95872d224f87ccafafe61/redis-7.2.0-py3-none-any.whl", hash = "sha256:01f591f8598e483f1842d429e8ae3a820804566f1c73dca1b80e23af9fba0497", size = 394898, upload-time = "2026-02-16T17:16:20.693Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/98/1dd1a5c060916cf21d15e67b7d6a7078e26e2605d5c37cbc9f4f5454c478/redis-7.2.1-py3-none-any.whl", hash = "sha256:49e231fbc8df2001436ae5252b3f0f3dc930430239bfeb6da4c7ee92b16e5d33", size = 396057, upload-time = "2026-02-25T20:05:16.533Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3388,27 +3389,27 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.1"
|
||||
version = "0.15.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/04/dc/4e6ac71b511b141cf626357a3946679abeba4cf67bc7cc5a17920f31e10d/ruff-0.15.1.tar.gz", hash = "sha256:c590fe13fb57c97141ae975c03a1aedb3d3156030cabd740d6ff0b0d601e203f", size = 4540855, upload-time = "2026-02-12T23:09:09.998Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/da/31/d6e536cdebb6568ae75a7f00e4b4819ae0ad2640c3604c305a0428680b0c/ruff-0.15.4.tar.gz", hash = "sha256:3412195319e42d634470cc97aa9803d07e9d5c9223b99bcb1518f0c725f26ae1", size = 4569550, upload-time = "2026-02-26T20:04:14.959Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/23/bf/e6e4324238c17f9d9120a9d60aa99a7daaa21204c07fcd84e2ef03bb5fd1/ruff-0.15.1-py3-none-linux_armv6l.whl", hash = "sha256:b101ed7cf4615bda6ffe65bdb59f964e9f4a0d3f85cbf0e54f0ab76d7b90228a", size = 10367819, upload-time = "2026-02-12T23:09:03.598Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/ea/c8f89d32e7912269d38c58f3649e453ac32c528f93bb7f4219258be2e7ed/ruff-0.15.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:939c995e9277e63ea632cc8d3fae17aa758526f49a9a850d2e7e758bfef46602", size = 10798618, upload-time = "2026-02-12T23:09:22.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/0f/1d0d88bc862624247d82c20c10d4c0f6bb2f346559d8af281674cf327f15/ruff-0.15.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1d83466455fdefe60b8d9c8df81d3c1bbb2115cede53549d3b522ce2bc703899", size = 10148518, upload-time = "2026-02-12T23:08:58.339Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/c8/291c49cefaa4a9248e986256df2ade7add79388fe179e0691be06fae6f37/ruff-0.15.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9457e3c3291024866222b96108ab2d8265b477e5b1534c7ddb1810904858d16", size = 10518811, upload-time = "2026-02-12T23:09:31.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/1a/f5707440e5ae43ffa5365cac8bbb91e9665f4a883f560893829cf16a606b/ruff-0.15.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:92c92b003e9d4f7fbd33b1867bb15a1b785b1735069108dfc23821ba045b29bc", size = 10196169, upload-time = "2026-02-12T23:09:17.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/ff/26ddc8c4da04c8fd3ee65a89c9fb99eaa5c30394269d424461467be2271f/ruff-0.15.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fe5c41ab43e3a06778844c586251eb5a510f67125427625f9eb2b9526535779", size = 10990491, upload-time = "2026-02-12T23:09:25.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/00/50920cb385b89413f7cdb4bb9bc8fc59c1b0f30028d8bccc294189a54955/ruff-0.15.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66a6dd6df4d80dc382c6484f8ce1bcceb55c32e9f27a8b94c32f6c7331bf14fb", size = 11843280, upload-time = "2026-02-12T23:09:19.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/6d/2f5cad8380caf5632a15460c323ae326f1e1a2b5b90a6ee7519017a017ca/ruff-0.15.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a4a42cbb8af0bda9bcd7606b064d7c0bc311a88d141d02f78920be6acb5aa83", size = 11274336, upload-time = "2026-02-12T23:09:14.907Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/1d/5f56cae1d6c40b8a318513599b35ea4b075d7dc1cd1d04449578c29d1d75/ruff-0.15.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ab064052c31dddada35079901592dfba2e05f5b1e43af3954aafcbc1096a5b2", size = 11137288, upload-time = "2026-02-12T23:09:07.475Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/20/6f8d7d8f768c93b0382b33b9306b3b999918816da46537d5a61635514635/ruff-0.15.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5631c940fe9fe91f817a4c2ea4e81f47bee3ca4aa646134a24374f3c19ad9454", size = 11070681, upload-time = "2026-02-12T23:08:55.43Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/67/d640ac76069f64cdea59dba02af2e00b1fa30e2103c7f8d049c0cff4cafd/ruff-0.15.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68138a4ba184b4691ccdc39f7795c66b3c68160c586519e7e8444cf5a53e1b4c", size = 10486401, upload-time = "2026-02-12T23:09:27.927Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/3d/e1429f64a3ff89297497916b88c32a5cc88eeca7e9c787072d0e7f1d3e1e/ruff-0.15.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:518f9af03bfc33c03bdb4cb63fabc935341bb7f54af500f92ac309ecfbba6330", size = 10197452, upload-time = "2026-02-12T23:09:12.147Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/83/e2c3bade17dad63bf1e1c2ffaf11490603b760be149e1419b07049b36ef2/ruff-0.15.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:da79f4d6a826caaea95de0237a67e33b81e6ec2e25fc7e1993a4015dffca7c61", size = 10693900, upload-time = "2026-02-12T23:09:34.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/27/fdc0e11a813e6338e0706e8b39bb7a1d61ea5b36873b351acee7e524a72a/ruff-0.15.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3dd86dccb83cd7d4dcfac303ffc277e6048600dfc22e38158afa208e8bf94a1f", size = 11227302, upload-time = "2026-02-12T23:09:36.536Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/58/ac864a75067dcbd3b95be5ab4eb2b601d7fbc3d3d736a27e391a4f92a5c1/ruff-0.15.1-py3-none-win32.whl", hash = "sha256:660975d9cb49b5d5278b12b03bb9951d554543a90b74ed5d366b20e2c57c2098", size = 10462555, upload-time = "2026-02-12T23:09:29.899Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/5e/d4ccc8a27ecdb78116feac4935dfc39d1304536f4296168f91ed3ec00cd2/ruff-0.15.1-py3-none-win_amd64.whl", hash = "sha256:c820fef9dd5d4172a6570e5721704a96c6679b80cf7be41659ed439653f62336", size = 11599956, upload-time = "2026-02-12T23:09:01.157Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/07/5bda6a85b220c64c65686bc85bd0bbb23b29c62b3a9f9433fa55f17cda93/ruff-0.15.1-py3-none-win_arm64.whl", hash = "sha256:5ff7d5f0f88567850f45081fac8f4ec212be8d0b963e385c3f7d0d2eb4899416", size = 10874604, upload-time = "2026-02-12T23:09:05.515Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/82/c11a03cfec3a4d26a0ea1e571f0f44be5993b923f905eeddfc397c13d360/ruff-0.15.4-py3-none-linux_armv6l.whl", hash = "sha256:a1810931c41606c686bae8b5b9a8072adac2f611bb433c0ba476acba17a332e0", size = 10453333, upload-time = "2026-02-26T20:04:20.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/5d/6a1f271f6e31dffb31855996493641edc3eef8077b883eaf007a2f1c2976/ruff-0.15.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5a1632c66672b8b4d3e1d1782859e98d6e0b4e70829530666644286600a33992", size = 10853356, upload-time = "2026-02-26T20:04:05.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/d8/0fab9f8842b83b1a9c2bf81b85063f65e93fb512e60effa95b0be49bfc54/ruff-0.15.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a4386ba2cd6c0f4ff75252845906acc7c7c8e1ac567b7bc3d373686ac8c222ba", size = 10187434, upload-time = "2026-02-26T20:03:54.656Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/cc/cc220fd9394eff5db8d94dec199eec56dd6c9f3651d8869d024867a91030/ruff-0.15.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2496488bdfd3732747558b6f95ae427ff066d1fcd054daf75f5a50674411e75", size = 10535456, upload-time = "2026-02-26T20:03:52.738Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/0f/bced38fa5cf24373ec767713c8e4cadc90247f3863605fb030e597878661/ruff-0.15.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3f1c4893841ff2d54cbda1b2860fa3260173df5ddd7b95d370186f8a5e66a4ac", size = 10287772, upload-time = "2026-02-26T20:04:08.138Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/90/58a1802d84fed15f8f281925b21ab3cecd813bde52a8ca033a4de8ab0e7a/ruff-0.15.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:820b8766bd65503b6c30aaa6331e8ef3a6e564f7999c844e9a547c40179e440a", size = 11049051, upload-time = "2026-02-26T20:04:03.53Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/ac/b7ad36703c35f3866584564dc15f12f91cb1a26a897dc2fd13d7cb3ae1af/ruff-0.15.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9fb74bab47139c1751f900f857fa503987253c3ef89129b24ed375e72873e85", size = 11890494, upload-time = "2026-02-26T20:04:10.497Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/3d/3eb2f47a39a8b0da99faf9c54d3eb24720add1e886a5309d4d1be73a6380/ruff-0.15.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f80c98765949c518142b3a50a5db89343aa90f2c2bf7799de9986498ae6176db", size = 11326221, upload-time = "2026-02-26T20:04:12.84Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/90/bf134f4c1e5243e62690e09d63c55df948a74084c8ac3e48a88468314da6/ruff-0.15.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:451a2e224151729b3b6c9ffb36aed9091b2996fe4bdbd11f47e27d8f2e8888ec", size = 11168459, upload-time = "2026-02-26T20:04:00.969Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/e5/a64d27688789b06b5d55162aafc32059bb8c989c61a5139a36e1368285eb/ruff-0.15.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a8f157f2e583c513c4f5f896163a93198297371f34c04220daf40d133fdd4f7f", size = 11104366, upload-time = "2026-02-26T20:03:48.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/f6/32d1dcb66a2559763fc3027bdd65836cad9eb09d90f2ed6a63d8e9252b02/ruff-0.15.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:917cc68503357021f541e69b35361c99387cdbbf99bd0ea4aa6f28ca99ff5338", size = 10510887, upload-time = "2026-02-26T20:03:45.771Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/92/22d1ced50971c5b6433aed166fcef8c9343f567a94cf2b9d9089f6aa80fe/ruff-0.15.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e9737c8161da79fd7cfec19f1e35620375bd8b2a50c3e77fa3d2c16f574105cc", size = 10285939, upload-time = "2026-02-26T20:04:22.42Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/f4/7c20aec3143837641a02509a4668fb146a642fd1211846634edc17eb5563/ruff-0.15.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:291258c917539e18f6ba40482fe31d6f5ac023994ee11d7bdafd716f2aab8a68", size = 10765471, upload-time = "2026-02-26T20:03:58.924Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/09/6d2f7586f09a16120aebdff8f64d962d7c4348313c77ebb29c566cefc357/ruff-0.15.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3f83c45911da6f2cd5936c436cf86b9f09f09165f033a99dcf7477e34041cbc3", size = 11263382, upload-time = "2026-02-26T20:04:24.424Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/fa/2ef715a1cd329ef47c1a050e10dee91a9054b7ce2fcfdd6a06d139afb7ec/ruff-0.15.4-py3-none-win32.whl", hash = "sha256:65594a2d557d4ee9f02834fcdf0a28daa8b3b9f6cb2cb93846025a36db47ef22", size = 10506664, upload-time = "2026-02-26T20:03:50.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/a8/c688ef7e29983976820d18710f955751d9f4d4eb69df658af3d006e2ba3e/ruff-0.15.4-py3-none-win_amd64.whl", hash = "sha256:04196ad44f0df220c2ece5b0e959c2f37c777375ec744397d21d15b50a75264f", size = 11651048, upload-time = "2026-02-26T20:04:17.191Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/0a/9e1be9035b37448ce2e68c978f0591da94389ade5a5abafa4cf99985d1b2/ruff-0.15.4-py3-none-win_arm64.whl", hash = "sha256:60d5177e8cfc70e51b9c5fad936c634872a74209f934c1e79107d11787ad5453", size = 10966776, upload-time = "2026-02-26T20:03:56.908Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user