mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-10-17 05:35:31 +02:00
Compare commits
208
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41c9c22fdc | ||
|
|
51018652a6 | ||
|
|
9e6fc45c6f | ||
|
|
b0cabd93d0 | ||
|
|
4ac46ba186 | ||
|
|
e14f4277dc | ||
|
|
4e2dfe4a24 | ||
|
|
e77a5cb08c | ||
|
|
b0482d0627 | ||
|
|
889a5ff16f | ||
|
|
5eb60aef6f | ||
|
|
d4149cc1f5 | ||
|
|
36bf3887f0 | ||
|
|
e5b422455d | ||
|
|
6cd26d0e21 | ||
|
|
53cf85bece | ||
|
|
69d98cc0d9 | ||
|
|
ccb851effb | ||
|
|
a6731f4367 | ||
|
|
8a77332d0d | ||
|
|
a1ce197234 | ||
|
|
afbe7e48b1 | ||
|
|
1074714c26 | ||
|
|
740461ed50 | ||
|
|
08a345637f | ||
|
|
b7d053b256 | ||
|
|
395c145c04 | ||
|
|
10f1fabb3b | ||
|
|
799c2f04fd | ||
|
|
b4991f1ba3 | ||
|
|
6aa0afba68 | ||
|
|
12aeb0fddb | ||
|
|
d05236f805 | ||
|
|
9d92f33cca | ||
|
|
26356227c4 | ||
|
|
a5dbacae0d | ||
|
|
cba111d8d6 | ||
|
|
93a5a28008 | ||
|
|
bfcfea554e | ||
|
|
93f5eaff21 | ||
|
|
5965d72ff7 | ||
|
|
40a2e6d845 | ||
|
|
a0053bb616 | ||
|
|
87f1c8eb9a | ||
|
|
736a18eaab | ||
|
|
ec4f8535f8 | ||
|
|
ce14d33687 | ||
|
|
708aaa4ebc | ||
|
|
39c523eb0a | ||
|
|
3a1796ecf0 | ||
|
|
2b93e8b79f | ||
|
|
70dd64065b | ||
|
|
55b0e36c51 | ||
|
|
d89c7e100e | ||
|
|
2d942085e2 | ||
|
|
456e2b1acc | ||
|
|
1b84cb0873 | ||
|
|
8e15e0d0bc | ||
|
|
524b320e30 | ||
|
|
9a0394d88b | ||
|
|
7dc9195e41 | ||
|
|
1fd716c44a | ||
|
|
3af263175f | ||
|
|
e6cf9ea667 | ||
|
|
157a06dda9 | ||
|
|
5204e60579 | ||
|
|
2053f0b176 | ||
|
|
c4f55715ec | ||
|
|
b36b1d58a8 | ||
|
|
9829ef9e64 | ||
|
|
f6e9ace639 | ||
|
|
b1765f3d0f | ||
|
|
3a2501e8c5 | ||
|
|
3474bbff29 | ||
|
|
7c8be00fd6 | ||
|
|
e9bde462f2 | ||
|
|
4be610c6bc | ||
|
|
98b10ba0ff | ||
|
|
ffe4e8cd6d | ||
|
|
f75add0ee9 | ||
|
|
902be1eb2b | ||
|
|
3b969324db | ||
|
|
e43f0f5f1e | ||
|
|
8ccdedcc1b | ||
|
|
7ea31e5e45 | ||
|
|
313e0e7e0a | ||
|
|
b9919ace32 | ||
|
|
4217373227 | ||
|
|
65ab10f017 | ||
|
|
eb69f67b65 | ||
|
|
c0279f0910 | ||
|
|
f5804a5bf5 | ||
|
|
07b33185ea | ||
|
|
7daa3ab49d | ||
|
|
e868c3ccfd | ||
|
|
bdb85b5aa8 | ||
|
|
1211af45b1 | ||
|
|
1afaca35a0 | ||
|
|
49cce0ca85 | ||
|
|
19273fa88b | ||
|
|
ed384f3a12 | ||
|
|
aa742fb31e | ||
|
|
b58044ac7a | ||
|
|
daa514a988 | ||
|
|
022043a679 | ||
|
|
d7b99cc3ab | ||
|
|
b19edd783b | ||
|
|
c81c13533e | ||
|
|
448a763779 | ||
|
|
230927fb3a | ||
|
|
e539ac122f | ||
|
|
ff4529380d | ||
|
|
c0b884e6d8 | ||
|
|
3299a1f563 | ||
|
|
0199b519e1 | ||
|
|
81bf17b231 | ||
|
|
11738d83db | ||
|
|
1983e60971 | ||
|
|
d07198a53c | ||
|
|
c0a13bb22d | ||
|
|
7bfee11ad5 | ||
|
|
eae02c622d | ||
|
|
bbd6e2da0b | ||
|
|
b7bcf0083c | ||
|
|
7815573c76 | ||
|
|
0cf2f960f6 | ||
|
|
bd1f031ac0 | ||
|
|
d80317dd6c | ||
|
|
394c109737 | ||
|
|
0c1a062f88 | ||
|
|
4b29b60d13 | ||
|
|
59738d1550 | ||
|
|
2efb0073a5 | ||
|
|
408e964501 | ||
|
|
11ee185999 | ||
|
|
d5f4b2aa96 | ||
|
|
5a77be5e8b | ||
|
|
bdb8a9c7a4 | ||
|
|
38031739e5 | ||
|
|
f09cfe8ffc | ||
|
|
837212b969 | ||
|
|
70918557ca | ||
|
|
1e44bda48f | ||
|
|
644815f9e5 | ||
|
|
7d6b5790ba | ||
|
|
d56666f7fb | ||
|
|
6a2822d3c7 | ||
|
|
fde3068970 | ||
|
|
f55e77274d | ||
|
|
a90ab44358 | ||
|
|
ea5f9cc9fb | ||
|
|
36a505ac65 | ||
|
|
d569e18f4b | ||
|
|
f22af6248c | ||
|
|
658541c496 | ||
|
|
fb3d5f0399 | ||
|
|
01521c1b1c | ||
|
|
52d9281005 | ||
|
|
cd62febcfa | ||
|
|
26a92d670c | ||
|
|
4ea0cac0a4 | ||
|
|
2cac3c0932 | ||
|
|
b2af47da46 | ||
|
|
f223c6d6f4 | ||
|
|
e7f05cb67c | ||
|
|
7efdf04209 | ||
|
|
fa36da7e88 | ||
|
|
5efb3d2d87 | ||
|
|
a43147ee57 | ||
|
|
b84734d62f | ||
|
|
b2926a0ff9 | ||
|
|
fcdf520938 | ||
|
|
66ebe1a0da | ||
|
|
4134145734 | ||
|
|
30c4d58db8 | ||
|
|
1f2f88b2b7 | ||
|
|
270820363d | ||
|
|
9f1e40bfee | ||
|
|
1e1ca88dad | ||
|
|
31f90df3e6 | ||
|
|
f02c0f0ce5 | ||
|
|
8b39db3875 | ||
|
|
9578140336 | ||
|
|
49ae27c2ae | ||
|
|
80633b2c53 | ||
|
|
eb6acf9c8b | ||
|
|
b96f6170e7 | ||
|
|
55ec2f2193 | ||
|
|
95af6a0071 | ||
|
|
7d0b33c961 | ||
|
|
23652c54be | ||
|
|
b45d96b8eb | ||
|
|
be999ad38a | ||
|
|
4df294f566 | ||
|
|
84e065a342 | ||
|
|
64a3e68be6 | ||
|
|
7989e5956f | ||
|
|
2390d6f940 | ||
|
|
039adfd708 | ||
|
|
cda83ea65a | ||
|
|
4c3db74ed7 | ||
|
|
72d7e2d18d | ||
|
|
634ad56740 | ||
|
|
06122e2f8e | ||
|
|
6dd2dad54b | ||
|
|
5931a5f0b3 | ||
|
|
9a27693c64 | ||
|
|
1b5ca0a1b1 |
@@ -41,7 +41,7 @@ def test(config: pathlib.Path, port: int, tag: str, verbose: bool):
|
||||
)
|
||||
|
||||
# Compose up with wait (implies detach), similar to `langgraph up --wait`
|
||||
args_up = [*args, "up", "--remove-orphans", "--wait"]
|
||||
args_up = [*args, "up", "--remove-orphans", "--wait", "--wait-timeout", "180"]
|
||||
|
||||
compose_cmd = ["docker", "compose"]
|
||||
if capabilities.compose_type == "standalone":
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
run:
|
||||
working-directory: libs/cli
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
if: github.event_name != 'workflow_dispatch'
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
if [ -f ../.env ]; then echo "LANGSMITH_API_KEY=${{ secrets.LANGSMITH_API_KEY }}" >> ../.env; fi
|
||||
# Run the integration test using the built tag
|
||||
REPO_ROOT=$(git rev-parse --show-toplevel)
|
||||
timeout 60 python "$REPO_ROOT/.github/scripts/run_langgraph_cli_test.py" -t ${{ matrix.example.tag }}
|
||||
timeout --kill-after=30 300 python "$REPO_ROOT/.github/scripts/run_langgraph_cli_test.py" -t ${{ matrix.example.tag }}
|
||||
|
||||
- name: Build JS service
|
||||
if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' }}
|
||||
@@ -98,7 +98,7 @@ jobs:
|
||||
run: |
|
||||
cp apps/agent/.env.example apps/agent/.env
|
||||
echo "LANGSMITH_API_KEY=${{ secrets.LANGSMITH_API_KEY }}" >> apps/agent/.env
|
||||
timeout 60 python ../../../.github/scripts/run_langgraph_cli_test.py -t langgraph-test-g -c apps/agent/langgraph.json
|
||||
timeout --kill-after=30 300 python ../../../.github/scripts/run_langgraph_cli_test.py -t langgraph-test-g -c apps/agent/langgraph.json
|
||||
|
||||
- name: Build prerelease reqs service
|
||||
if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' }}
|
||||
@@ -113,11 +113,11 @@ jobs:
|
||||
run: |
|
||||
cp ../.env.example .env
|
||||
echo "LANGSMITH_API_KEY=${{ secrets.LANGSMITH_API_KEY }}" >> .env
|
||||
timeout 60 python ../../../../.github/scripts/run_langgraph_cli_test.py -t langgraph-test-h
|
||||
timeout --kill-after=30 300 python ../../../../.github/scripts/run_langgraph_cli_test.py -t langgraph-test-h
|
||||
echo "Finished starting up langgraph-test-h"
|
||||
LANGGRAPH_VERSION=$(docker run --rm --entrypoint "" langgraph-test-h python -c "import sys; from importlib.metadata import version; v = version('langgraph'); print(v);")
|
||||
if [ "$LANGGRAPH_VERSION" != "1.1.5" ]; then
|
||||
echo "LANGGRAPH_VERSION != 1.1.5; $LANGGRAPH_VERSION"
|
||||
if [ "$LANGGRAPH_VERSION" != "1.2.6" ]; then
|
||||
echo "LANGGRAPH_VERSION != 1.2.6; $LANGGRAPH_VERSION"
|
||||
exit 1
|
||||
fi
|
||||
LANGCHAIN_OPENAI_VERSION=$(docker run --rm --entrypoint "" langgraph-test-h python -c "import sys; from importlib.metadata import version; v = version('langchain-openai'); print(v);")
|
||||
@@ -150,7 +150,7 @@ jobs:
|
||||
run: |
|
||||
cp .env.example .env
|
||||
echo "LANGSMITH_API_KEY=${{ secrets.LANGSMITH_API_KEY }}" >> .env
|
||||
timeout 60 python ../../../../.github/scripts/run_langgraph_cli_test.py -t langgraph-test-uv-simple
|
||||
timeout --kill-after=30 300 python ../../../../.github/scripts/run_langgraph_cli_test.py -t langgraph-test-uv-simple
|
||||
|
||||
- name: Build uv monorepo service
|
||||
if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' }}
|
||||
@@ -165,4 +165,4 @@ jobs:
|
||||
run: |
|
||||
cp .env.example .env
|
||||
echo "LANGSMITH_API_KEY=${{ secrets.LANGSMITH_API_KEY }}" >> .env
|
||||
timeout 60 python ../../../../../../.github/scripts/run_langgraph_cli_test.py -t langgraph-test-uv-monorepo
|
||||
timeout --kill-after=30 300 python ../../../../../../.github/scripts/run_langgraph_cli_test.py -t langgraph-test-uv-monorepo
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
- "3.12"
|
||||
name: "lint #${{ matrix.python-version }}"
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
if: github.event_name != 'workflow_dispatch'
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
env:
|
||||
HAS_LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY != '' }}
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Set up Python
|
||||
uses: ./.github/actions/uv_setup
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
working-directory: libs/sdk-py
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
||||
if: ${{ !github.event.pull_request.head.repo.fork }}
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
|
||||
name: "test #${{ matrix.python-version }}"
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: ./.github/actions/uv_setup
|
||||
with:
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
cache-suffix: test-${{ inputs.working-directory }}
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
||||
if: ${{ !github.event.pull_request.head.repo.fork }}
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
working-directory: libs/langgraph
|
||||
name: "test #${{ matrix.python-version }}"
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: ./.github/actions/uv_setup
|
||||
with:
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
cache-suffix: "test-langgraph"
|
||||
working-directory: libs/langgraph
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
||||
if: ${{ !github.event.pull_request.head.repo.fork }}
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
version: ${{ steps.check-version.outputs.version }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Set up Python ${{ env.PYTHON_VERSION }}
|
||||
uses: ./.github/actions/uv_setup
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
path: ${{ inputs.working-directory }}/dist/
|
||||
|
||||
- name: Publish to test PyPI
|
||||
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
|
||||
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1
|
||||
with:
|
||||
packages-dir: ${{ inputs.working-directory }}/dist/
|
||||
verbose: true
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
run:
|
||||
working-directory: libs/langgraph
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- run: SHA=$(git rev-parse HEAD) && echo "SHA=$SHA" >> $GITHUB_ENV
|
||||
- name: Set up Python 3.11
|
||||
uses: ./.github/actions/uv_setup
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
- name: Run benchmarks
|
||||
run: OUTPUT=out/benchmark-baseline.json make -s benchmark
|
||||
- name: Save outputs
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
key: ${{ runner.os }}-benchmark-baseline-${{ env.SHA }}
|
||||
path: |
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
run:
|
||||
working-directory: libs/langgraph
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- id: files
|
||||
name: Get changed files
|
||||
uses: Ana06/get-changed-files@25f79e676e7ea1868813e21465014798211fad8c # v2.3.0
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: uv sync --group test
|
||||
- name: Download baseline
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
key: ${{ runner.os }}-benchmark-baseline
|
||||
restore-keys: |
|
||||
|
||||
@@ -30,8 +30,8 @@ jobs:
|
||||
deps: ${{ steps.filter.outputs.deps || 'true' }}
|
||||
sdk_py: ${{ steps.filter.outputs.sdk_py || 'true' }}
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4
|
||||
if: github.event_name != 'workflow_dispatch'
|
||||
id: filter
|
||||
with:
|
||||
@@ -112,9 +112,9 @@ jobs:
|
||||
name: "Check SDK methods matching"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- name: Run check_sdk_methods script
|
||||
@@ -130,7 +130,7 @@ jobs:
|
||||
python-version:
|
||||
- "3.13"
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: ./.github/actions/uv_setup
|
||||
with:
|
||||
|
||||
@@ -7,6 +7,10 @@ on:
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- '.github/workflows/deploy-redirects.yml'
|
||||
# llms.txt is fetched from docs.langchain.com at build time, so redeploy on a
|
||||
# schedule to pick up docs changes that never touch this repo.
|
||||
schedule:
|
||||
- cron: '17 6 * * 1'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -26,10 +30,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
@@ -46,4 +50,4 @@ jobs:
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
|
||||
uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
tag: ${{ steps.check-version.outputs.tag }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Set up Python
|
||||
uses: ./.github/actions/uv_setup
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
outputs:
|
||||
release-body: ${{ steps.generate-release-body.outputs.release-body }}
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: langchain-ai/langgraph
|
||||
path: langgraph
|
||||
@@ -139,26 +139,13 @@ jobs:
|
||||
echo EOF
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
test-pypi-publish:
|
||||
needs:
|
||||
- build
|
||||
- release-notes
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
uses: ./.github/workflows/_test_release.yml
|
||||
with:
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
secrets: inherit
|
||||
|
||||
pre-release-checks:
|
||||
needs:
|
||||
- build
|
||||
- release-notes
|
||||
- test-pypi-publish
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
# We explicitly *don't* set up caching here. This ensures our tests are
|
||||
# maximally sensitive to catching breakage.
|
||||
@@ -180,31 +167,20 @@ jobs:
|
||||
enable-cache: false
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
|
||||
- name: Import published package
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: dist
|
||||
path: ${{ inputs.working-directory }}/dist/
|
||||
|
||||
- name: Import dist package
|
||||
shell: bash
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
env:
|
||||
PKG_NAME: ${{ needs.build.outputs.pkg-name }}
|
||||
VERSION: ${{ needs.build.outputs.version }}
|
||||
# Here we use:
|
||||
# - The default regular PyPI index as the *primary* index, meaning
|
||||
# that it takes priority (https://pypi.org/simple)
|
||||
# - The test PyPI index as an extra index, so that any dependencies that
|
||||
# are not found on test PyPI can be resolved and installed anyway.
|
||||
# (https://test.pypi.org/simple). This will include the PKG_NAME==VERSION
|
||||
# package because VERSION will not have been uploaded to regular PyPI yet.
|
||||
# - attempt install again after 5 seconds if it fails because there is
|
||||
# sometimes a delay in availability on test pypi
|
||||
# Install directly from the locally-built wheel (no index resolution needed).
|
||||
run: |
|
||||
uv run pip install \
|
||||
--extra-index-url https://test.pypi.org/simple/ \
|
||||
"$PKG_NAME==$VERSION" || \
|
||||
( \
|
||||
sleep 5 && \
|
||||
uv run pip install \
|
||||
--extra-index-url https://test.pypi.org/simple/ \
|
||||
"$PKG_NAME==$VERSION" \
|
||||
)
|
||||
uv run pip install dist/*.whl
|
||||
|
||||
if [[ "$PKG_NAME" == *prebuilt* ]]; then
|
||||
uv run pip install langgraph
|
||||
@@ -226,7 +202,7 @@ jobs:
|
||||
run: uv sync --group test
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
|
||||
# Overwrite the local version of the package with the test PyPI version.
|
||||
# Overwrite the local version of the package with the built version
|
||||
- name: Import published package (again)
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
shell: bash
|
||||
@@ -234,14 +210,25 @@ jobs:
|
||||
PKG_NAME: ${{ needs.build.outputs.pkg-name }}
|
||||
VERSION: ${{ needs.build.outputs.version }}
|
||||
run: |
|
||||
uv run pip install \
|
||||
--extra-index-url https://test.pypi.org/simple/ \
|
||||
"$PKG_NAME==$VERSION"
|
||||
uv run pip install dist/*.whl
|
||||
|
||||
- name: Run unit tests
|
||||
run: make test
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
|
||||
test-pypi-publish:
|
||||
needs:
|
||||
- build
|
||||
- release-notes
|
||||
- pre-release-checks
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
uses: ./.github/workflows/_test_release.yml
|
||||
with:
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
secrets: inherit
|
||||
|
||||
publish:
|
||||
needs:
|
||||
- build
|
||||
@@ -262,7 +249,7 @@ jobs:
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Set up Python
|
||||
uses: ./.github/actions/uv_setup
|
||||
@@ -278,7 +265,7 @@ jobs:
|
||||
path: ${{ inputs.working-directory }}/dist/
|
||||
|
||||
- name: Publish package distributions to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
|
||||
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1
|
||||
with:
|
||||
packages-dir: ${{ inputs.working-directory }}/dist/
|
||||
verbose: true
|
||||
@@ -304,7 +291,7 @@ jobs:
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Set up Python
|
||||
uses: ./.github/actions/uv_setup
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Set up uv
|
||||
uses: ./.github/actions/uv_setup
|
||||
|
||||
@@ -76,7 +76,12 @@ __pypackages__/
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.env.*
|
||||
.envrc
|
||||
*.crt
|
||||
*.key
|
||||
*.pem
|
||||
credentials.json
|
||||
.venv
|
||||
.venvs
|
||||
env/
|
||||
@@ -98,6 +103,7 @@ dmypy.json
|
||||
|
||||
.vercel
|
||||
.turbo
|
||||
node_modules/
|
||||
.editorconfig
|
||||
.scratch
|
||||
.worktrees/
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
This repository is a monorepo. Each library lives in a subdirectory under `libs/`.
|
||||
|
||||
<corridor>
|
||||
|
||||
## Corridor security analysis
|
||||
|
||||
When Corridor's `analyzePlan` tool is available, create a plan and use the tool to analyze it before generating or modifying code. Apply the resulting security guidance before writing code.
|
||||
|
||||
</corridor>
|
||||
|
||||
When you modify code in any library, run the following commands in that library's directory before creating a pull request:
|
||||
|
||||
- `make format` – run code formatters
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
# AGENTS Instructions
|
||||
|
||||
This repository is a monorepo. Each library lives in a subdirectory under `libs/`.
|
||||
|
||||
When you modify code in any library, run the following commands in that library's directory before creating a pull request:
|
||||
|
||||
- `make format` – run code formatters
|
||||
- `make lint` – run the linter
|
||||
- `make test` – execute the test suite
|
||||
|
||||
To run a particular test file or to pass additional pytest options you can specify the `TEST` variable:
|
||||
|
||||
```
|
||||
TEST=path/to/test.py make test
|
||||
```
|
||||
|
||||
Other pytest arguments can also be supplied inside the `TEST` variable.
|
||||
|
||||
## Libraries
|
||||
|
||||
The repository contains several Python and JavaScript/TypeScript libraries.
|
||||
Below is a high-level overview:
|
||||
|
||||
- **checkpoint** – base interfaces for LangGraph checkpointers.
|
||||
- **checkpoint-postgres** – Postgres implementation of the checkpoint saver.
|
||||
- **checkpoint-sqlite** – SQLite implementation of the checkpoint saver.
|
||||
- **cli** – official command-line interface for LangGraph.
|
||||
- **langgraph** – core framework for building stateful, multi-actor agents.
|
||||
- **prebuilt** – high-level APIs for creating and running agents and tools.
|
||||
- **sdk-js** – JS/TS SDK for interacting with the LangGraph REST API.
|
||||
- **sdk-py** – Python SDK for the LangGraph Server API.
|
||||
|
||||
### Dependency map
|
||||
|
||||
The diagram below lists downstream libraries for each production dependency as
|
||||
declared in that library's `pyproject.toml` (or `package.json`).
|
||||
|
||||
```text
|
||||
checkpoint
|
||||
├── checkpoint-postgres
|
||||
├── checkpoint-sqlite
|
||||
├── prebuilt
|
||||
└── langgraph
|
||||
|
||||
prebuilt
|
||||
└── langgraph
|
||||
|
||||
sdk-py
|
||||
├── langgraph
|
||||
└── cli
|
||||
|
||||
sdk-js (standalone)
|
||||
```
|
||||
|
||||
Changes to a library may impact all of its dependents shown above.
|
||||
|
||||
- Do NOT use Sphinx-style double backtick formatting (` ``code`` `). Use single backticks (`` `code` ``) for inline code references in docstrings and comments.
|
||||
@@ -12,13 +12,26 @@ 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 http.client
|
||||
import json
|
||||
import os
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
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"
|
||||
|
||||
# The docs site regenerates this index on every deploy, so fetching it here
|
||||
# keeps the published llms.txt from drifting. The URL is a hardcoded constant,
|
||||
# never built from input, and both it and the post-redirect URL are checked
|
||||
# against ALLOWED_LLMS_HOST before anything is read.
|
||||
CANONICAL_LLMS_URL = "https://docs.langchain.com/oss/python/langgraph/llms.txt"
|
||||
ALLOWED_LLMS_HOST = "docs.langchain.com"
|
||||
LLMS_FETCH_TIMEOUT = 30
|
||||
LLMS_MAX_BYTES = 1_000_000
|
||||
|
||||
HTML_TEMPLATE = """<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -75,6 +88,64 @@ CATCHALL_404_TEMPLATE = """<!doctype html>
|
||||
"""
|
||||
|
||||
|
||||
def is_allowed_llms_url(url):
|
||||
"""Return True if url is HTTPS on the one host we accept content from."""
|
||||
parsed = urllib.parse.urlsplit(url)
|
||||
return parsed.scheme == "https" and parsed.hostname == ALLOWED_LLMS_HOST
|
||||
|
||||
|
||||
def fetch_canonical_llms_txt():
|
||||
"""Return the published LangGraph index, or None if it cannot be used.
|
||||
|
||||
Returning None leaves the caller on the committed docs/llms.txt, so a
|
||||
docs.langchain.com outage degrades to a stale file rather than a broken
|
||||
deploy or a published error page.
|
||||
"""
|
||||
if not is_allowed_llms_url(CANONICAL_LLMS_URL):
|
||||
print(f"Refusing to fetch {CANONICAL_LLMS_URL}: host not allowed")
|
||||
return None
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen( # noqa: S310 - constant, allowlisted URL
|
||||
CANONICAL_LLMS_URL, timeout=LLMS_FETCH_TIMEOUT
|
||||
) as response:
|
||||
# urlopen follows redirects, so re-check where it actually landed.
|
||||
if not is_allowed_llms_url(response.url):
|
||||
print(f"Refusing {CANONICAL_LLMS_URL}: redirected to {response.url}")
|
||||
return None
|
||||
body = response.read(LLMS_MAX_BYTES + 1)
|
||||
# A connection dropped mid-body raises http.client.IncompleteRead, which
|
||||
# descends from HTTPException rather than OSError, so catching only the
|
||||
# urllib and OS errors would let it escape and fail the whole deploy.
|
||||
except (
|
||||
urllib.error.URLError,
|
||||
http.client.HTTPException,
|
||||
TimeoutError,
|
||||
OSError,
|
||||
) as exc:
|
||||
print(f"Could not fetch {CANONICAL_LLMS_URL}: {type(exc).__name__}: {exc}")
|
||||
return None
|
||||
|
||||
if len(body) > LLMS_MAX_BYTES:
|
||||
print(f"Refusing {CANONICAL_LLMS_URL}: larger than {LLMS_MAX_BYTES} bytes")
|
||||
return None
|
||||
|
||||
try:
|
||||
text = body.decode("utf-8")
|
||||
except UnicodeDecodeError as exc:
|
||||
print(f"Refusing {CANONICAL_LLMS_URL}: not valid UTF-8: {exc}")
|
||||
return None
|
||||
|
||||
# An index opens with a markdown heading and links to the docs site. A
|
||||
# body that does not is an error page or a truncated response, not content
|
||||
# worth publishing.
|
||||
if not text.startswith("# ") or f"https://{ALLOWED_LLMS_HOST}/" not in text:
|
||||
print(f"Refusing {CANONICAL_LLMS_URL}: does not look like an llms.txt index")
|
||||
return None
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def generate_redirects():
|
||||
script_dir = Path(__file__).parent
|
||||
output_dir = script_dir / "_site"
|
||||
@@ -126,14 +197,19 @@ def generate_redirects():
|
||||
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
|
||||
# llms.txt can't be redirected via HTML, so publish the docs site's own
|
||||
# generated index. The committed copy is only a fallback.
|
||||
llms_txt = fetch_canonical_llms_txt()
|
||||
if llms_txt is not None:
|
||||
(output_dir / "llms.txt").write_text(llms_txt)
|
||||
print(f"Fetched: {output_dir / 'llms.txt'} (from {CANONICAL_LLMS_URL})")
|
||||
else:
|
||||
src = script_dir / "llms.txt"
|
||||
if src.exists():
|
||||
dst = output_dir / static_file
|
||||
dst.write_text(src.read_text())
|
||||
print(f"Copied: {dst}")
|
||||
(output_dir / "llms.txt").write_text(src.read_text())
|
||||
print(f"Copied: {output_dir / 'llms.txt'} (fallback, may be stale)")
|
||||
else:
|
||||
print("No llms.txt fetched and no committed fallback; skipping")
|
||||
|
||||
print(f"\nGenerated {len(redirects)} redirect files in {output_dir}")
|
||||
|
||||
|
||||
+46
-32
@@ -1,35 +1,49 @@
|
||||
# LangGraph
|
||||
# Docs by LangChain: LangGraph (Python)
|
||||
|
||||
LangGraph documentation has moved to docs.langchain.com.
|
||||
> Markdown index of the LangGraph (Python) documentation.
|
||||
|
||||
## Overview
|
||||
## LangGraph (Python)
|
||||
|
||||
- [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).
|
||||
- [Memory](https://docs.langchain.com/oss/python/langgraph/add-memory.md)
|
||||
- [Build a custom RAG agent with LangGraph](https://docs.langchain.com/oss/python/langgraph/agentic-rag.md)
|
||||
- [Application structure](https://docs.langchain.com/oss/python/langgraph/application-structure.md)
|
||||
- [Backward compatibility](https://docs.langchain.com/oss/python/langgraph/backward-compatibility.md)
|
||||
- [Case studies](https://docs.langchain.com/oss/python/langgraph/case-studies.md)
|
||||
- [Changelog](https://docs.langchain.com/oss/python/langgraph/changelog-js.md)
|
||||
- [Changelog](https://docs.langchain.com/oss/python/langgraph/changelog-py.md)
|
||||
- [Checkpointers](https://docs.langchain.com/oss/python/langgraph/checkpointers.md)
|
||||
- [Choosing between Graph and Functional APIs](https://docs.langchain.com/oss/python/langgraph/choosing-apis.md)
|
||||
- [Deployment](https://docs.langchain.com/oss/python/langgraph/deploy.md)
|
||||
- [GRAPH_RECURSION_LIMIT](https://docs.langchain.com/oss/python/langgraph/errors/GRAPH_RECURSION_LIMIT.md)
|
||||
- [INVALID_CHAT_HISTORY](https://docs.langchain.com/oss/python/langgraph/errors/INVALID_CHAT_HISTORY.md)
|
||||
- [INVALID_CONCURRENT_GRAPH_UPDATE](https://docs.langchain.com/oss/python/langgraph/errors/INVALID_CONCURRENT_GRAPH_UPDATE.md)
|
||||
- [INVALID_GRAPH_NODE_RETURN_VALUE](https://docs.langchain.com/oss/python/langgraph/errors/INVALID_GRAPH_NODE_RETURN_VALUE.md)
|
||||
- [MISSING_CHECKPOINTER](https://docs.langchain.com/oss/python/langgraph/errors/MISSING_CHECKPOINTER.md)
|
||||
- [MULTIPLE_SUBGRAPHS](https://docs.langchain.com/oss/python/langgraph/errors/MULTIPLE_SUBGRAPHS.md)
|
||||
- [Event streaming](https://docs.langchain.com/oss/python/langgraph/event-streaming.md)
|
||||
- [Fault tolerance](https://docs.langchain.com/oss/python/langgraph/fault-tolerance.md)
|
||||
- [Custom stream channels](https://docs.langchain.com/oss/python/langgraph/frontend/custom-stream-channels.md)
|
||||
- [Graph execution](https://docs.langchain.com/oss/python/langgraph/frontend/graph-execution.md)
|
||||
- [Overview](https://docs.langchain.com/oss/python/langgraph/frontend/overview.md)
|
||||
- [Functional API overview](https://docs.langchain.com/oss/python/langgraph/functional-api.md)
|
||||
- [Graph API overview](https://docs.langchain.com/oss/python/langgraph/graph-api.md)
|
||||
- [Install LangGraph](https://docs.langchain.com/oss/python/langgraph/install.md)
|
||||
- [Interrupts](https://docs.langchain.com/oss/python/langgraph/interrupts.md)
|
||||
- [Run a local server](https://docs.langchain.com/oss/python/langgraph/local-server.md)
|
||||
- [LangSmith Observability](https://docs.langchain.com/oss/python/langgraph/observability.md)
|
||||
- [LangGraph overview](https://docs.langchain.com/oss/python/langgraph/overview.md)
|
||||
- [Persistence](https://docs.langchain.com/oss/python/langgraph/persistence.md)
|
||||
- [LangGraph runtime](https://docs.langchain.com/oss/python/langgraph/pregel.md)
|
||||
- [Quickstart](https://docs.langchain.com/oss/python/langgraph/quickstart.md)
|
||||
- [Build a custom SQL agent](https://docs.langchain.com/oss/python/langgraph/sql-agent.md)
|
||||
- [Stores](https://docs.langchain.com/oss/python/langgraph/stores.md)
|
||||
- [Streaming](https://docs.langchain.com/oss/python/langgraph/streaming.md)
|
||||
- [LangSmith Studio](https://docs.langchain.com/oss/python/langgraph/studio.md)
|
||||
- [Test](https://docs.langchain.com/oss/python/langgraph/test.md)
|
||||
- [Thinking in LangGraph](https://docs.langchain.com/oss/python/langgraph/thinking-in-langgraph.md)
|
||||
- [Agent Chat UI](https://docs.langchain.com/oss/python/langgraph/ui.md)
|
||||
- [Use the functional API](https://docs.langchain.com/oss/python/langgraph/use-functional-api.md)
|
||||
- [Use the graph API](https://docs.langchain.com/oss/python/langgraph/use-graph-api.md)
|
||||
- [Subgraphs](https://docs.langchain.com/oss/python/langgraph/use-subgraphs.md)
|
||||
- [Use time-travel](https://docs.langchain.com/oss/python/langgraph/use-time-travel.md)
|
||||
- [Workflows and agents](https://docs.langchain.com/oss/python/langgraph/workflows-agents.md)
|
||||
|
||||
+3
-18
@@ -54,12 +54,6 @@ async def build_delta_chain(
|
||||
stored: list[RunnableConfig] = []
|
||||
parent_cfg: RunnableConfig | None = None
|
||||
|
||||
# Track supersteps since the channel's last snapshot, mirroring Pregel's
|
||||
# `counters_since_delta_snapshot` bookkeeping: every step increments the
|
||||
# counter; a snapshot step resets it (and carries no counter entry, so
|
||||
# the walk locates the seed exactly `supersteps` hops back).
|
||||
supersteps_since_snapshot = 0
|
||||
|
||||
for step in range(total_steps):
|
||||
config: RunnableConfig = {
|
||||
"configurable": {
|
||||
@@ -74,19 +68,11 @@ async def build_delta_chain(
|
||||
|
||||
channel_values: dict[str, Any] = {}
|
||||
channel_versions: dict[str, int] = {}
|
||||
supersteps_since_snapshot += 1
|
||||
counters: dict[str, tuple[int, int]] = {}
|
||||
if step in snapshot_set:
|
||||
channel_values[channel] = _DeltaSnapshot(
|
||||
write_value_fn(step),
|
||||
)
|
||||
channel_versions[channel] = step + 1
|
||||
supersteps_since_snapshot = 0
|
||||
else:
|
||||
counters[channel] = (
|
||||
supersteps_since_snapshot,
|
||||
supersteps_since_snapshot,
|
||||
)
|
||||
|
||||
cp = Checkpoint(
|
||||
v=1,
|
||||
@@ -98,10 +84,9 @@ async def build_delta_chain(
|
||||
updated_channels=None,
|
||||
)
|
||||
new_versions = dict(channel_versions)
|
||||
md = generate_metadata(step=step)
|
||||
if counters:
|
||||
md["counters_since_delta_snapshot"] = counters
|
||||
parent_cfg = await saver.aput(config, cp, md, new_versions)
|
||||
parent_cfg = await saver.aput(
|
||||
config, cp, generate_metadata(step=step), new_versions
|
||||
)
|
||||
stored.append(parent_cfg)
|
||||
|
||||
# Write a pending write for non-snapshot steps so the walk has
|
||||
|
||||
+98
-71
@@ -6,9 +6,12 @@ import traceback
|
||||
from collections.abc import Callable
|
||||
from uuid import uuid4
|
||||
|
||||
from langgraph.checkpoint.base import BaseCheckpointSaver
|
||||
from langgraph.checkpoint.base import BaseCheckpointSaver, Checkpoint
|
||||
from langgraph.checkpoint.base.id import uuid6
|
||||
from langgraph.checkpoint.serde.types import _DeltaSnapshot
|
||||
|
||||
from langgraph.checkpoint.conformance.spec._delta_fixtures import build_delta_chain
|
||||
from langgraph.checkpoint.conformance.test_utils import generate_metadata
|
||||
|
||||
|
||||
async def test_history_returns_writes_oldest_first(
|
||||
@@ -48,8 +51,6 @@ async def test_history_seed_is_nearest_snapshot(
|
||||
result = await saver.aget_delta_channel_history(config=head, channels=["ch"])
|
||||
assert "seed" in result["ch"], "Expected seed from snapshot at step 3"
|
||||
seed = result["ch"]["seed"]
|
||||
from langgraph.checkpoint.serde.types import _DeltaSnapshot
|
||||
|
||||
actual_value = seed.value if isinstance(seed, _DeltaSnapshot) else seed
|
||||
assert actual_value == 3, f"Expected seed value 3 (step 3), got {actual_value}"
|
||||
writes = result["ch"]["writes"]
|
||||
@@ -81,16 +82,7 @@ async def test_history_multi_channel(
|
||||
tid = str(uuid4())
|
||||
configs: list = []
|
||||
parent_cfg = None
|
||||
from langgraph.checkpoint.base import Checkpoint
|
||||
from langgraph.checkpoint.base.id import uuid6
|
||||
from langgraph.checkpoint.serde.types import _DeltaSnapshot
|
||||
|
||||
from langgraph.checkpoint.conformance.test_utils import generate_metadata
|
||||
|
||||
# Per-channel supersteps since last snapshot ("a" snapshots at step 1,
|
||||
# "b" at step 3). Drives the per-channel walk depth from the head.
|
||||
s_a = 0
|
||||
s_b = 0
|
||||
for step in range(5):
|
||||
config = {"configurable": {"thread_id": tid, "checkpoint_ns": ""}}
|
||||
if parent_cfg:
|
||||
@@ -99,21 +91,12 @@ async def test_history_multi_channel(
|
||||
]
|
||||
cv: dict = {}
|
||||
cvs: dict = {}
|
||||
s_a += 1
|
||||
s_b += 1
|
||||
counters: dict = {}
|
||||
if step == 1:
|
||||
cv["a"] = _DeltaSnapshot("snap_a")
|
||||
cvs["a"] = step + 1
|
||||
s_a = 0
|
||||
else:
|
||||
counters["a"] = (s_a, s_a)
|
||||
if step == 3:
|
||||
cv["b"] = _DeltaSnapshot("snap_b")
|
||||
cvs["b"] = step + 1
|
||||
s_b = 0
|
||||
else:
|
||||
counters["b"] = (s_b, s_b)
|
||||
cp = Checkpoint(
|
||||
v=1,
|
||||
id=str(uuid6(clock_seq=-1)),
|
||||
@@ -123,10 +106,7 @@ async def test_history_multi_channel(
|
||||
versions_seen={},
|
||||
updated_channels=None,
|
||||
)
|
||||
md = generate_metadata(step=step)
|
||||
if counters:
|
||||
md["counters_since_delta_snapshot"] = counters
|
||||
parent_cfg = await saver.aput(config, cp, md, cvs)
|
||||
parent_cfg = await saver.aput(config, cp, generate_metadata(step=step), cvs)
|
||||
configs.append(parent_cfg)
|
||||
await saver.aput_writes(parent_cfg, [("a", step), ("b", step)], str(uuid4()))
|
||||
|
||||
@@ -164,12 +144,7 @@ async def test_history_walk_to_root_no_seed(
|
||||
)
|
||||
head = configs[-1]
|
||||
result = await saver.aget_delta_channel_history(config=head, channels=["ch"])
|
||||
# The head's supersteps counter (4) runs one hop past the real root —
|
||||
# the snapshot checkpoint was never persisted (implicit empty baseline).
|
||||
# No seed, and the full chain (steps 0..2) replays from empty.
|
||||
assert "seed" not in result["ch"], f"Expected no seed, got {result['ch']}"
|
||||
values = [w[2] for w in result["ch"]["writes"]]
|
||||
assert values == [0, 1, 2], f"Expected full-chain writes [0,1,2], got {values}"
|
||||
|
||||
|
||||
async def test_history_migration_plain_value_as_seed(
|
||||
@@ -182,19 +157,10 @@ async def test_history_migration_plain_value_as_seed(
|
||||
channel_values[ch] (not a _DeltaSnapshot). The walk should treat it as the
|
||||
seed and terminate there.
|
||||
"""
|
||||
from langgraph.checkpoint.base import Checkpoint
|
||||
from langgraph.checkpoint.base.id import uuid6
|
||||
|
||||
from langgraph.checkpoint.conformance.test_utils import generate_metadata
|
||||
|
||||
tid = str(uuid4())
|
||||
configs: list = []
|
||||
parent_cfg = None
|
||||
|
||||
# The channel was a non-delta channel through step 1 (plain value, no
|
||||
# delta counter), then migrated to DeltaChannel at step 2. Supersteps
|
||||
# count from the migration boundary: step 2 -> 1, step 3 -> 2, so the
|
||||
# head (step 3) walks 2 hops back to the plain-value seed at step 1.
|
||||
for step in range(4):
|
||||
config = {"configurable": {"thread_id": tid, "checkpoint_ns": ""}}
|
||||
if parent_cfg:
|
||||
@@ -207,9 +173,6 @@ async def test_history_migration_plain_value_as_seed(
|
||||
if step == 1:
|
||||
cv["ch"] = [10, 20, 30]
|
||||
cvs["ch"] = step + 1
|
||||
md = generate_metadata(step=step)
|
||||
if step >= 2:
|
||||
md["counters_since_delta_snapshot"] = {"ch": (step - 1, step - 1)}
|
||||
cp = Checkpoint(
|
||||
v=1,
|
||||
id=str(uuid6(clock_seq=-1)),
|
||||
@@ -219,7 +182,7 @@ async def test_history_migration_plain_value_as_seed(
|
||||
versions_seen={},
|
||||
updated_channels=None,
|
||||
)
|
||||
parent_cfg = await saver.aput(config, cp, md, cvs)
|
||||
parent_cfg = await saver.aput(config, cp, generate_metadata(step=step), cvs)
|
||||
configs.append(parent_cfg)
|
||||
if step != 1:
|
||||
await saver.aput_writes(parent_cfg, [("ch", step)], str(uuid4()))
|
||||
@@ -236,26 +199,35 @@ async def test_history_migration_plain_value_as_seed(
|
||||
assert values == [2], f"Expected [2], got {values}"
|
||||
|
||||
|
||||
async def test_history_migration_skips_seed_checkpoint_writes(
|
||||
async def test_history_seed_ancestor_own_writes_are_replayed(
|
||||
saver: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""A migrated plain-value seed already incorporates its own checkpoint's
|
||||
writes, so those writes must NOT be re-emitted in the history.
|
||||
"""Writes stored AT the seed ancestor must be included in `writes`.
|
||||
|
||||
This is the discriminating case for non-additive reducers (e.g. an
|
||||
even-only filter): re-applying the seed checkpoint's writes on top of
|
||||
the seed would corrupt the reconstructed value. The saver contract is
|
||||
to skip them — only writes strictly after the seed are returned.
|
||||
A stored value is the state ENTERING its checkpoint; the writes stored
|
||||
under that same checkpoint are what produced its child and are therefore
|
||||
NOT subsumed by it. Only writes at ancestors OLDER than the seed are
|
||||
subsumed, and the walk terminates before reaching them.
|
||||
|
||||
This holds for plain-value seeds (migration from a pre-delta channel type)
|
||||
exactly as it does for `_DeltaSnapshot` seeds. Skipping the seed
|
||||
ancestor's own writes silently drops the first post-migration write.
|
||||
"""
|
||||
from langgraph.checkpoint.base import Checkpoint
|
||||
from langgraph.checkpoint.base.id import uuid6
|
||||
|
||||
from langgraph.checkpoint.conformance.test_utils import generate_metadata
|
||||
|
||||
tid = str(uuid4())
|
||||
configs: list = []
|
||||
parent_cfg = None
|
||||
|
||||
# Each step's write is labelled by the role it plays, so the assertion
|
||||
# below reads directly rather than by step index.
|
||||
writes_by_step = {
|
||||
0: "older-than-seed", # subsumed by the value stored at step 1
|
||||
1: "at-seed", # the seed's own write, produced step 2
|
||||
2: "after-seed", # delta-era write on the path to the head
|
||||
3: "pending-at-head", # pending for the next step, never replayed
|
||||
}
|
||||
# Steps 0 and 1 store a plain value; 1 is the nearest, so it is the seed.
|
||||
values_by_step = {0: [10], 1: [10, 20]}
|
||||
|
||||
for step in range(4):
|
||||
config = {"configurable": {"thread_id": tid, "checkpoint_ns": ""}}
|
||||
if parent_cfg:
|
||||
@@ -264,13 +236,9 @@ async def test_history_migration_skips_seed_checkpoint_writes(
|
||||
]
|
||||
cv: dict = {}
|
||||
cvs: dict = {}
|
||||
# Step 1: pre-delta plain accumulated value AND its own writes.
|
||||
if step == 1:
|
||||
cv["ch"] = [2, 4]
|
||||
if step in values_by_step:
|
||||
cv["ch"] = values_by_step[step]
|
||||
cvs["ch"] = step + 1
|
||||
md = generate_metadata(step=step)
|
||||
if step >= 2:
|
||||
md["counters_since_delta_snapshot"] = {"ch": (step - 1, step - 1)}
|
||||
cp = Checkpoint(
|
||||
v=1,
|
||||
id=str(uuid6(clock_seq=-1)),
|
||||
@@ -280,21 +248,78 @@ async def test_history_migration_skips_seed_checkpoint_writes(
|
||||
versions_seen={},
|
||||
updated_channels=None,
|
||||
)
|
||||
parent_cfg = await saver.aput(config, cp, md, cvs)
|
||||
parent_cfg = await saver.aput(config, cp, generate_metadata(step=step), cvs)
|
||||
configs.append(parent_cfg)
|
||||
# The seed checkpoint (step 1) carries writes that the plain value
|
||||
# already subsumes; later steps carry post-migration delta writes.
|
||||
write_value = 99 if step == 1 else step * 10
|
||||
await saver.aput_writes(parent_cfg, [("ch", write_value)], str(uuid4()))
|
||||
await saver.aput_writes(
|
||||
parent_cfg, [("ch", writes_by_step[step])], str(uuid4())
|
||||
)
|
||||
|
||||
head = configs[-1]
|
||||
result = await saver.aget_delta_channel_history(config=head, channels=["ch"])
|
||||
assert result["ch"].get("seed") == [2, 4], (
|
||||
f"Expected plain seed [2, 4], got {result['ch'].get('seed')}"
|
||||
|
||||
assert "seed" in result["ch"], "Expected seed from plain value at step 1"
|
||||
assert result["ch"]["seed"] == [10, 20], (
|
||||
f"Expected nearest plain value [10, 20], got {result['ch']['seed']}"
|
||||
)
|
||||
values = [w[2] for w in result["ch"]["writes"]]
|
||||
# Seed checkpoint's own write (99) is skipped; step 2's write (20) kept.
|
||||
assert values == [20], f"Expected only post-seed writes [20], got {values}"
|
||||
assert values == ["at-seed", "after-seed"], (
|
||||
f'Expected ["at-seed", "after-seed"], got {values}'
|
||||
)
|
||||
|
||||
|
||||
# Every uuid4 `build_delta_chain` tags its own writes with sorts between these
|
||||
# two, so task_id order is fixed and always disagrees with task_path order.
|
||||
TASK_ID_SORTS_FIRST = "00000000-0000-0000-0000-000000000000"
|
||||
TASK_ID_SORTS_LAST = "ffffffff-ffff-ffff-ffff-ffffffffffff"
|
||||
|
||||
|
||||
async def test_history_orders_parallel_writes_by_task_path(
|
||||
saver: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Writes from parallel tasks replay in task_path order, not task_id order."""
|
||||
configs = await build_delta_chain(
|
||||
saver,
|
||||
thread_id=str(uuid4()),
|
||||
channel="ch",
|
||||
snapshots_at_steps=[0],
|
||||
total_steps=3,
|
||||
)
|
||||
step_1, head = configs[1], configs[2]
|
||||
await saver.aput_writes(
|
||||
step_1, [("ch", "second")], TASK_ID_SORTS_FIRST, "~pull, 02"
|
||||
)
|
||||
await saver.aput_writes(step_1, [("ch", "first")], TASK_ID_SORTS_LAST, "~pull, 01")
|
||||
|
||||
result = await saver.aget_delta_channel_history(config=head, channels=["ch"])
|
||||
values = [w[2] for w in result["ch"]["writes"]]
|
||||
assert values == [1, "first", "second"], (
|
||||
f"Expected task_path order [1, 'first', 'second'], got {values}. "
|
||||
"Ordering by (task_id, idx) alone yields [1, 'second', 'first']."
|
||||
)
|
||||
|
||||
|
||||
async def test_history_orders_pathless_writes_first(
|
||||
saver: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Writes stored without a task_path (graph input) replay before task writes."""
|
||||
configs = await build_delta_chain(
|
||||
saver,
|
||||
thread_id=str(uuid4()),
|
||||
channel="ch",
|
||||
snapshots_at_steps=[0],
|
||||
total_steps=3,
|
||||
)
|
||||
step_1, head = configs[1], configs[2]
|
||||
await saver.aput_writes(
|
||||
step_1, [("ch", "from_node")], TASK_ID_SORTS_FIRST, "~pull, a"
|
||||
)
|
||||
await saver.aput_writes(step_1, [("ch", "from_input")], TASK_ID_SORTS_LAST)
|
||||
|
||||
result = await saver.aget_delta_channel_history(config=head, channels=["ch"])
|
||||
values = [w[2] for w in result["ch"]["writes"]]
|
||||
assert values == [1, "from_input", "from_node"], (
|
||||
f"Expected pathless writes first, got {values}"
|
||||
)
|
||||
|
||||
|
||||
ALL_DELTA_CHANNEL_HISTORY_TESTS = [
|
||||
@@ -305,7 +330,9 @@ ALL_DELTA_CHANNEL_HISTORY_TESTS = [
|
||||
test_history_empty_channels_returns_empty,
|
||||
test_history_walk_to_root_no_seed,
|
||||
test_history_migration_plain_value_as_seed,
|
||||
test_history_migration_skips_seed_checkpoint_writes,
|
||||
test_history_seed_ancestor_own_writes_are_replayed,
|
||||
test_history_orders_parallel_writes_by_task_path,
|
||||
test_history_orders_pathless_writes_first,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -176,6 +176,35 @@ async def test_get_tuple_pending_writes(saver: BaseCheckpointSaver) -> None:
|
||||
)
|
||||
|
||||
|
||||
async def test_get_tuple_pending_writes_in_writes_sort_key_order(
|
||||
saver: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""pending_writes come back in writes_sort_key order, not put or task_id order."""
|
||||
config = generate_config(str(uuid4()))
|
||||
stored = await saver.aput(config, generate_checkpoint(), generate_metadata(), {})
|
||||
await saver.aput_writes(
|
||||
stored, [("ch", "b")], "00000000-0000-0000-0000-000000000000", "~pull, 02"
|
||||
)
|
||||
await saver.aput_writes(
|
||||
stored,
|
||||
[("ch", "a1"), ("ch", "a2")],
|
||||
"ffffffff-ffff-ffff-ffff-ffffffffffff",
|
||||
"~pull, 01",
|
||||
)
|
||||
await saver.aput_writes(
|
||||
stored, [("ch", "input")], "88888888-8888-8888-8888-888888888888"
|
||||
)
|
||||
|
||||
tup = await saver.aget_tuple(stored)
|
||||
assert tup is not None
|
||||
values = [w[2] for w in tup.pending_writes or []]
|
||||
assert values == ["input", "a1", "a2", "b"], (
|
||||
f"Expected writes_sort_key order ['input', 'a1', 'a2', 'b'], got {values}. "
|
||||
"Put order gives ['b', 'a1', 'a2', 'input'], task_id order gives "
|
||||
"['b', 'input', 'a1', 'a2']."
|
||||
)
|
||||
|
||||
|
||||
async def test_get_tuple_respects_namespace(saver: BaseCheckpointSaver) -> None:
|
||||
"""checkpoint_ns filtering."""
|
||||
tid = str(uuid4())
|
||||
@@ -223,6 +252,7 @@ ALL_GET_TUPLE_TESTS = [
|
||||
test_get_tuple_metadata,
|
||||
test_get_tuple_parent_config,
|
||||
test_get_tuple_pending_writes,
|
||||
test_get_tuple_pending_writes_in_writes_sort_key_order,
|
||||
test_get_tuple_respects_namespace,
|
||||
test_get_tuple_nonexistent_checkpoint_id,
|
||||
]
|
||||
|
||||
@@ -58,8 +58,14 @@ lint.select = [
|
||||
"UP", # pyupgrade
|
||||
"B", # flake8-bugbear
|
||||
"I", # isort
|
||||
"PLC0415", # import-outside-top-level
|
||||
"RUF100", # unused noqa directive
|
||||
]
|
||||
lint.ignore = ["E501", "B008"]
|
||||
# PLC0415 (import-outside-top-level) is enforced in tests only. Library code
|
||||
# still has deferred imports that have not been reviewed, so it stays exempt
|
||||
# for now.
|
||||
lint.per-file-ignores = { "langgraph/**" = ["PLC0415"] }
|
||||
target-version = "py310"
|
||||
|
||||
[tool.uv.sources]
|
||||
|
||||
@@ -14,8 +14,8 @@ async def memory_checkpointer():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_memory_base():
|
||||
"""InMemorySaver passes all base capability tests."""
|
||||
async def test_validate_memory():
|
||||
"""InMemorySaver passes the tests of every capability it implements."""
|
||||
report = await validate(memory_checkpointer)
|
||||
report.print_report()
|
||||
assert report.passed_all_base(), f"Base tests failed: {report.to_dict()}"
|
||||
assert report.passed_all(), f"Capability tests failed: {report.to_dict()}"
|
||||
|
||||
Generated
+55
-55
@@ -13,16 +13,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.13.0"
|
||||
version = "4.14.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
||||
{ name = "idna" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -162,7 +162,7 @@ name = "exceptiongroup"
|
||||
version = "1.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
|
||||
wheels = [
|
||||
@@ -247,7 +247,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langchain-core"
|
||||
version = "1.4.0"
|
||||
version = "1.5.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jsonpatch" },
|
||||
@@ -260,26 +260,26 @@ dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "uuid-utils" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/59/de/679a53472c25860837e32c0442c962fa86e95317a36460e2c9d5c91b17c2/langchain_core-1.4.0.tar.gz", hash = "sha256:1dc341eed802ed9c117c0df3923c991e5e9e226571e5725c194eeb5bd93d1a7f", size = 920260, upload-time = "2026-05-11T18:42:35.919Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6e/58/3ad53096eee1e07728e8219ded30ae308b0f2b8b7b26b8ebb6371917c0f5/langchain_core-1.5.2.tar.gz", hash = "sha256:2d13ab35b42eec63d4669a483776b8cdd778ee764107149369fb369d84c08c41", size = 972322, upload-time = "2026-07-28T16:38:37.977Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/1a/86c38c27b81913a1c6c12448cab55defb5a1097c7dc9a4cea83f55477a2d/langchain_core-1.4.0-py3-none-any.whl", hash = "sha256:23cbbdb46e38ddd1dd5247e6167e96013eae74bea4c5949c550809970a9e565c", size = 548120, upload-time = "2026-05-11T18:42:33.992Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/e4/024e402a65f5ee2eb6ae9667b5ffc5f08776cb4e712f5a11ee1997d56083/langchain_core-1.5.2-py3-none-any.whl", hash = "sha256:a687dd7c3b22c6c1294e1c1eeb61fb6f3a308e6015a1d75b576b3836ad5b5aed", size = 561643, upload-time = "2026-07-28T16:38:36.38Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langchain-protocol"
|
||||
version = "0.0.15"
|
||||
version = "0.0.18"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4f/24/9777489d6fbbee64af0c8f96d4f840239c408cf694f3394672807dafc490/langchain_protocol-0.0.15.tar.gz", hash = "sha256:9ab2d11ee73944754f10e037e717098d3a6796f0e58afa9cadda6154e7655ade", size = 5862, upload-time = "2026-05-01T22:30:04.748Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d2/59/b5959aea96faa9146e2e49a7a22882b3528c62efafe9a6a95beab30c2305/langchain_protocol-0.0.18.tar.gz", hash = "sha256:ec3e11782f1ed0c9db38e5a9ed01b0e7a0d3fba406faa8aef6594b73c56a63e6", size = 6150, upload-time = "2026-06-18T17:08:26.959Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/7a/9c97a7b9cbe4c5dc6a44cdb1545450c28f0c8ce89b9c1f0ee7fbad896263/langchain_protocol-0.0.15-py3-none-any.whl", hash = "sha256:461eb794358f83d5e42635a5797799ffec7b4702314e34edf73ac21e75d3ef79", size = 6982, upload-time = "2026-05-01T22:30:03.877Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/2e/d82db9eec13ad0f72e7aaad5c4bc730ab111934fdc83c85523206eb9b0a0/langchain_protocol-0.0.18-py3-none-any.whl", hash = "sha256:70b53a86fbf9cedc863555effe44da192ab02d556ddbf2cf95b8873adcf41b5a", size = 7221, upload-time = "2026-06-18T17:08:25.996Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-checkpoint"
|
||||
version = "4.1.1"
|
||||
version = "4.3.0"
|
||||
source = { editable = "../checkpoint" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -686,7 +686,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.3"
|
||||
version = "9.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
@@ -697,9 +697,9 @@ dependencies = [
|
||||
{ name = "pygments" },
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -809,27 +809,27 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.15"
|
||||
version = "0.16.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/84/6f/a76f7d96e5c962f5b69cee865e49c15c1116897c01990faa8a57edb62e7f/ruff-0.15.15.tar.gz", hash = "sha256:b8dff018130b46d8e5bf0f926ef6b60cf871d6d5ae45fc9334e09632daa741d6", size = 4706985, upload-time = "2026-05-28T14:16:57.784Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/bf/c935ca98e73fe8ce65b87ef08a280c0c1e85295d569228c15e87d8fdfaf1/ruff-0.16.9.tar.gz", hash = "sha256:12b625c6cfba78d285d9f48eda5f053374f1e53cb10ef17342a383750db99161", size = 4948764, upload-time = "2026-09-24T20:37:49.416Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/9d/3a45c05b8ab04b4705989de70a79008e27c8003296a0feaee9edc18dd7e9/ruff-0.15.15-py3-none-linux_armv6l.whl", hash = "sha256:cf93e5388f412e1b108b1f8b34a6e036b70fe8aff89393befad96fe48670311b", size = 10710652, upload-time = "2026-05-28T14:16:06.701Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/66/da974431624bf3b49f6ee1f9543c02d929ff1cba78b0d5a79c38cf21f744/ruff-0.15.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ac5a646d1f6a7dadd5d50842dae2c1f9862ac887ef5d1b1375e02def791fde6e", size = 11096615, upload-time = "2026-05-28T14:16:23.313Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/09/7443452e5d290230a712103f2fdceeef7184f3ec99a2bd01c8be78aaceb5/ruff-0.15.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:77d955a431430c66f72dd94e379ad38a16daea3d25094872ac4edf9e797be530", size = 10436683, upload-time = "2026-05-28T14:16:40.974Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/01/d330c26a57fa4f3943a14424904027428315b700fe4d14a84bb123a649e5/ruff-0.15.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7614ee79c69788cf6cedd568069ade9cecc22a1ad20494efe8d0c9ebb4b622d4", size = 10769064, upload-time = "2026-05-28T14:16:28.905Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/85/cc8770f8bdff541b1da8392d1634141fe4a0e3f4ee596605959b7906c27f/ruff-0.15.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3cdb1679e06a1f6b47bc384714ae96f6e2fb65ca441eb78c43d2ca554176ce1f", size = 10511987, upload-time = "2026-05-28T14:16:43.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/29/8c190c1472b63013583ba391f3342036e02010544c1270455ed8e519bdf3/ruff-0.15.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2728b93d7b23a603ea2c0ac6eb73d760bd38ec9de35f35fb41e18f7a3fee7622", size = 11275100, upload-time = "2026-05-28T14:16:55.244Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/6b/7e145ce2cc8e63d6834eca03d83a0e18d121def5c69f91b4cf4011ed4879/ruff-0.15.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be582fcc0db438902c7792b08d6ddf6c9b9e21addaa10092c2c741cfb09e5a45", size = 12176903, upload-time = "2026-05-28T14:16:14.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/a3/d5974637f68e451f7fadf015cf3101d1cd7d8ba5027cffe0b9e3826ebe6b/ruff-0.15.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7aa77465b8ecaf1a27bea098d696f7fed5e1eccbd10b321b682d6de586ae5627", size = 11404550, upload-time = "2026-05-28T14:16:20.138Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/1c/e6e5e568f22be4fb05d6244234aba384c06b451252453b821e1a529263cf/ruff-0.15.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48decfa11d740de4889de623be1463308346312f2409a56e24aa280c86162dc4", size = 11382027, upload-time = "2026-05-28T14:16:46.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/01/170921b49fcd2e8858825593f91cf7146c3e40a5c3e6df763e4bb0484dde/ruff-0.15.15-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a5015088452ca0081387063649ec67f06d3d1d6b8b936a1f836b5e9657ecd48c", size = 11366041, upload-time = "2026-05-28T14:16:26.247Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/54/a7bad711d7de93254e15e06a4c375b89a03d18de45d3e5dcc86a4472fb1a/ruff-0.15.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f5294aab6356c81600fcdea3a62bb1b924dfd5e91767c12318d3f68f86af57cd", size = 10741795, upload-time = "2026-05-28T14:16:17.11Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/31/38c075963668f8b41c6914ee0f6f318727fbe30ab9145cb29e6df464c5fa/ruff-0.15.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:db5bd4d802415cca656dc1616070b725952d6ae95eb5d4831e49fbd94a38f75f", size = 10511117, upload-time = "2026-05-28T14:16:31.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/96/6ff689e1f7e375d1d97075eca022f74c2bab59554a432fe4d2e6f091986a/ruff-0.15.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:587a6278ed42059191c1a466e490bd7930fb50bd2e255398bc29616c895a61cb", size = 10994867, upload-time = "2026-05-28T14:16:35.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/c2/5dce0ab9f92a8d534fa62b9bf9caca3eddb8c1a81b616f5e195ada4f0d6e/ruff-0.15.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:df0c1c084f5f4be9812f61518a45c440d3c30d69ce4bf6c5270e66d38338f02a", size = 11482101, upload-time = "2026-05-28T14:16:49.598Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/c0/1003b60edd697c649faf61f1a34094b1abb38fb3d1181e3f895781250a08/ruff-0.15.15-py3-none-win32.whl", hash = "sha256:29428ea79694afbe756d45fd59b36f22b6b020dc0443cf7de0173046236964b9", size = 10716774, upload-time = "2026-05-28T14:16:52.337Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/a8/1269eddd6945a06c23f055ef7848886e37cf9d6a8bebb386a3115f01470c/ruff-0.15.15-py3-none-win_amd64.whl", hash = "sha256:8df0323902e15e24bc4bf246da830573d3cf3352bd0b9a164eab335d111ff4a4", size = 11868463, upload-time = "2026-05-28T14:16:11.333Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/b2/920464c907b191e37469d477a1aa8bc048b8f36c4c1610dfa4ab87b39e18/ruff-0.15.15-py3-none-win_arm64.whl", hash = "sha256:3c8ceca6792f38196b8f589bc92eccd03eef286602da92e5dc05cc42ef6441b7", size = 11138498, upload-time = "2026-05-28T14:16:38.425Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/26/df51322b52ee1ada7eff2d071ea09d11d5c2d1dcc9f02594f5785c4d1635/ruff-0.16.9-py3-none-linux_armv6l.whl", hash = "sha256:95e6f022090368ab3b824c36276839c53b2adf1a3f4c09fefc33dfc400f6da96", size = 10082922, upload-time = "2026-09-24T20:37:13.045Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/27/7bf51f5a7aa375e9f339280a303aab44ca75dc1525f1cdc5991761685b0f/ruff-0.16.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a5f27be168556594a86d2f415db0cf43f5291917849318f873c7e2791f7a8c67", size = 10236360, upload-time = "2026-09-24T20:37:16.049Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/63/09659283f92f02dff45809da194a70da2f688d87c55d8875c4fae3536072/ruff-0.16.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1632eb1d6197f33bd00b1acbc5b71009e89a8895c158e2d2b03a834fac964ab6", size = 9892940, upload-time = "2026-09-24T20:37:17.957Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/58/98de1b72ec172f5f8f1731236fe21585b3998bf7dfe9fcc44ae9ba626012/ruff-0.16.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3f951b14d865d5952c89d40a5ca07e87abe24fa5453299878411e127748fb1c", size = 10032114, upload-time = "2026-09-24T20:37:19.942Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/7d/f1e17c54ab59d4bad1dce8ee3e22a7a1d0ef4745240decacdcf3832b5bb2/ruff-0.16.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:447fc07e1573afff7cb02803462b12b6c8ece7cf10e2cd78565fa6d7a1c0bf8d", size = 9910227, upload-time = "2026-09-24T20:37:21.872Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/19/436f647a65075bbd3bab2668b3bdaa5120559b294694018cdcefabbbf30b/ruff-0.16.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a3e039a6a40ed976c491722b60e0ae4a4aa1a86057f540ee7a37a5d19ae9120", size = 10547484, upload-time = "2026-09-24T20:37:24.229Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/59/38430a6bc2f6d8095447ac39625cf8b6e9344a47e6c26225c8ba1bff3ffb/ruff-0.16.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4684dded7db60aa57cb118fa158630f5feade4af5782903b6053484bdf9bd129", size = 11412367, upload-time = "2026-09-24T20:37:26.307Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/bd/bbb6d7fc7f208c8b8c50dd5dc8206e4cfdb1e7a8fb852606a3adf370c880/ruff-0.16.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d29c934357e45642fda2f34c0b1f4025b4a6c01e15e4bf0016879d60078a142c", size = 10869787, upload-time = "2026-09-24T20:37:28.35Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/b8/9c543074918061abbefc3bd139bee22de35abedb00dde0f2d27288838962/ruff-0.16.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a21713e629d3e5bdb2f5c2def1cc7f04f47fa8e1a7eb0571b4a28e1da64bc728", size = 10406494, upload-time = "2026-09-24T20:37:30.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/7a/5a8851bd146e7ccf8fd4b003f6c75c11f8fbdb0e60673b45097986b6bf41/ruff-0.16.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7baa24ef5fc8e77aa93879e1d3f43754a01ae488e869f1ae30cf431afd4d2452", size = 10590083, upload-time = "2026-09-24T20:37:32.439Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/f0/4c3467188f23f806960b46fa76575a7cd0514c9ba90562650b71efc96980/ruff-0.16.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a41aac6230aadfaa133bdfa1614488531ffa3e0837567ae04c0da2058a9c0f9e", size = 10119151, upload-time = "2026-09-24T20:37:34.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/34/5a4def5adea572ce6aea0bb64f21f928ee317b80e0db747d7979b01d7261/ruff-0.16.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c2529fb5896d49115b0e9aa8f887490b34bbe76baf879ec2264ac59406869ce7", size = 9911544, upload-time = "2026-09-24T20:37:36.796Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/5d/d15ebea7499eef9373318c0ee6ca127832927c6529731f6d48e18dce7ca9/ruff-0.16.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:41e3870277694177429b56406d65dfbdb2c2802c52b715edaf6a0b829c69d4ee", size = 10269884, upload-time = "2026-09-24T20:37:38.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/56/c5d3cd119ded7a3c7aba0e961b69cb3df701c91662c98ad694467d060ce1/ruff-0.16.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8adbe4e58af167f767d7b2ba5e83c42e878350796cf78c2f5e14ab9903a92588", size = 10749366, upload-time = "2026-09-24T20:37:41.042Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/fe/734ec7527029ac757ecf821f143c9f3fcf69149c21f53a044a899b430f5a/ruff-0.16.9-py3-none-win32.whl", hash = "sha256:0e1dbc2073624dee6618d41d0098690a7244654af746704b64759e12b6b6b385", size = 10152355, upload-time = "2026-09-24T20:37:43.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/21/26e4643629b3ebb44f0a06f9c9a53058d63d989415f63a9a3c28e2ee7f22/ruff-0.16.9-py3-none-win_amd64.whl", hash = "sha256:6bd40fec8cd4c8a3d4dd589bd8ad4e6320c13c29234159bfd959a40d529d597b", size = 10592965, upload-time = "2026-09-24T20:37:44.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/60/5fb1a39dbb5ae314d5f59bc7348a63c1d5c20f3cd83914c4b5cb0be31d2d/ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284", size = 10458649, upload-time = "2026-09-24T20:37:46.882Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -897,27 +897,27 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ty"
|
||||
version = "0.0.43"
|
||||
version = "0.0.84"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0d/37/4ec04de0659b93be37d956dfceca13b1ecab9c959f28d8a1d5e514603f36/ty-0.0.43.tar.gz", hash = "sha256:ea4cff50548f2a1877e848d3abe9e293cde8ab94757a7eb93fc0d4013f98be8e", size = 5798429, upload-time = "2026-06-04T00:52:10.013Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e1/f6/34f8869c45ea87fe6a931ffa824b1fe4428167173543d92988d631add355/ty-0.0.84.tar.gz", hash = "sha256:0cefdb0cd5d399418dbe83c349ba605047b7dff815ae6f460c80e8522b40be67", size = 7409537, upload-time = "2026-09-24T13:16:24.367Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/db/74/1916026a78f20019a2f03adbd6fb4430ddb7ce1e52c2e17a90856a6d192e/ty-0.0.43-py3-none-linux_armv6l.whl", hash = "sha256:3bf70f5446480562bf6c9f639df4b5cb60716b8f8d1a6b8e5811d5c7eccd8bf2", size = 11598153, upload-time = "2026-06-04T00:52:20.646Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/af/58bb0089d2635216c8fa6612dd486a3f986d0ab1c46a41527ab95e57f0e3/ty-0.0.43-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7184741f8b15425a1bc64b950ad005cb353573288ac0e8a04f5481ceb3832596", size = 11357811, upload-time = "2026-06-04T00:52:24.683Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/9c/32c6b14f3feddf87b59c7a50709e2b3da408258f2f583f05575f77bc8f7b/ty-0.0.43-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8c306379ca9a35f6ae5270fe9bda7af4b46d91822725a2586d78c8b9b5493b62", size = 10772024, upload-time = "2026-06-04T00:52:14.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/fa/98aa4a74bd00cd5efc424923cd1daffbf1e40a0338041cafb203379d746f/ty-0.0.43-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d624b884c9c1fd244ad2a5f026364e7162a22b3f537025941ada2e363e676414", size = 11291034, upload-time = "2026-06-04T00:52:37.249Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/db/4de086c38ce96dcada2bd451f43171d2c237f96d8ed19a1ea8fe51bb8ef4/ty-0.0.43-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:281fc4c00fbc196045141faa085055bddc58846b04a2800204701415a1b9c6aa", size = 11364724, upload-time = "2026-06-04T00:52:33.138Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/d3/e3cd8e3233a6fd8362a49aa025b79e9f40151a2a86d811ace154c6eb7445/ty-0.0.43-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f57d6cc28de89024b48d1788e4758c05299d5749d4a51c02e71ac655ec23d9a5", size = 11890555, upload-time = "2026-06-04T00:52:22.711Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/7b/6f46d444e8241606bbde098df3dca93f2ec0b834a42055db85ee7d33646f/ty-0.0.43-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0a1d6ad6c5e7792c7eac0a01e550f2c2004462e01a64a91ea1636aba6fef6e71", size = 12450968, upload-time = "2026-06-04T00:52:28.94Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/e1/79fbe51f2e4b9d8347f2013cd7ed0b63f3b499038c02dc0357e9b28a3a47/ty-0.0.43-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:66d474395d7635fb618bdbb58b4e3360259a2056d0a5621b82754b9da2cd8a04", size = 12064187, upload-time = "2026-06-04T00:52:12.039Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/3f/c758a3a8df5b90d331f2b60c8f16021ee64d75e78f99d67cc4efc9bf5f4b/ty-0.0.43-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2663a0003a8b60fb98db7f6f6e673df80b21d0fe3a9868a26fb06b4e049b6fc4", size = 11943208, upload-time = "2026-06-04T00:52:31.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/5f/f516442749cf1b45ca6720a5d41df2738a486ed9ace774c03d515db89084/ty-0.0.43-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:d5a6c352d374d889189d5ec82b54b26a5885f769f7b7787f7f875500dcb8673e", size = 12143572, upload-time = "2026-06-04T00:52:18.457Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/bf/0d83c7f43bf4c10f3678bfe7d938e51c445298c7b923f155c5204730c2df/ty-0.0.43-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e7dbbeedfad3ca250d74fcc355fa9ab6b38d2a17f22d6304f615716939dbbb27", size = 11279355, upload-time = "2026-06-04T00:52:26.726Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/de/a6c978bef6d9e949f79f4782d9e4ee4df0893713e73b055d84c1a5116b9a/ty-0.0.43-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:24b18a0273ee46154996cfcfa27438f851f440c925587ec200df6f98dffe67d3", size = 11408412, upload-time = "2026-06-04T00:52:35.282Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/b1/d13857c23867f0f76b92e38e5841c64ca5e76dc5d4bf27f52cb81d8ab685/ty-0.0.43-py3-none-musllinux_1_2_i686.whl", hash = "sha256:2ef681951520d692b7e9c0b5e56aacf4f98ccae47cf6ffccaf2c7b6b33dc226e", size = 11541709, upload-time = "2026-06-04T00:52:16.451Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/f1/cd6afc6f6a687e238bf5e12189f7920e81a0bdef6c3dba4c784ef140f7d9/ty-0.0.43-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:2af105de7437143aa4676b28016b5bee661aaaa4eff52be5867fb25119641ceb", size = 12041266, upload-time = "2026-06-04T00:52:43.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/ba/51ca7c3335da2b8d0a3e477fa4986be9f4a53b05bfab862967d8d2e6ca60/ty-0.0.43-py3-none-win32.whl", hash = "sha256:e4773115b0d6486ee30f1657fc8bdffe7e3a3f5300ab77ef2495da6e83e4694f", size = 10858724, upload-time = "2026-06-04T00:52:07.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/29/5d80453e5f7c520145fa058851da87230dbd7ca761a7675447a9fe504e0b/ty-0.0.43-py3-none-win_amd64.whl", hash = "sha256:48d3545094a4ae6395492c7e6ac90550fce969e0ed2815fbf8c5da9756676b7d", size = 11976157, upload-time = "2026-06-04T00:52:41.438Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/ed/befe5a543e5b95e754ed38ee95239e44efda9bc5f578db4ac1bc8dd758d6/ty-0.0.43-py3-none-win_arm64.whl", hash = "sha256:740ca33d7f75f655a4e7d475bc42dfb825c13219bb073fad30fcc04d35790c74", size = 11308680, upload-time = "2026-06-04T00:52:39.233Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/0c/600648778e1c79133ce6b6a5c4c2531ae16b90c9b30f5041b38b574f61b8/ty-0.0.84-py3-none-linux_armv6l.whl", hash = "sha256:868438e2b9abfc835dd5b5b07ed693db95d8348ac40e3228252511d63ae87695", size = 13935333, upload-time = "2026-09-24T13:15:31.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/3b/27f06f49945c36170765fad82076898039ae3873338e9865f876dd62f12c/ty-0.0.84-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b00f39b521f0b559cde0f6abf8dd47213b513b04f33d58efeb9b98486a16bb17", size = 13511328, upload-time = "2026-09-24T13:15:34.446Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/b6/b12dd130e7f30141f0645614f454c925991cd389b1cc6c8284fd6bf32326/ty-0.0.84-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8f78c2915567f2ee62ce0c24d86091bc76f81577677755e242d09951db588b9a", size = 13445445, upload-time = "2026-09-24T13:15:37.476Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/96/f012de4b3c1d9a4773326621380d768b4d48c47e620dc012775c5e01c7d7/ty-0.0.84-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:61155cad0921e890d86d1d911a40b3a0e924ef4bdc3b4f3865a6c89c7e3012dc", size = 13471235, upload-time = "2026-09-24T13:15:40.64Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/32/dca630f5f5353fa7bf67eebccf1549df99b16656d21071d0df6677c826a5/ty-0.0.84-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:417c3bf14947ed16b0b92976ab333c27915590354e96d4f581709068d3811355", size = 13680704, upload-time = "2026-09-24T13:15:43.817Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/8d/33702c8f62f05ad45b33644fb914d483de6a0a7bc8e51884e9b8773e6d69/ty-0.0.84-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e06ef117759f654f5379efe9ae79bda8a186016c016f1023d05c34f8e15c1546", size = 14518923, upload-time = "2026-09-24T13:15:46.802Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/a1/da3246e6ef2ae0e842bca4058b25c54bee55ab058ffff4ee54e72517ec01/ty-0.0.84-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a05d1d53db8b64410ab38c6e32c5898510584869d1d5dd9c0605db81c9aef7df", size = 15027731, upload-time = "2026-09-24T13:15:49.755Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/17/b0099098a560aeb4f9fde6c912f9a81622c3e44e6555e1fbe322b368ec31/ty-0.0.84-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b92400494ab855cfb1204ac1101e3f53b2d09a8e21d171758b2cf9acdab1809e", size = 14748260, upload-time = "2026-09-24T13:15:52.713Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/d5/32cd67aedb271f006e716ad093806c96c5d7e2af86be8255469dbd3d76c8/ty-0.0.84-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af17eb391ae3027fed9be1780bea555c2b8a25ba68e184c627b4981b2c9eaab7", size = 14205858, upload-time = "2026-09-24T13:15:55.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/c4/638ac62ca2e8eca1b92e3e16273085744fada728b993500819b5c258ca15/ty-0.0.84-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:44cf06af0e7ec78ea6d8ff33a3450fc19627756a4fba681f7410d4e3ef63047a", size = 14573196, upload-time = "2026-09-24T13:15:59.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/f6/9ee81c5d8921e5fd5f10889ae563c95cd3272b31df2ada528919ca1cef6b/ty-0.0.84-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c6e56b443ff9ba462e34bfbc1e582004c2719ed35f8f93a433744b0ba0afb2d9", size = 13480760, upload-time = "2026-09-24T13:16:02.313Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/83/fbe5d1177667ae16d84c3242846b143809c9cc09568b333e5a9b4d5908f6/ty-0.0.84-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8b6562fcc48820c1030e2a4509ef2c14ef93043ca4860280074314ac3a825b95", size = 13696346, upload-time = "2026-09-24T13:16:05.391Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/9a/1b4ac9ddc34cc0ab4624bc219825d66af52f1ad87f4778ad08ad03fa06d5/ty-0.0.84-py3-none-musllinux_1_2_i686.whl", hash = "sha256:dee7421451efcbd70f03e95a8f2c8d9e5ec3d35edcd27b4095ee00ee062abfc4", size = 13959410, upload-time = "2026-09-24T13:16:08.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/04/70e289437a67dfd686056ab4fd91880261a8233033a6a95d29393741fb97/ty-0.0.84-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:31e96307cf621babe969307dd657783d672e2d8bdbd06493710da635bf63fa07", size = 14350294, upload-time = "2026-09-24T13:16:11.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/f4/ae7dbd118425c850981d23efb850f0d9d7f6b8d2b852bde2767434f668b4/ty-0.0.84-py3-none-win32.whl", hash = "sha256:4bdc85f91cbaae35825f2b17bb4ac5de0563367875cb0f42b2bf07a1bf7ff2ba", size = 13176439, upload-time = "2026-09-24T13:16:15.049Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/ec/994d87252f065dda5a500bc8afc726d18f6582d81d6f4ff1feb8a267c800/ty-0.0.84-py3-none-win_amd64.whl", hash = "sha256:71cc9aa7a7c89d4e12c6a814e9c0c3310ad5bee44efedbd6f5a42d91e9ac0f0d", size = 13962879, upload-time = "2026-09-24T13:16:18.191Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/1c/e6e91852b72566b1ab9aca504712857dfb89324c12092a5bd61d42d34427/ty-0.0.84-py3-none-win_arm64.whl", hash = "sha256:6c9d3718c4a0d318bc9c92af9b2aa85c3294e7dff54cf1d69ad79568f56516da", size = 13656453, upload-time = "2026-09-24T13:16:21.51Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -943,11 +943,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.7.0"
|
||||
version = "2.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -67,24 +67,12 @@ with PostgresSaver.from_conn_string(DB_URI) as checkpointer:
|
||||
"v": 4,
|
||||
"ts": "2024-07-31T20:14:19.804150+00:00",
|
||||
"id": "1ef4f797-8335-6428-8001-8a1503f9b875",
|
||||
"channel_values": {
|
||||
"my_key": "meow",
|
||||
"node": "node"
|
||||
},
|
||||
"channel_versions": {
|
||||
"__start__": 2,
|
||||
"my_key": 3,
|
||||
"start:node": 3,
|
||||
"node": 3
|
||||
},
|
||||
"channel_values": {"my_key": "meow", "node": "node"},
|
||||
"channel_versions": {"__start__": 2, "my_key": 3, "start:node": 3, "node": 3},
|
||||
"versions_seen": {
|
||||
"__input__": {},
|
||||
"__start__": {
|
||||
"__start__": 1
|
||||
},
|
||||
"node": {
|
||||
"start:node": 2
|
||||
}
|
||||
"__start__": {"__start__": 1},
|
||||
"node": {"start:node": 2},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -108,24 +96,12 @@ async with AsyncPostgresSaver.from_conn_string(DB_URI) as checkpointer:
|
||||
"v": 4,
|
||||
"ts": "2024-07-31T20:14:19.804150+00:00",
|
||||
"id": "1ef4f797-8335-6428-8001-8a1503f9b875",
|
||||
"channel_values": {
|
||||
"my_key": "meow",
|
||||
"node": "node"
|
||||
},
|
||||
"channel_versions": {
|
||||
"__start__": 2,
|
||||
"my_key": 3,
|
||||
"start:node": 3,
|
||||
"node": 3
|
||||
},
|
||||
"channel_values": {"my_key": "meow", "node": "node"},
|
||||
"channel_versions": {"__start__": 2, "my_key": 3, "start:node": 3, "node": 3},
|
||||
"versions_seen": {
|
||||
"__input__": {},
|
||||
"__start__": {
|
||||
"__start__": 1
|
||||
},
|
||||
"node": {
|
||||
"start:node": 2
|
||||
}
|
||||
"__start__": {"__start__": 1},
|
||||
"node": {"start:node": 2},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ from langgraph.checkpoint.base import (
|
||||
CheckpointMetadata,
|
||||
CheckpointTuple,
|
||||
DeltaChannelHistory,
|
||||
_parse_supersteps_since_last_snapshot_by_channel,
|
||||
get_checkpoint_id,
|
||||
get_serializable_checkpoint_metadata,
|
||||
)
|
||||
@@ -28,12 +27,15 @@ from psycopg_pool import ConnectionPool
|
||||
from langgraph.checkpoint.postgres import _internal
|
||||
from langgraph.checkpoint.postgres.base import (
|
||||
_DELTA_PAGE_SIZE,
|
||||
PRUNE_DELETE_THREADS_SQL,
|
||||
PRUNE_STRATEGIES,
|
||||
BasePostgresSaver,
|
||||
_advance_shared_chain,
|
||||
_build_delta_fetch_sql,
|
||||
_build_delta_walk_sql,
|
||||
_build_delta_stage1_sql,
|
||||
_build_delta_stage2_sql,
|
||||
_DeltaStage2Row,
|
||||
_ingest_walk_page,
|
||||
_execute,
|
||||
_keep_latest,
|
||||
_refuse_delta_threads,
|
||||
)
|
||||
from langgraph.checkpoint.postgres.shallow import ShallowPostgresSaver
|
||||
|
||||
@@ -226,7 +228,7 @@ class PostgresSaver(BasePostgresSaver):
|
||||
>>> checkpoint_tuple = memory.get_tuple(config)
|
||||
>>> print(checkpoint_tuple)
|
||||
CheckpointTuple(...)
|
||||
""" # noqa
|
||||
"""
|
||||
thread_id = config["configurable"]["thread_id"]
|
||||
checkpoint_id = get_checkpoint_id(config)
|
||||
checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
|
||||
@@ -404,6 +406,73 @@ class PostgresSaver(BasePostgresSaver):
|
||||
(str(thread_id),),
|
||||
)
|
||||
|
||||
def prune(
|
||||
self,
|
||||
thread_ids: Sequence[str],
|
||||
*,
|
||||
strategy: str = "keep_latest",
|
||||
) -> None:
|
||||
"""Prune checkpoints for the given threads.
|
||||
|
||||
`"delete"` removes every checkpoint, write and blob of the threads, in
|
||||
one transaction.
|
||||
|
||||
`"keep_latest"` keeps the latest checkpoint of each namespace (the one
|
||||
`get_tuple` loads, plus its parent when it predates v4 and has pending
|
||||
sends), with its pending writes and the blobs it references, and
|
||||
deletes the rest. A kept checkpoint whose parent it deletes is left with
|
||||
no parent. A subgraph call's namespace is deleted with the root
|
||||
checkpoint the call started from, except a `checkpointer=True`
|
||||
subgraph's, which keeps its latest checkpoint. Each thread is pruned in
|
||||
its own transaction, on one snapshot of it, so a run appending to the
|
||||
thread meanwhile is safe; forking or replaying from a checkpoint being
|
||||
deleted is not. A thread that fails because another transaction changed
|
||||
it leaves the threads before it pruned, and retrying is safe.
|
||||
|
||||
`"keep_latest"` refuses threads that use a `DeltaChannel`, since it
|
||||
rebuilds the latest value from older checkpoints: if any requested
|
||||
thread does, nothing is pruned. A thread that starts using one before
|
||||
its turn in the batch is refused then, after the threads before it.
|
||||
|
||||
Args:
|
||||
thread_ids: The thread IDs to prune.
|
||||
strategy: `"keep_latest"` or `"delete"`.
|
||||
|
||||
Raises:
|
||||
TypeError: If `thread_ids` is a single string.
|
||||
ValueError: If `strategy` is unknown.
|
||||
NotImplementedError: If `strategy` is `"keep_latest"` and a thread
|
||||
uses a `DeltaChannel`.
|
||||
psycopg.errors.SerializationFailure: If another transaction, such
|
||||
as a second prune, changes a thread's rows while `"keep_latest"`
|
||||
prunes it.
|
||||
psycopg.errors.DeadlockDetected: If a second prune of a thread runs
|
||||
at the same time.
|
||||
"""
|
||||
if isinstance(thread_ids, str):
|
||||
raise TypeError("thread_ids must be a sequence of thread IDs, not a str")
|
||||
if strategy not in PRUNE_STRATEGIES:
|
||||
raise ValueError(f"Unknown prune strategy: {strategy!r}")
|
||||
thread_ids = [str(t) for t in thread_ids]
|
||||
if not thread_ids:
|
||||
return
|
||||
if strategy == "delete":
|
||||
with self._cursor() as cur, cur.connection.transaction():
|
||||
for sql in PRUNE_DELETE_THREADS_SQL:
|
||||
cur.execute(sql, (thread_ids,))
|
||||
return
|
||||
with self._cursor() as cur:
|
||||
_execute(cur, _refuse_delta_threads(thread_ids))
|
||||
for thread_id in thread_ids:
|
||||
with self._cursor() as cur:
|
||||
if self.pipe:
|
||||
# The transaction has to start with its isolation level, and
|
||||
# in pipeline mode it would take in what ran since the last
|
||||
# sync.
|
||||
self.pipe.sync()
|
||||
with cur.connection.transaction():
|
||||
_execute(cur, _keep_latest(thread_id))
|
||||
|
||||
@contextmanager
|
||||
def _cursor(self, *, pipeline: bool = False) -> Iterator[Cursor[DictRow]]:
|
||||
"""Create a database cursor as a context manager.
|
||||
@@ -449,137 +518,117 @@ class PostgresSaver(BasePostgresSaver):
|
||||
) -> Mapping[str, DeltaChannelHistory]:
|
||||
"""Fast-path override of `BaseCheckpointSaver.get_delta_channel_history`.
|
||||
|
||||
Reconstructs each delta channel's state for the target checkpoint by
|
||||
walking the parent chain `supersteps` hops (read from the target's
|
||||
`counters_since_delta_snapshot` metadata) to its seed snapshot, then
|
||||
collecting the writes between the seed and the target.
|
||||
Two-stage query, both stages cover ALL requested channels:
|
||||
|
||||
Two passes (see the `# Multi-channel two-pass` comment in `base.py`):
|
||||
* Stage 1 (paged): dynamic SELECT over `checkpoints` with three
|
||||
columns per channel: its version, an `EXISTS` probe for a stored
|
||||
blob at that version, and its inline value. Pages newest-first by
|
||||
`checkpoint_id`, starting at the target; page size is
|
||||
`_DELTA_PAGE_SIZE`.
|
||||
Stops paging when every channel has found its seed or a page comes
|
||||
back short.
|
||||
|
||||
* WALK (paged): dynamic SELECT over `checkpoints` with K parallel
|
||||
JSONB lookups for `channel_versions[ch]` (the seed blob version),
|
||||
following `parent_checkpoint_id` newest-first. Page size is
|
||||
`_DELTA_PAGE_SIZE`; stops once the shared chain reaches the deepest
|
||||
requested `supersteps`, the root is reached, or the chain is
|
||||
exhausted.
|
||||
|
||||
* FETCH (per-channel UNION ALL): one branch per channel reading
|
||||
`checkpoint_writes` for its `chain_cids`, plus one branch per
|
||||
channel that located a seed reading `checkpoint_blobs` at the
|
||||
seed's version.
|
||||
* Stage 2 (per-channel UNION ALL): one branch per channel reading
|
||||
`checkpoint_writes` filtered to that channel's specific
|
||||
`chain_cids`, plus one branch per channel that has a seed reading
|
||||
`checkpoint_blobs` for that channel + version. Avoids the
|
||||
over-fetch of a single `channel = ANY(channels)` filter when
|
||||
channels have different chain depths.
|
||||
"""
|
||||
if not channels:
|
||||
return {}
|
||||
channels = list(channels)
|
||||
thread_id = config["configurable"]["thread_id"]
|
||||
if not thread_id:
|
||||
raise ValueError("empty thread ID")
|
||||
checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
|
||||
|
||||
# Resolve the target checkpoint id + its metadata (for supersteps).
|
||||
checkpoint_id = get_checkpoint_id(config)
|
||||
with self._cursor() as cur:
|
||||
if checkpoint_id is None:
|
||||
cur.execute(
|
||||
"SELECT checkpoint_id, metadata FROM checkpoints "
|
||||
"WHERE thread_id = %s AND checkpoint_ns = %s "
|
||||
"ORDER BY checkpoint_id DESC LIMIT 1",
|
||||
(thread_id, checkpoint_ns),
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"SELECT checkpoint_id, metadata FROM checkpoints "
|
||||
"WHERE thread_id = %s AND checkpoint_ns = %s "
|
||||
"AND checkpoint_id = %s",
|
||||
(thread_id, checkpoint_ns, checkpoint_id),
|
||||
)
|
||||
target_row = cur.fetchone()
|
||||
if target_row is None:
|
||||
return {ch: {"writes": []} for ch in channels}
|
||||
target_id = cast(str, target_row["checkpoint_id"])
|
||||
supersteps_by_ch = _parse_supersteps_since_last_snapshot_by_channel(
|
||||
target_row["metadata"] or {}, channels
|
||||
)
|
||||
max_supersteps = max(supersteps_by_ch.values(), default=0)
|
||||
if not checkpoint_id:
|
||||
target = self.get_tuple(config)
|
||||
if target is None:
|
||||
return {ch: {"writes": []} for ch in channels}
|
||||
checkpoint_id = target.config["configurable"]["checkpoint_id"]
|
||||
|
||||
# WALK: page the parent chain, bounded by the deepest supersteps.
|
||||
walk_sql = _build_delta_walk_sql(channels)
|
||||
# Stage 1: paged K-JSONB-lookup scan, walking the parent chain in
|
||||
# Python after each page. Stops as soon as every channel has its seed.
|
||||
stage1_sql = _build_delta_stage1_sql(channels, paged=True, include_cursor=True)
|
||||
parent_of: dict[str, str | None] = {}
|
||||
ver_by_i_by_cid: list[dict[str, str | None]] = [{} for _ in channels]
|
||||
shared_cpid_chain: list[str] = []
|
||||
has_reached_root = False
|
||||
cursor: str | None = None
|
||||
hb_by_i_by_cid: list[dict[str, bool]] = [{} for _ in channels]
|
||||
inline_by_i_by_cid: list[dict[str, Any]] = [{} for _ in channels]
|
||||
chain_by_ch: dict[str, list[str]] = {ch: [] for ch in channels}
|
||||
seed_ver_by_ch: dict[str, str | None] = {ch: None for ch in channels}
|
||||
seed_inline_by_ch: dict[str, Any] = {}
|
||||
walk_cursor_by_ch: dict[str, str | None] = {}
|
||||
seeded: set[str] = set()
|
||||
cursor: str | None = checkpoint_id
|
||||
|
||||
while max_supersteps > 0:
|
||||
walk_params: list[Any] = [
|
||||
*channels,
|
||||
thread_id,
|
||||
checkpoint_ns,
|
||||
cursor,
|
||||
cursor,
|
||||
_DELTA_PAGE_SIZE,
|
||||
]
|
||||
with self._cursor() as cur:
|
||||
cur.execute(walk_sql, walk_params)
|
||||
with self._cursor() as cur:
|
||||
while True:
|
||||
stage1_params: list[Any] = []
|
||||
for ch in channels:
|
||||
# ver_i, blob channel, blob version, inline_i
|
||||
stage1_params.extend([ch, ch, ch, ch])
|
||||
stage1_params.extend(
|
||||
[thread_id, checkpoint_ns, cursor, _DELTA_PAGE_SIZE]
|
||||
)
|
||||
cur.execute(stage1_sql, stage1_params)
|
||||
page = cur.fetchall()
|
||||
if not page:
|
||||
break
|
||||
oldest = _ingest_walk_page(
|
||||
cast("list[Mapping[str, Any]]", page),
|
||||
channels,
|
||||
parent_of,
|
||||
ver_by_i_by_cid,
|
||||
)
|
||||
has_reached_root = _advance_shared_chain(
|
||||
target_id, parent_of, shared_cpid_chain, max_supersteps
|
||||
)
|
||||
if (
|
||||
has_reached_root
|
||||
or len(shared_cpid_chain) >= max_supersteps
|
||||
or len(page) < _DELTA_PAGE_SIZE
|
||||
):
|
||||
break
|
||||
cursor = oldest
|
||||
if not page:
|
||||
break
|
||||
oldest = self._ingest_stage1_page(
|
||||
cast("list[Mapping[str, Any]]", page),
|
||||
channels,
|
||||
parent_of,
|
||||
ver_by_i_by_cid,
|
||||
hb_by_i_by_cid,
|
||||
inline_by_i_by_cid,
|
||||
)
|
||||
self._try_advance_walks(
|
||||
checkpoint_id,
|
||||
channels,
|
||||
parent_of,
|
||||
ver_by_i_by_cid,
|
||||
hb_by_i_by_cid,
|
||||
inline_by_i_by_cid,
|
||||
chain_by_ch,
|
||||
seed_ver_by_ch,
|
||||
seed_inline_by_ch,
|
||||
walk_cursor_by_ch,
|
||||
seeded,
|
||||
)
|
||||
# Stop if every channel is seeded, or the page was short
|
||||
# (chain exhausted — no more rows to fetch).
|
||||
if len(seeded) == len(channels) or len(page) < _DELTA_PAGE_SIZE:
|
||||
break
|
||||
cursor = oldest
|
||||
stage1_sql = _build_delta_stage1_sql(channels, paged=True)
|
||||
|
||||
chained_cpid_by_ch, seed_cpid_by_ch, seed_ver_by_ch = (
|
||||
self._resolve_delta_chains(
|
||||
channels,
|
||||
supersteps_by_ch,
|
||||
shared_cpid_chain,
|
||||
ver_by_i_by_cid,
|
||||
has_reached_root,
|
||||
thread_id,
|
||||
)
|
||||
)
|
||||
|
||||
# FETCH: per-channel UNION ALL — writes branch per chained channel,
|
||||
# blob branch per seeded channel.
|
||||
channels_with_chain = [ch for ch in channels if chained_cpid_by_ch[ch]]
|
||||
# Stage 2: per-channel UNION ALL — one writes branch per channel
|
||||
# with non-empty chain, plus one blob branch per seeded channel.
|
||||
channels_with_chain = [ch for ch in channels if chain_by_ch[ch]]
|
||||
channels_with_seed = [ch for ch in channels if seed_ver_by_ch[ch] is not None]
|
||||
fetch_sql = _build_delta_fetch_sql(
|
||||
stage2_sql = _build_delta_stage2_sql(
|
||||
channels_with_chain=channels_with_chain,
|
||||
channels_with_seed=channels_with_seed,
|
||||
)
|
||||
if fetch_sql:
|
||||
fetch_params: list[Any] = []
|
||||
for ch in channels_with_chain:
|
||||
fetch_params.extend(
|
||||
[thread_id, checkpoint_ns, ch, chained_cpid_by_ch[ch]]
|
||||
)
|
||||
for ch in channels_with_seed:
|
||||
fetch_params.extend([thread_id, checkpoint_ns, ch, seed_ver_by_ch[ch]])
|
||||
with self._cursor() as cur:
|
||||
cur.execute(fetch_sql, fetch_params)
|
||||
fetch_rows = cur.fetchall()
|
||||
else:
|
||||
fetch_rows = []
|
||||
|
||||
return self._assemble_delta_history(
|
||||
if stage2_sql:
|
||||
stage2_params: list[Any] = []
|
||||
for ch in channels_with_chain:
|
||||
stage2_params.extend([thread_id, checkpoint_ns, ch, chain_by_ch[ch]])
|
||||
for ch in channels_with_seed:
|
||||
stage2_params.extend([thread_id, checkpoint_ns, ch, seed_ver_by_ch[ch]])
|
||||
with self._cursor() as cur:
|
||||
cur.execute(stage2_sql, stage2_params)
|
||||
stage2_rows = cur.fetchall()
|
||||
else:
|
||||
stage2_rows = []
|
||||
|
||||
return self._build_delta_channels_writes_history(
|
||||
channels=channels,
|
||||
chained_cpid_by_ch=chained_cpid_by_ch,
|
||||
seed_cpid_by_ch=seed_cpid_by_ch,
|
||||
chain_by_ch=chain_by_ch,
|
||||
seed_ver_by_ch=seed_ver_by_ch,
|
||||
fetch_rows=cast("list[_DeltaStage2Row]", fetch_rows),
|
||||
seed_inline_by_ch=seed_inline_by_ch,
|
||||
stage2_rows=cast("list[_DeltaStage2Row]", stage2_rows),
|
||||
)
|
||||
|
||||
def _load_checkpoint_tuple(self, value: DictRow) -> CheckpointTuple:
|
||||
|
||||
@@ -14,7 +14,6 @@ from langgraph.checkpoint.base import (
|
||||
CheckpointMetadata,
|
||||
CheckpointTuple,
|
||||
DeltaChannelHistory,
|
||||
_parse_supersteps_since_last_snapshot_by_channel,
|
||||
get_checkpoint_id,
|
||||
get_serializable_checkpoint_metadata,
|
||||
)
|
||||
@@ -28,12 +27,15 @@ from psycopg_pool import AsyncConnectionPool
|
||||
from langgraph.checkpoint.postgres import _ainternal
|
||||
from langgraph.checkpoint.postgres.base import (
|
||||
_DELTA_PAGE_SIZE,
|
||||
PRUNE_DELETE_THREADS_SQL,
|
||||
PRUNE_STRATEGIES,
|
||||
BasePostgresSaver,
|
||||
_advance_shared_chain,
|
||||
_build_delta_fetch_sql,
|
||||
_build_delta_walk_sql,
|
||||
_aexecute,
|
||||
_build_delta_stage1_sql,
|
||||
_build_delta_stage2_sql,
|
||||
_DeltaStage2Row,
|
||||
_ingest_walk_page,
|
||||
_keep_latest,
|
||||
_refuse_delta_threads,
|
||||
)
|
||||
from langgraph.checkpoint.postgres.shallow import AsyncShallowPostgresSaver
|
||||
|
||||
@@ -363,6 +365,55 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
||||
(str(thread_id),),
|
||||
)
|
||||
|
||||
async def aprune(
|
||||
self,
|
||||
thread_ids: Sequence[str],
|
||||
*,
|
||||
strategy: str = "keep_latest",
|
||||
) -> None:
|
||||
"""Prune checkpoints for the given threads.
|
||||
|
||||
See `PostgresSaver.prune` for what each strategy keeps.
|
||||
|
||||
Args:
|
||||
thread_ids: The thread IDs to prune.
|
||||
strategy: `"keep_latest"` or `"delete"`.
|
||||
|
||||
Raises:
|
||||
TypeError: If `thread_ids` is a single string.
|
||||
ValueError: If `strategy` is unknown.
|
||||
NotImplementedError: If `strategy` is `"keep_latest"` and a thread
|
||||
uses a `DeltaChannel`.
|
||||
psycopg.errors.SerializationFailure: If another transaction, such
|
||||
as a second prune, changes a thread's rows while `"keep_latest"`
|
||||
prunes it.
|
||||
psycopg.errors.DeadlockDetected: If a second prune of a thread runs
|
||||
at the same time.
|
||||
"""
|
||||
if isinstance(thread_ids, str):
|
||||
raise TypeError("thread_ids must be a sequence of thread IDs, not a str")
|
||||
if strategy not in PRUNE_STRATEGIES:
|
||||
raise ValueError(f"Unknown prune strategy: {strategy!r}")
|
||||
thread_ids = [str(t) for t in thread_ids]
|
||||
if not thread_ids:
|
||||
return
|
||||
if strategy == "delete":
|
||||
async with self._cursor() as cur, cur.connection.transaction():
|
||||
for sql in PRUNE_DELETE_THREADS_SQL:
|
||||
await cur.execute(sql, (thread_ids,))
|
||||
return
|
||||
async with self._cursor() as cur:
|
||||
await _aexecute(cur, _refuse_delta_threads(thread_ids))
|
||||
for thread_id in thread_ids:
|
||||
async with self._cursor() as cur:
|
||||
if self.pipe:
|
||||
# The transaction has to start with its isolation level, and
|
||||
# in pipeline mode it would take in what ran since the last
|
||||
# sync.
|
||||
await self.pipe.sync()
|
||||
async with cur.connection.transaction():
|
||||
await _aexecute(cur, _keep_latest(thread_id))
|
||||
|
||||
@asynccontextmanager
|
||||
async def _cursor(
|
||||
self, *, pipeline: bool = False
|
||||
@@ -411,122 +462,97 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
||||
"""Fast-path override of `BaseCheckpointSaver.aget_delta_channel_history`.
|
||||
|
||||
See `PostgresSaver.get_delta_channel_history` for design notes; this is
|
||||
the async equivalent: paged supersteps-bounded WALK + per-channel
|
||||
UNION ALL FETCH.
|
||||
the async equivalent with internal stage-1 paging and per-channel
|
||||
UNION ALL stage-2.
|
||||
"""
|
||||
if not channels:
|
||||
return {}
|
||||
channels = list(channels)
|
||||
thread_id = config["configurable"]["thread_id"]
|
||||
if not thread_id:
|
||||
raise ValueError("empty thread ID")
|
||||
checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
|
||||
|
||||
# Resolve the target checkpoint id + its metadata (for supersteps).
|
||||
checkpoint_id = get_checkpoint_id(config)
|
||||
async with self._cursor() as cur:
|
||||
if checkpoint_id is None:
|
||||
await cur.execute(
|
||||
"SELECT checkpoint_id, metadata FROM checkpoints "
|
||||
"WHERE thread_id = %s AND checkpoint_ns = %s "
|
||||
"ORDER BY checkpoint_id DESC LIMIT 1",
|
||||
(thread_id, checkpoint_ns),
|
||||
)
|
||||
else:
|
||||
await cur.execute(
|
||||
"SELECT checkpoint_id, metadata FROM checkpoints "
|
||||
"WHERE thread_id = %s AND checkpoint_ns = %s "
|
||||
"AND checkpoint_id = %s",
|
||||
(thread_id, checkpoint_ns, checkpoint_id),
|
||||
)
|
||||
target_row = await cur.fetchone()
|
||||
if target_row is None:
|
||||
return {ch: {"writes": []} for ch in channels}
|
||||
target_id = cast(str, target_row["checkpoint_id"])
|
||||
supersteps_by_ch = _parse_supersteps_since_last_snapshot_by_channel(
|
||||
target_row["metadata"] or {}, channels
|
||||
)
|
||||
max_supersteps = max(supersteps_by_ch.values(), default=0)
|
||||
if not checkpoint_id:
|
||||
target = await self.aget_tuple(config)
|
||||
if target is None:
|
||||
return {ch: {"writes": []} for ch in channels}
|
||||
checkpoint_id = target.config["configurable"]["checkpoint_id"]
|
||||
|
||||
# WALK: page the parent chain, bounded by the deepest supersteps.
|
||||
walk_sql = _build_delta_walk_sql(channels)
|
||||
stage1_sql = _build_delta_stage1_sql(channels, paged=True, include_cursor=True)
|
||||
parent_of: dict[str, str | None] = {}
|
||||
ver_by_i_by_cid: list[dict[str, str | None]] = [{} for _ in channels]
|
||||
shared_cpid_chain: list[str] = []
|
||||
has_reached_root = False
|
||||
cursor: str | None = None
|
||||
hb_by_i_by_cid: list[dict[str, bool]] = [{} for _ in channels]
|
||||
inline_by_i_by_cid: list[dict[str, Any]] = [{} for _ in channels]
|
||||
chain_by_ch: dict[str, list[str]] = {ch: [] for ch in channels}
|
||||
seed_ver_by_ch: dict[str, str | None] = {ch: None for ch in channels}
|
||||
seed_inline_by_ch: dict[str, Any] = {}
|
||||
walk_cursor_by_ch: dict[str, str | None] = {}
|
||||
seeded: set[str] = set()
|
||||
cursor: str | None = checkpoint_id
|
||||
|
||||
while max_supersteps > 0:
|
||||
walk_params: list[Any] = [
|
||||
*channels,
|
||||
thread_id,
|
||||
checkpoint_ns,
|
||||
cursor,
|
||||
cursor,
|
||||
_DELTA_PAGE_SIZE,
|
||||
]
|
||||
async with self._cursor() as cur:
|
||||
await cur.execute(walk_sql, walk_params)
|
||||
async with self._cursor() as cur:
|
||||
while True:
|
||||
stage1_params: list[Any] = []
|
||||
for ch in channels:
|
||||
# ver_i, blob channel, blob version, inline_i
|
||||
stage1_params.extend([ch, ch, ch, ch])
|
||||
stage1_params.extend(
|
||||
[thread_id, checkpoint_ns, cursor, _DELTA_PAGE_SIZE]
|
||||
)
|
||||
await cur.execute(stage1_sql, stage1_params)
|
||||
page = await cur.fetchall()
|
||||
if not page:
|
||||
break
|
||||
oldest = _ingest_walk_page(
|
||||
cast("list[Mapping[str, Any]]", page),
|
||||
channels,
|
||||
parent_of,
|
||||
ver_by_i_by_cid,
|
||||
)
|
||||
has_reached_root = _advance_shared_chain(
|
||||
target_id, parent_of, shared_cpid_chain, max_supersteps
|
||||
)
|
||||
if (
|
||||
has_reached_root
|
||||
or len(shared_cpid_chain) >= max_supersteps
|
||||
or len(page) < _DELTA_PAGE_SIZE
|
||||
):
|
||||
break
|
||||
cursor = oldest
|
||||
if not page:
|
||||
break
|
||||
oldest = self._ingest_stage1_page(
|
||||
cast("list[Mapping[str, Any]]", page),
|
||||
channels,
|
||||
parent_of,
|
||||
ver_by_i_by_cid,
|
||||
hb_by_i_by_cid,
|
||||
inline_by_i_by_cid,
|
||||
)
|
||||
self._try_advance_walks(
|
||||
checkpoint_id,
|
||||
channels,
|
||||
parent_of,
|
||||
ver_by_i_by_cid,
|
||||
hb_by_i_by_cid,
|
||||
inline_by_i_by_cid,
|
||||
chain_by_ch,
|
||||
seed_ver_by_ch,
|
||||
seed_inline_by_ch,
|
||||
walk_cursor_by_ch,
|
||||
seeded,
|
||||
)
|
||||
if len(seeded) == len(channels) or len(page) < _DELTA_PAGE_SIZE:
|
||||
break
|
||||
cursor = oldest
|
||||
stage1_sql = _build_delta_stage1_sql(channels, paged=True)
|
||||
|
||||
chained_cpid_by_ch, seed_cpid_by_ch, seed_ver_by_ch = (
|
||||
self._resolve_delta_chains(
|
||||
channels,
|
||||
supersteps_by_ch,
|
||||
shared_cpid_chain,
|
||||
ver_by_i_by_cid,
|
||||
has_reached_root,
|
||||
thread_id,
|
||||
)
|
||||
)
|
||||
|
||||
# FETCH: per-channel UNION ALL — writes branch per chained channel,
|
||||
# blob branch per seeded channel.
|
||||
channels_with_chain = [ch for ch in channels if chained_cpid_by_ch[ch]]
|
||||
channels_with_chain = [ch for ch in channels if chain_by_ch[ch]]
|
||||
channels_with_seed = [ch for ch in channels if seed_ver_by_ch[ch] is not None]
|
||||
fetch_sql = _build_delta_fetch_sql(
|
||||
stage2_sql = _build_delta_stage2_sql(
|
||||
channels_with_chain=channels_with_chain,
|
||||
channels_with_seed=channels_with_seed,
|
||||
)
|
||||
|
||||
if fetch_sql:
|
||||
fetch_params: list[Any] = []
|
||||
if stage2_sql:
|
||||
stage2_params: list[Any] = []
|
||||
for ch in channels_with_chain:
|
||||
fetch_params.extend(
|
||||
[thread_id, checkpoint_ns, ch, chained_cpid_by_ch[ch]]
|
||||
)
|
||||
stage2_params.extend([thread_id, checkpoint_ns, ch, chain_by_ch[ch]])
|
||||
for ch in channels_with_seed:
|
||||
fetch_params.extend([thread_id, checkpoint_ns, ch, seed_ver_by_ch[ch]])
|
||||
stage2_params.extend([thread_id, checkpoint_ns, ch, seed_ver_by_ch[ch]])
|
||||
async with self._cursor() as cur:
|
||||
await cur.execute(fetch_sql, fetch_params)
|
||||
fetch_rows = await cur.fetchall()
|
||||
await cur.execute(stage2_sql, stage2_params)
|
||||
stage2_rows = await cur.fetchall()
|
||||
else:
|
||||
fetch_rows = []
|
||||
stage2_rows = []
|
||||
|
||||
return self._assemble_delta_history(
|
||||
return self._build_delta_channels_writes_history(
|
||||
channels=channels,
|
||||
chained_cpid_by_ch=chained_cpid_by_ch,
|
||||
seed_cpid_by_ch=seed_cpid_by_ch,
|
||||
chain_by_ch=chain_by_ch,
|
||||
seed_ver_by_ch=seed_ver_by_ch,
|
||||
fetch_rows=cast("list[_DeltaStage2Row]", fetch_rows),
|
||||
seed_inline_by_ch=seed_inline_by_ch,
|
||||
stage2_rows=cast("list[_DeltaStage2Row]", stage2_rows),
|
||||
)
|
||||
|
||||
async def _load_checkpoint_tuple(self, value: DictRow) -> CheckpointTuple:
|
||||
@@ -609,7 +635,7 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
||||
while True:
|
||||
try:
|
||||
yield asyncio.run_coroutine_threadsafe(
|
||||
anext(aiter_), # type: ignore[arg-type] # noqa: F821
|
||||
anext(aiter_), # type: ignore[arg-type]
|
||||
self.loop,
|
||||
).result()
|
||||
except StopAsyncIteration:
|
||||
@@ -716,5 +742,50 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
||||
self.adelete_thread(thread_id), self.loop
|
||||
).result()
|
||||
|
||||
def prune(
|
||||
self,
|
||||
thread_ids: Sequence[str],
|
||||
*,
|
||||
strategy: str = "keep_latest",
|
||||
) -> None:
|
||||
"""Prune checkpoints for the given threads.
|
||||
|
||||
See `PostgresSaver.prune` for what each strategy keeps.
|
||||
|
||||
Args:
|
||||
thread_ids: The thread IDs to prune.
|
||||
strategy: `"keep_latest"` or `"delete"`.
|
||||
"""
|
||||
try:
|
||||
if asyncio.get_running_loop() is self.loop:
|
||||
raise asyncio.InvalidStateError(
|
||||
"Synchronous calls to AsyncPostgresSaver are only allowed from a "
|
||||
"different thread. From the main thread, use the async interface. "
|
||||
"For example, use `await checkpointer.aprune(...)`."
|
||||
)
|
||||
except RuntimeError:
|
||||
pass
|
||||
return asyncio.run_coroutine_threadsafe(
|
||||
self.aprune(thread_ids, strategy=strategy), self.loop
|
||||
).result()
|
||||
|
||||
def get_delta_channel_history(
|
||||
self, *, config: RunnableConfig, channels: Sequence[str]
|
||||
) -> Mapping[str, DeltaChannelHistory]:
|
||||
"""Sync bridge to `aget_delta_channel_history`, guarded like `get_tuple`."""
|
||||
try:
|
||||
if asyncio.get_running_loop() is self.loop:
|
||||
raise asyncio.InvalidStateError(
|
||||
"Synchronous calls to AsyncPostgresSaver are only allowed from a "
|
||||
"different thread. From the main thread, use the async interface. "
|
||||
"For example, use `await checkpointer.aget_delta_channel_history(...)`."
|
||||
)
|
||||
except RuntimeError:
|
||||
pass
|
||||
return asyncio.run_coroutine_threadsafe(
|
||||
self.aget_delta_channel_history(config=config, channels=channels),
|
||||
self.loop,
|
||||
).result()
|
||||
|
||||
|
||||
__all__ = ["AsyncPostgresSaver", "AsyncShallowPostgresSaver", "Conn"]
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import random
|
||||
import warnings
|
||||
from collections.abc import Mapping, Sequence
|
||||
from collections.abc import Generator, Mapping, Sequence
|
||||
from importlib.metadata import version as get_version
|
||||
from typing import Any, TypedDict, cast
|
||||
|
||||
@@ -15,13 +14,14 @@ from langgraph.checkpoint.base import (
|
||||
DeltaChannelHistory,
|
||||
PendingWrite,
|
||||
get_checkpoint_id,
|
||||
writes_sort_key,
|
||||
)
|
||||
from langgraph.checkpoint.serde.types import TASKS, _DeltaSnapshot
|
||||
from langgraph.checkpoint.serde.types import TASKS
|
||||
from psycopg import AsyncCursor, Cursor
|
||||
from psycopg.rows import DictRow
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Page size for the paged WALK scan in `get_delta_channel_history`. Internal
|
||||
# Page size for stage-1 paged scan in `get_delta_channel_history`. Internal
|
||||
# constant — exposing this as a kwarg is left as a follow-up.
|
||||
_DELTA_PAGE_SIZE = 1024
|
||||
|
||||
@@ -112,7 +112,7 @@ select
|
||||
) as channel_values,
|
||||
(
|
||||
select
|
||||
array_agg(array[cw.task_id::text::bytea, cw.channel::bytea, cw.type::bytea, cw.blob] order by cw.task_id, cw.idx)
|
||||
array_agg(array[cw.task_id::text::bytea, cw.channel::bytea, cw.type::bytea, cw.blob, convert_to(cw.task_path, 'UTF8'), cw.idx::text::bytea])
|
||||
from checkpoint_writes cw
|
||||
where cw.thread_id = checkpoints.thread_id
|
||||
and cw.checkpoint_ns = checkpoints.checkpoint_ns
|
||||
@@ -162,8 +162,220 @@ INSERT_CHECKPOINT_WRITES_SQL = """
|
||||
"""
|
||||
|
||||
|
||||
PRUNE_STRATEGIES = ("keep_latest", "delete")
|
||||
|
||||
PRUNE_DELETE_THREADS_SQL = (
|
||||
"DELETE FROM checkpoints WHERE thread_id = ANY(%s)",
|
||||
"DELETE FROM checkpoint_blobs WHERE thread_id = ANY(%s)",
|
||||
"DELETE FROM checkpoint_writes WHERE thread_id = ANY(%s)",
|
||||
)
|
||||
|
||||
# The prune statements below never join two sets of a thread's rows: a join is
|
||||
# planned from per-thread row estimates, which are worst for the few huge
|
||||
# threads worth pruning, and can go quadratic. They look rows up by primary key
|
||||
# instead (`LATERAL ... LIMIT 1`, `= ANY`).
|
||||
|
||||
# A `DeltaChannel` leaves update counters in checkpoint metadata, or a
|
||||
# snapshot: an inline `true` marker with a blob behind it, which a plain `True`
|
||||
# value never has. LangGraph.js inlines no values, but marks the threads its
|
||||
# DeltaChannel graphs start with `delta_writes_versioned`.
|
||||
PRUNE_DELTA_THREADS_SQL = """
|
||||
SELECT thread_id FROM checkpoints
|
||||
WHERE thread_id = ANY(%s) AND (
|
||||
metadata ? 'counters_since_delta_snapshot'
|
||||
OR metadata ? 'delta_writes_versioned'
|
||||
)
|
||||
UNION
|
||||
SELECT c.thread_id
|
||||
FROM checkpoints c
|
||||
CROSS JOIN LATERAL jsonb_each(c.checkpoint -> 'channel_values') AS v
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT 1 FROM checkpoint_blobs b
|
||||
WHERE b.thread_id = c.thread_id
|
||||
AND b.checkpoint_ns = c.checkpoint_ns
|
||||
AND b.channel = v.key
|
||||
AND b.version = c.checkpoint -> 'channel_versions' ->> v.key
|
||||
LIMIT 1
|
||||
) AS snapshot
|
||||
WHERE c.thread_id = ANY(%s) AND v.value = 'true'::jsonb
|
||||
"""
|
||||
|
||||
# Per namespace: the latest checkpoint, the parent it reads its pending sends
|
||||
# from when it predates v4, and the root graph's checkpoint its subgraph call
|
||||
# started from, with whether that one is saved.
|
||||
PRUNE_HEADS_SQL = f"""
|
||||
SELECT
|
||||
h.checkpoint_ns,
|
||||
h.checkpoint_id AS head,
|
||||
CASE WHEN (c.checkpoint ->> 'v')::int < 4 AND EXISTS (
|
||||
SELECT 1 FROM checkpoint_writes w
|
||||
WHERE w.thread_id = %s
|
||||
AND w.checkpoint_ns = h.checkpoint_ns
|
||||
AND w.checkpoint_id = c.parent_checkpoint_id
|
||||
AND w.channel = '{TASKS}'
|
||||
) THEN c.parent_checkpoint_id END AS sends_parent,
|
||||
c.metadata -> 'parents' ->> '' AS root,
|
||||
EXISTS (
|
||||
SELECT 1 FROM checkpoints r
|
||||
WHERE r.thread_id = %s AND r.checkpoint_ns = ''
|
||||
AND r.checkpoint_id = c.metadata -> 'parents' ->> ''
|
||||
) AS root_saved
|
||||
FROM (
|
||||
SELECT DISTINCT ON (checkpoint_ns) checkpoint_ns, checkpoint_id
|
||||
FROM checkpoints
|
||||
WHERE thread_id = %s
|
||||
ORDER BY checkpoint_ns DESC, checkpoint_id DESC
|
||||
) AS h
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT checkpoint, parent_checkpoint_id, metadata FROM checkpoints
|
||||
WHERE thread_id = %s
|
||||
AND checkpoint_ns = h.checkpoint_ns
|
||||
AND checkpoint_id = h.checkpoint_id
|
||||
) AS c
|
||||
"""
|
||||
|
||||
PRUNE_SNAPSHOT_SQL = "SET TRANSACTION ISOLATION LEVEL REPEATABLE READ"
|
||||
|
||||
PRUNE_UNKEPT_SQL = """
|
||||
SELECT checkpoint_id FROM checkpoints
|
||||
WHERE thread_id = %s AND checkpoint_ns = %s AND checkpoint_id <> ALL(%s)
|
||||
"""
|
||||
|
||||
# A run saves a superstep's writes in the background, where they can commit
|
||||
# before their checkpoint does, so they go by the checkpoints deleted.
|
||||
PRUNE_WRITES_SQL = """
|
||||
DELETE FROM checkpoint_writes
|
||||
WHERE thread_id = %s AND checkpoint_ns = %s AND checkpoint_id = ANY(%s)
|
||||
"""
|
||||
|
||||
PRUNE_CHECKPOINTS_SQL = """
|
||||
WITH deleted AS (
|
||||
DELETE FROM checkpoints
|
||||
WHERE thread_id = %s AND checkpoint_ns = %s AND checkpoint_id <> ALL(%s)
|
||||
RETURNING checkpoint -> 'channel_versions' AS versions
|
||||
)
|
||||
SELECT DISTINCT v.key AS channel, v.value AS version
|
||||
FROM deleted
|
||||
CROSS JOIN LATERAL jsonb_each_text(deleted.versions) AS v
|
||||
"""
|
||||
|
||||
PRUNE_PARENTS_SQL = """
|
||||
UPDATE checkpoints SET parent_checkpoint_id = NULL
|
||||
WHERE thread_id = %s AND checkpoint_ns = %s
|
||||
AND checkpoint_id = ANY(%s) AND parent_checkpoint_id <> ALL(%s)
|
||||
"""
|
||||
|
||||
PRUNE_REFERENCED_BLOBS_SQL = """
|
||||
SELECT DISTINCT v.key AS channel, v.value AS version
|
||||
FROM checkpoints c
|
||||
CROSS JOIN LATERAL jsonb_each_text(c.checkpoint -> 'channel_versions') AS v
|
||||
WHERE c.thread_id = %s AND c.checkpoint_ns = %s
|
||||
"""
|
||||
|
||||
PRUNE_BLOBS_SQL = """
|
||||
DELETE FROM checkpoint_blobs
|
||||
WHERE thread_id = %s AND checkpoint_ns = %s AND channel = %s AND version = ANY(%s)
|
||||
"""
|
||||
|
||||
PRUNE_SUBGRAPH_CALLS_SQL = (
|
||||
"DELETE FROM checkpoints WHERE thread_id = %s AND checkpoint_ns = ANY(%s)",
|
||||
"DELETE FROM checkpoint_writes WHERE thread_id = %s AND checkpoint_ns = ANY(%s)",
|
||||
"DELETE FROM checkpoint_blobs WHERE thread_id = %s AND checkpoint_ns = ANY(%s)",
|
||||
)
|
||||
|
||||
|
||||
def _garbage_blobs(
|
||||
freed: Sequence[Mapping[str, Any]], referenced: Sequence[Mapping[str, Any]]
|
||||
) -> dict[str, list[str]]:
|
||||
"""Blobs a deleted checkpoint used and no kept one does, by channel."""
|
||||
kept = {(r["channel"], r["version"]) for r in referenced}
|
||||
garbage: dict[str, list[str]] = {}
|
||||
for r in freed:
|
||||
if (r["channel"], r["version"]) not in kept:
|
||||
garbage.setdefault(r["channel"], []).append(r["version"])
|
||||
return garbage
|
||||
|
||||
|
||||
_Statements = Generator[tuple[str, Sequence[Any], bool], list[DictRow], None]
|
||||
|
||||
|
||||
def _refuse_delta_threads(thread_ids: Sequence[str]) -> _Statements:
|
||||
rows = yield PRUNE_DELTA_THREADS_SQL, (thread_ids, thread_ids), True
|
||||
if delta := sorted(r["thread_id"] for r in rows):
|
||||
raise NotImplementedError(
|
||||
f"keep_latest can't prune threads that use a DeltaChannel: {', '.join(delta)}"
|
||||
)
|
||||
|
||||
|
||||
def _keep_latest(thread_id: str) -> _Statements:
|
||||
"""The statements of one thread's keep_latest transaction, for `_execute`
|
||||
and `_aexecute` to run. Each comes with whether its rows are sent back.
|
||||
|
||||
They all read one snapshot, so what a run saves meanwhile is neither seen
|
||||
nor deleted, whatever its checkpoint ID.
|
||||
"""
|
||||
yield PRUNE_SNAPSHOT_SQL, (), False
|
||||
heads = yield PRUNE_HEADS_SQL, (thread_id,) * 4, True
|
||||
# Catches a thread that started using a DeltaChannel after the batch check.
|
||||
yield from _refuse_delta_threads([thread_id])
|
||||
kept = {
|
||||
h["checkpoint_ns"]: [i for i in (h["head"], h["sends_parent"]) if i]
|
||||
for h in heads
|
||||
}
|
||||
# A subgraph call's namespace has its task ID after a ":", unlike a
|
||||
# `checkpointer=True` subgraph's, whose state outlives each call. A call
|
||||
# goes with the root checkpoint it started from, but only once that one is
|
||||
# saved: runs save in the background, so a call can commit first.
|
||||
calls = {
|
||||
h["checkpoint_ns"]
|
||||
for h in heads
|
||||
if ":" in h["checkpoint_ns"]
|
||||
and h["root_saved"]
|
||||
and h["root"] not in kept.get("", ())
|
||||
}
|
||||
if calls:
|
||||
for sql in PRUNE_SUBGRAPH_CALLS_SQL:
|
||||
yield sql, (thread_id, list(calls)), False
|
||||
for ns, ids in kept.items():
|
||||
if ns in calls:
|
||||
continue
|
||||
unkept = yield PRUNE_UNKEPT_SQL, (thread_id, ns, ids), True
|
||||
if unkept:
|
||||
deleted = [r["checkpoint_id"] for r in unkept]
|
||||
yield PRUNE_WRITES_SQL, (thread_id, ns, deleted), False
|
||||
freed = yield PRUNE_CHECKPOINTS_SQL, (thread_id, ns, ids), True
|
||||
yield PRUNE_PARENTS_SQL, (thread_id, ns, ids, ids), False
|
||||
if freed:
|
||||
referenced = yield PRUNE_REFERENCED_BLOBS_SQL, (thread_id, ns), True
|
||||
for channel, versions in _garbage_blobs(freed, referenced).items():
|
||||
yield PRUNE_BLOBS_SQL, (thread_id, ns, channel, versions), False
|
||||
|
||||
|
||||
# Unprepared, so Postgres plans each statement knowing its arrays: a cached
|
||||
# generic plan checks a row against an `= ANY(...)` array one element at a time.
|
||||
def _execute(cur: Cursor[DictRow], statements: _Statements) -> None:
|
||||
try:
|
||||
sql, params, fetch = next(statements)
|
||||
while True:
|
||||
cur.execute(sql, params, prepare=False)
|
||||
sql, params, fetch = statements.send(cur.fetchall() if fetch else [])
|
||||
except StopIteration:
|
||||
pass
|
||||
|
||||
|
||||
async def _aexecute(cur: AsyncCursor[DictRow], statements: _Statements) -> None:
|
||||
try:
|
||||
sql, params, fetch = next(statements)
|
||||
while True:
|
||||
await cur.execute(sql, params, prepare=False)
|
||||
rows = await cur.fetchall() if fetch else []
|
||||
sql, params, fetch = statements.send(rows)
|
||||
except StopIteration:
|
||||
pass
|
||||
|
||||
|
||||
class _DeltaStage2Row(TypedDict, total=False):
|
||||
"""One row from `_build_delta_fetch_sql` (a UNION ALL of writes and blobs)."""
|
||||
"""One row from `_build_delta_stage2_sql` (a UNION ALL of writes and blobs)."""
|
||||
|
||||
_kind: str # "w" or "b"
|
||||
checkpoint_id: str | None # "w" rows only
|
||||
@@ -171,101 +383,128 @@ class _DeltaStage2Row(TypedDict, total=False):
|
||||
type: str | None
|
||||
blob: bytes | None
|
||||
task_id: str | None # "w" rows only
|
||||
task_path: str | None # "w" rows only
|
||||
idx: int | None # "w" rows only
|
||||
version: str | None # "b" rows only
|
||||
|
||||
|
||||
# Multi-channel two-pass DeltaChannel reconstruction.
|
||||
#
|
||||
# A `DeltaChannel` does not store its full value at every checkpoint — it
|
||||
# stores periodic full-value *snapshots* and accumulates intermediate
|
||||
# *writes* between snapshots. To rebuild a channel's value at a target
|
||||
# checkpoint we need:
|
||||
#
|
||||
# - the **seed** — the most recent snapshot at-or-before the target
|
||||
# (a single blob row in `checkpoint_blobs`); and
|
||||
# - the **chain writes** — every write for this channel committed
|
||||
# between that snapshot and the target, in order
|
||||
# (rows in `checkpoint_writes`).
|
||||
#
|
||||
# Two passes, in order:
|
||||
#
|
||||
# 1. WALK — scan checkpoint metadata only (no blob bytes). For each
|
||||
# requested channel we read `channel_versions[ch]` (the seed's blob
|
||||
# version pointer). Then follow `parent_checkpoint_id` from the
|
||||
# target backwards in pages of `_DELTA_PAGE_SIZE` rows.
|
||||
#
|
||||
# Walk depth is driven by the *supersteps since last snapshot*
|
||||
# counter — `metadata.counters_since_delta_snapshot[ch][1]` — read
|
||||
# from the target checkpoint. A channel's seed snapshot sits exactly
|
||||
# `supersteps` hops back along the parent chain; walking by the
|
||||
# counter (rather than scanning `channel_values` for the snapshot
|
||||
# marker) is the only reliable way to locate seeds that aren't a
|
||||
# `_DeltaSnapshot` sentinel — e.g. legacy plain-value blobs left by a
|
||||
# thread that migrated from a non-delta channel, which `put` stores
|
||||
# out of the inline `channel_values` map.
|
||||
#
|
||||
# 2. FETCH — given each channel's chained checkpoint ids and seed
|
||||
# version from WALK, pull only the rows we need: writes for those
|
||||
# exact checkpoint_ids and the seed blob at that exact version. One
|
||||
# roundtrip, per-channel UNION ALL — no over-fetch.
|
||||
#
|
||||
# Walking the *parent chain* (not `list(before=...)`) matters: forked
|
||||
# threads have multiple branches, and only on-path ancestors contribute.
|
||||
# Delta history is rebuilt in two queries; `_build_delta_stage1_sql` and
|
||||
# `_build_delta_stage2_sql` document their shapes.
|
||||
|
||||
|
||||
def _build_delta_walk_sql(channels: Sequence[str]) -> str:
|
||||
"""Build the paged WALK SQL — scans checkpoint metadata only.
|
||||
def _build_delta_stage1_sql(
|
||||
channels: Sequence[str], *, paged: bool, include_cursor: bool = False
|
||||
) -> str:
|
||||
"""Build stage 1 SQL with K parallel version lookups + seed probes.
|
||||
|
||||
Emits one row per checkpoint with K parallel JSONB key lookups (one
|
||||
`ver_i` column per requested channel — the channel's blob version, the
|
||||
pointer we dereference in FETCH if this checkpoint is the seed). No
|
||||
blob bytes; the result set fits a paged `LIMIT` cleanly.
|
||||
|
||||
For channels=["messages", "files"] the result is::
|
||||
For channels=["messages", "files"] (with `paged=True, include_cursor=True`)
|
||||
the result is::
|
||||
|
||||
SELECT checkpoint_id, parent_checkpoint_id,
|
||||
checkpoint -> 'channel_versions' ->> %s AS ver_0,
|
||||
checkpoint -> 'channel_versions' ->> %s AS ver_1
|
||||
EXISTS (SELECT 1 FROM checkpoint_blobs b0
|
||||
WHERE b0.thread_id = checkpoints.thread_id
|
||||
AND b0.checkpoint_ns = checkpoints.checkpoint_ns
|
||||
AND b0.channel = %s
|
||||
AND b0.version = checkpoint -> 'channel_versions' ->> %s
|
||||
AND b0.type <> 'empty') AS hb_0,
|
||||
checkpoint -> 'channel_values' -> %s AS inline_0,
|
||||
checkpoint -> 'channel_versions' ->> %s AS ver_1,
|
||||
EXISTS (...) AS hb_1,
|
||||
checkpoint -> 'channel_values' -> %s AS inline_1
|
||||
FROM checkpoints
|
||||
WHERE thread_id = %s AND checkpoint_ns = %s
|
||||
AND (%s::text IS NULL OR checkpoint_id < %s)
|
||||
AND checkpoint_id <= %s
|
||||
ORDER BY checkpoint_id DESC
|
||||
LIMIT %s
|
||||
|
||||
Channel names are passed as `%s` parameters (safe from SQL injection).
|
||||
Only the column aliases `ver_i` are interpolated into the SQL string
|
||||
(i is bounded by len(channels) and uses safe identifiers).
|
||||
A stored value for a channel lives in one of two places, because `put`
|
||||
splits them:
|
||||
|
||||
Caller must extend params with `[ch_0, ch_1, ..., thread_id, ns,
|
||||
cursor, cursor, page_size]`. The `cursor` is the smallest
|
||||
`checkpoint_id` from the previous page (or `None` on the first page);
|
||||
`(%s::text IS NULL OR ...)` makes the first-page `WHERE` a no-op.
|
||||
* **blob** — non-primitive values (and `_DeltaSnapshot`) are moved to
|
||||
`checkpoint_blobs`. `hb_i` ("has blob") probes for one. The probe hits
|
||||
that table's primary key `(thread_id, checkpoint_ns, channel, version)`
|
||||
exactly, so it is an index lookup per row per channel.
|
||||
* **inline** — `None`, `str`, `int`, `float` and `bool` stay in the
|
||||
checkpoint's own `channel_values` and get no blob row at all. `inline_i`
|
||||
returns that value.
|
||||
|
||||
Testing only for a key in `channel_values` (the previous approach) missed
|
||||
blob-stored plain values, since `put` leaves an inline marker there for
|
||||
`_DeltaSnapshot` but not for a plain value — which is what a thread
|
||||
migrated from a pre-delta channel type leaves behind. Probing only the
|
||||
blobs table would conversely miss inline primitives. Both are needed, and
|
||||
the caller treats "either present" as the seed.
|
||||
|
||||
`hb_i` also disambiguates the two: for a `_DeltaSnapshot`, `inline_i` is the
|
||||
literal `true` marker rather than the value, so a blob must win over an
|
||||
inline reading whenever one exists. That ordering is what makes a genuine
|
||||
inline `true` (a bool channel) distinguishable from the marker.
|
||||
|
||||
The `type <> 'empty'` predicate mirrors the check stage 2 already applies
|
||||
when resolving the seed blob. `put` does not currently produce `empty` rows
|
||||
on this path — `blob_versions` is filtered to keys present in
|
||||
`channel_values`, so `_dump_blobs`' empty branch is unreachable from it —
|
||||
but without the predicate the two stages could disagree: stage 1 would
|
||||
terminate the walk on a row stage 2 then discards, yielding no seed *and* a
|
||||
truncated write chain, which is the failure this function exists to avoid.
|
||||
|
||||
Channel names are passed as `%s` parameters (safe from SQL injection).
|
||||
Only the column aliases `ver_i` / `hb_i` / `inline_i` and the subquery alias
|
||||
`b{i}` are interpolated into the SQL string (i is bounded by len(channels)
|
||||
and uses safe identifiers).
|
||||
|
||||
Caller must extend params with `[ch_0 x4, ch_1 x4, ..., thread_id, ns,
|
||||
cursor, page_size]` when `paged=True` — four per channel: the version
|
||||
lookup, the blob's channel, the version the blob must match, and the inline
|
||||
lookup.
|
||||
|
||||
Pages run newest-first from the target down. The first page passes the
|
||||
target as the cursor with `include_cursor=True`, so it opens with the
|
||||
target's own row, whose parent starts the walk; each later page continues
|
||||
below the oldest row read. A checkpoint's ancestors have smaller ids (uuid6
|
||||
is time-ordered, which `get_tuple` also relies on to find the latest
|
||||
checkpoint), so no row newer than the target is part of its chain.
|
||||
|
||||
When `paged=False`, the WHERE has no cursor predicate and there's no
|
||||
LIMIT/ORDER BY — kept as a non-public helper for tests/diagnostics.
|
||||
"""
|
||||
cols = [
|
||||
f"checkpoint -> 'channel_versions' ->> %s AS ver_{i}"
|
||||
for i in range(len(channels))
|
||||
]
|
||||
return (
|
||||
cols = []
|
||||
for i in range(len(channels)):
|
||||
cols.append(
|
||||
f"checkpoint -> 'channel_versions' ->> %s AS ver_{i}, "
|
||||
f"EXISTS (SELECT 1 FROM checkpoint_blobs b{i} "
|
||||
f"WHERE b{i}.thread_id = checkpoints.thread_id "
|
||||
f"AND b{i}.checkpoint_ns = checkpoints.checkpoint_ns "
|
||||
f"AND b{i}.channel = %s "
|
||||
f"AND b{i}.version = checkpoint -> 'channel_versions' ->> %s "
|
||||
f"AND b{i}.type <> 'empty') AS hb_{i}, "
|
||||
f"checkpoint -> 'channel_values' -> %s AS inline_{i}"
|
||||
)
|
||||
sql = (
|
||||
"SELECT checkpoint_id, parent_checkpoint_id, "
|
||||
+ ", ".join(cols)
|
||||
+ " FROM checkpoints WHERE thread_id = %s AND checkpoint_ns = %s"
|
||||
" AND (%s::text IS NULL OR checkpoint_id < %s)"
|
||||
" ORDER BY checkpoint_id DESC LIMIT %s"
|
||||
)
|
||||
if paged:
|
||||
sql += (
|
||||
f" AND checkpoint_id {'<=' if include_cursor else '<'} %s"
|
||||
" ORDER BY checkpoint_id DESC LIMIT %s"
|
||||
)
|
||||
return sql
|
||||
|
||||
|
||||
def _build_delta_fetch_sql(
|
||||
def _build_delta_stage2_sql(
|
||||
*,
|
||||
channels_with_chain: Sequence[str],
|
||||
channels_with_seed: Sequence[str],
|
||||
) -> str:
|
||||
"""Build the FETCH SQL as a per-channel UNION ALL.
|
||||
"""Build stage 2 SQL as a per-channel UNION ALL.
|
||||
|
||||
For each channel with a non-empty chain, emit one branch reading
|
||||
`checkpoint_writes` for that specific channel + chain_cids. For each
|
||||
channel with a seed_version, emit one branch reading `checkpoint_blobs`
|
||||
for that channel + version. This avoids the over-fetch of a single
|
||||
for that channel + version. This avoids the over-fetch of the prior
|
||||
`channel = ANY(channels) AND checkpoint_id = ANY(union)` form when
|
||||
channels have different chain depths.
|
||||
|
||||
@@ -281,11 +520,10 @@ def _build_delta_fetch_sql(
|
||||
"""
|
||||
branches: list[str] = []
|
||||
for _ in channels_with_chain:
|
||||
# NOTE: no ORDER BY on this branch — writes are sorted in assembly.
|
||||
branches.append(
|
||||
"SELECT 'w'::text AS _kind, "
|
||||
"checkpoint_id, channel, "
|
||||
"type, blob, task_id, idx, NULL::text AS version "
|
||||
"type, blob, task_id, task_path, idx, NULL::text AS version "
|
||||
"FROM checkpoint_writes "
|
||||
"WHERE thread_id = %s AND checkpoint_ns = %s AND channel = %s "
|
||||
"AND checkpoint_id = ANY(%s)"
|
||||
@@ -293,7 +531,8 @@ def _build_delta_fetch_sql(
|
||||
for _ in channels_with_seed:
|
||||
branches.append(
|
||||
"SELECT 'b'::text AS _kind, NULL::text AS checkpoint_id, channel, "
|
||||
"type, blob, NULL::text AS task_id, NULL::int AS idx, version "
|
||||
"type, blob, NULL::text AS task_id, NULL::text AS task_path, "
|
||||
"NULL::int AS idx, version "
|
||||
"FROM checkpoint_blobs "
|
||||
"WHERE thread_id = %s AND checkpoint_ns = %s AND channel = %s "
|
||||
"AND version = %s"
|
||||
@@ -301,59 +540,8 @@ def _build_delta_fetch_sql(
|
||||
return " UNION ALL ".join(branches)
|
||||
|
||||
|
||||
def _ingest_walk_page(
|
||||
page_rows: Sequence[Mapping[str, Any]],
|
||||
channels: Sequence[str],
|
||||
parent_of: dict[str, str | None],
|
||||
ver_by_i_by_cid: list[dict[str, str | None]],
|
||||
) -> str | None:
|
||||
"""Fold one WALK page into `parent_of` + per-channel `ver_by_cid`.
|
||||
|
||||
Returns the oldest checkpoint_id seen on this page (smallest, since
|
||||
pages come back DESC). Caller uses it as the cursor for the next page
|
||||
(`AND checkpoint_id < cursor`).
|
||||
"""
|
||||
oldest: str | None = None
|
||||
for r in page_rows:
|
||||
cid = cast(str, r["checkpoint_id"])
|
||||
parent_of[cid] = cast("str | None", r["parent_checkpoint_id"])
|
||||
for i in range(len(channels)):
|
||||
ver_by_i_by_cid[i][cid] = cast("str | None", r.get(f"ver_{i}"))
|
||||
# Rows are DESC; the last one is the smallest cid in the page.
|
||||
oldest = cid
|
||||
return oldest
|
||||
|
||||
|
||||
def _advance_shared_chain(
|
||||
target_id: str,
|
||||
parent_of: Mapping[str, str | None],
|
||||
shared_cpid_chain: list[str],
|
||||
max_supersteps: int,
|
||||
) -> bool:
|
||||
"""Extend the shared parent chain as far as ingested pages allow.
|
||||
|
||||
The chain holds ancestors of the target, newest first: `chain[0]` is
|
||||
the target's parent, `chain[1]` its grandparent, and so on. A single
|
||||
chain is shared across all channels and grown to the maximum requested
|
||||
depth; each channel later slices `chain[:supersteps]`.
|
||||
|
||||
Stops when:
|
||||
- the chain reaches `max_supersteps` hops, OR
|
||||
- the root is reached (parent is None) — returns True, OR
|
||||
- the next ancestor's cid isn't in `parent_of` yet (waits for the
|
||||
next page).
|
||||
|
||||
Returns True iff the root was reached.
|
||||
"""
|
||||
while len(shared_cpid_chain) < max_supersteps:
|
||||
top_of_chain = shared_cpid_chain[-1] if shared_cpid_chain else target_id
|
||||
if top_of_chain not in parent_of:
|
||||
return False # wait for the next page
|
||||
parent = parent_of[top_of_chain]
|
||||
if parent is None:
|
||||
return True # hit the root
|
||||
shared_cpid_chain.append(parent)
|
||||
return False
|
||||
# Stage 1 rows are dicts keyed by the per-channel aliases
|
||||
# `_build_delta_stage1_sql` emits, so there is no static TypedDict.
|
||||
|
||||
|
||||
class BasePostgresSaver(BaseCheckpointSaver[str]):
|
||||
@@ -398,132 +586,181 @@ class BasePostgresSaver(BaseCheckpointSaver[str]):
|
||||
if t.decode() != "empty"
|
||||
}
|
||||
|
||||
def _resolve_delta_chains(
|
||||
self,
|
||||
@staticmethod
|
||||
def _ingest_stage1_page(
|
||||
stage1_rows: Sequence[Mapping[str, Any]],
|
||||
channels: Sequence[str],
|
||||
supersteps_by_ch: Mapping[str, int],
|
||||
shared_cpid_chain: Sequence[str],
|
||||
ver_by_i_by_cid: Sequence[Mapping[str, str | None]],
|
||||
has_reached_root: bool,
|
||||
thread_id: str,
|
||||
) -> tuple[
|
||||
dict[str, list[str]],
|
||||
dict[str, str | None],
|
||||
dict[str, str | None],
|
||||
]:
|
||||
"""Slice the shared parent chain into per-channel chain/seed mappings.
|
||||
parent_of: dict[str, str | None],
|
||||
ver_by_i_by_cid: list[dict[str, str | None]],
|
||||
hb_by_i_by_cid: list[dict[str, bool]],
|
||||
inline_by_i_by_cid: list[dict[str, Any]],
|
||||
) -> str | None:
|
||||
"""Fold one stage-1 page into the running walk-state mappings.
|
||||
|
||||
For each channel the seed snapshot sits `supersteps` hops back, so
|
||||
the seed checkpoint is `shared_cpid_chain[supersteps - 1]` and the
|
||||
chain is `shared_cpid_chain[:supersteps]` (newest first).
|
||||
|
||||
When the chain is shorter than `supersteps` but the walk reached the
|
||||
root, the persisted chain is "compressed" relative to the logical
|
||||
superstep count — either because intermediate supersteps were never
|
||||
persisted (`durability="exit"`) or because the thread never produced
|
||||
a snapshot at all. In both cases the seed candidate is the oldest
|
||||
persisted checkpoint (`shared_cpid_chain[-1]`): FETCH loads its blob,
|
||||
and assembly keeps it only if non-empty (a real snapshot or migrated
|
||||
value) — otherwise it omits `seed` and replays the full chain on an
|
||||
empty baseline.
|
||||
Returns the oldest checkpoint_id seen on this page (smallest, since
|
||||
pages come back DESC). Caller uses it as the cursor for the next
|
||||
page (`AND checkpoint_id < cursor`).
|
||||
"""
|
||||
chained_cpid_by_ch: dict[str, list[str]] = {ch: [] for ch in channels}
|
||||
seed_cpid_by_ch: dict[str, str | None] = {ch: None for ch in channels}
|
||||
seed_ver_by_ch: dict[str, str | None] = {ch: None for ch in channels}
|
||||
for i, ch in enumerate(channels):
|
||||
bound = supersteps_by_ch.get(ch, 0)
|
||||
if bound <= 0:
|
||||
continue
|
||||
if len(shared_cpid_chain) >= bound:
|
||||
seed_depth = bound
|
||||
elif has_reached_root:
|
||||
seed_depth = len(shared_cpid_chain)
|
||||
else:
|
||||
logger.warning(
|
||||
"cannot find seed snapshot for delta channel "
|
||||
"(thread_id=%s, channel=%s)",
|
||||
thread_id,
|
||||
ch,
|
||||
)
|
||||
continue
|
||||
if seed_depth <= 0:
|
||||
continue
|
||||
chained_cpid_by_ch[ch] = list(shared_cpid_chain[:seed_depth])
|
||||
seed_cpid_by_ch[ch] = shared_cpid_chain[seed_depth - 1]
|
||||
seed_ver_by_ch[ch] = ver_by_i_by_cid[i].get(seed_cpid_by_ch[ch])
|
||||
return chained_cpid_by_ch, seed_cpid_by_ch, seed_ver_by_ch
|
||||
oldest: str | None = None
|
||||
for r in stage1_rows:
|
||||
cid = cast(str, r["checkpoint_id"])
|
||||
parent_of[cid] = cast("str | None", r["parent_checkpoint_id"])
|
||||
for i in range(len(channels)):
|
||||
ver_by_i_by_cid[i][cid] = cast("str | None", r.get(f"ver_{i}"))
|
||||
hb_by_i_by_cid[i][cid] = bool(r.get(f"hb_{i}"))
|
||||
inline_by_i_by_cid[i][cid] = r.get(f"inline_{i}")
|
||||
# Rows are DESC; the last one is the smallest cid in the page.
|
||||
oldest = cid
|
||||
return oldest
|
||||
|
||||
def _assemble_delta_history(
|
||||
@staticmethod
|
||||
def _try_advance_walks(
|
||||
target_id: str,
|
||||
channels: Sequence[str],
|
||||
parent_of: Mapping[str, str | None],
|
||||
ver_by_i_by_cid: Sequence[Mapping[str, str | None]],
|
||||
hb_by_i_by_cid: Sequence[Mapping[str, bool]],
|
||||
inline_by_i_by_cid: Sequence[Mapping[str, Any]],
|
||||
chain_by_ch: dict[str, list[str]],
|
||||
seed_ver_by_ch: dict[str, str | None],
|
||||
seed_inline_by_ch: dict[str, Any],
|
||||
walk_cursor_by_ch: dict[str, str | None],
|
||||
seeded: set[str],
|
||||
) -> None:
|
||||
"""Advance each not-yet-seeded channel's walk as far as possible.
|
||||
|
||||
Uses the partial `parent_of` map accumulated so far. A walk stops
|
||||
either because:
|
||||
(a) it found a stored value for its channel — a blob or an inline
|
||||
primitive (channel becomes seeded),
|
||||
(b) it reached a real root (parent_of[cid] is None — fully
|
||||
materialized at this point),
|
||||
(c) the next ancestor cid isn't in `parent_of` yet (waiting for
|
||||
a later page; the cursor stays put), or
|
||||
(d) the target's own row isn't in `parent_of` (the target doesn't
|
||||
exist, so the walk never starts).
|
||||
|
||||
Mutates `chain_by_ch`, `seed_ver_by_ch`, `seed_inline_by_ch`,
|
||||
`walk_cursor_by_ch`, and `seeded` in place.
|
||||
"""
|
||||
for i, ch in enumerate(channels):
|
||||
if ch in seeded:
|
||||
continue
|
||||
# The first page opens with the target's row, so it's missing only
|
||||
# when the target doesn't exist; a `None` cursor would read as
|
||||
# "target is a root".
|
||||
if ch not in walk_cursor_by_ch:
|
||||
if target_id not in parent_of:
|
||||
continue
|
||||
walk_cursor_by_ch[ch] = parent_of[target_id]
|
||||
cur_cid = walk_cursor_by_ch[ch]
|
||||
ch_chain = chain_by_ch[ch]
|
||||
hb_i = hb_by_i_by_cid[i]
|
||||
inline_i = inline_by_i_by_cid[i]
|
||||
ver_i = ver_by_i_by_cid[i]
|
||||
while cur_cid is not None:
|
||||
if cur_cid not in parent_of:
|
||||
# Need more pages to continue this walk.
|
||||
break
|
||||
ch_chain.append(cur_cid)
|
||||
has_blob = hb_i.get(cur_cid, False)
|
||||
inline = inline_i.get(cur_cid)
|
||||
if has_blob or inline is not None:
|
||||
# A blob wins: for a `_DeltaSnapshot` the inline reading is
|
||||
# the `true` marker, not the value.
|
||||
seed_ver_by_ch[ch] = ver_i.get(cur_cid)
|
||||
if not has_blob:
|
||||
seed_inline_by_ch[ch] = inline
|
||||
seeded.add(ch)
|
||||
cur_cid = None
|
||||
break
|
||||
cur_cid = parent_of[cur_cid]
|
||||
walk_cursor_by_ch[ch] = cur_cid
|
||||
|
||||
def _build_delta_channels_writes_history(
|
||||
self,
|
||||
*,
|
||||
channels: Sequence[str],
|
||||
chained_cpid_by_ch: Mapping[str, Sequence[str]],
|
||||
seed_cpid_by_ch: Mapping[str, str | None],
|
||||
chain_by_ch: Mapping[str, list[str]],
|
||||
seed_ver_by_ch: Mapping[str, str | None],
|
||||
fetch_rows: Sequence[_DeltaStage2Row],
|
||||
seed_inline_by_ch: Mapping[str, Any],
|
||||
stage2_rows: Sequence[_DeltaStage2Row],
|
||||
) -> dict[str, DeltaChannelHistory]:
|
||||
"""Demux FETCH rows per channel and produce per-channel histories.
|
||||
"""Demux stage 2 rows per channel; produce per-channel histories.
|
||||
|
||||
`fetch_rows` carry `channel` on every row. Write rows (`_kind = 'w'`)
|
||||
are bucketed per channel per checkpoint; seed-blob rows
|
||||
(`_kind = 'b'`) give each channel its snapshot value.
|
||||
stage2_rows carry `channel` on every row. We build per-channel
|
||||
`writes_by_cid` and per-channel `seed_blob` dicts, then assemble
|
||||
a `DeltaChannelHistory` per requested channel.
|
||||
|
||||
The seed checkpoint's own writes are replayed on top of a
|
||||
`_DeltaSnapshot` seed (the snapshot is the value *prior* to its own
|
||||
writes), but skipped for a migrated plain-value seed (a legacy
|
||||
non-delta blob already incorporates those writes). The `seed` key is
|
||||
omitted when no seed was located or the blob is the "empty"
|
||||
tombstone — the consumer treats absence as "start empty".
|
||||
A seed comes from the blobs table when the walk found one there, and
|
||||
otherwise from `seed_inline_by_ch` — `put` keeps `None`, `str`, `int`,
|
||||
`float` and `bool` values in the checkpoint's own `channel_values` with
|
||||
no blob row, so those never appear in `stage2_rows`.
|
||||
|
||||
The `seed` key is omitted when the walk reached root without finding a
|
||||
stored value, or when the seed blob is sentinel "empty" — in both cases
|
||||
the consumer treats absence as "start empty".
|
||||
"""
|
||||
# writes_by_ch_by_cid[channel][cid] = list of (type, blob, task_id, idx)
|
||||
writes_by_ch_by_cid: dict[str, dict[str, list[tuple[str, bytes, str, int]]]] = {
|
||||
ch: {} for ch in channels
|
||||
}
|
||||
seed_blob_by_ch: dict[str, tuple[str, bytes]] = {}
|
||||
# writes_by_ch_by_cid[channel][cid] = list of
|
||||
# (type, blob, task_id, idx, task_path)
|
||||
writes_by_ch_by_cid: dict[
|
||||
str, dict[str, list[tuple[str, bytes, str, int, str]]]
|
||||
] = {ch: {} for ch in channels}
|
||||
# seed_blob_by_ver[(channel, version)] = (type, blob)
|
||||
seed_blob_by_ver: dict[tuple[str, str], tuple[str, bytes]] = {}
|
||||
|
||||
for r in fetch_rows:
|
||||
for r in stage2_rows:
|
||||
ch = cast(str, r["channel"])
|
||||
if r["_kind"] == "w":
|
||||
kind = r["_kind"]
|
||||
if kind == "w":
|
||||
cid = cast(str, r["checkpoint_id"])
|
||||
writes_by_ch_by_cid.setdefault(ch, {}).setdefault(cid, []).append(
|
||||
cast(
|
||||
"tuple[str, bytes, str, int]",
|
||||
(r["type"], r["blob"], r["task_id"], r["idx"]),
|
||||
"tuple[str, bytes, str, int, str]",
|
||||
(
|
||||
r["type"],
|
||||
r["blob"],
|
||||
r["task_id"],
|
||||
r["idx"],
|
||||
r["task_path"],
|
||||
),
|
||||
)
|
||||
)
|
||||
else: # _kind == "b" — the seed blob for this channel.
|
||||
seed_blob_by_ch[ch] = cast("tuple[str, bytes]", (r["type"], r["blob"]))
|
||||
else: # kind == "b"
|
||||
ver = cast(str, r["version"])
|
||||
seed_blob_by_ver[(ch, ver)] = cast(
|
||||
"tuple[str, bytes]", (r["type"], r["blob"])
|
||||
)
|
||||
|
||||
# Within a checkpoint, writes apply oldest→newest by (task_id, idx).
|
||||
# Sort writes per (channel, cid) newest-first
|
||||
for cid_map in writes_by_ch_by_cid.values():
|
||||
for ws in cid_map.values():
|
||||
ws.sort(key=lambda w: (w[2], w[3]))
|
||||
ws.sort(key=lambda w: writes_sort_key(w[4], w[2], w[3]), reverse=True)
|
||||
|
||||
result: dict[str, DeltaChannelHistory] = {}
|
||||
for ch in channels:
|
||||
entry: DeltaChannelHistory = {"writes": []}
|
||||
|
||||
skip_seed_checkpoint_writes = False
|
||||
seed_blob = seed_blob_by_ch.get(ch)
|
||||
if seed_blob is not None and seed_blob[0] != "empty":
|
||||
seed_value = self.serde.loads_typed(seed_blob)
|
||||
entry["seed"] = seed_value
|
||||
# A migrated (non-delta) seed already includes the writes on
|
||||
# its own checkpoint; a `_DeltaSnapshot` does not.
|
||||
skip_seed_checkpoint_writes = not isinstance(seed_value, _DeltaSnapshot)
|
||||
chain_cids = chain_by_ch.get(ch, [])
|
||||
seed_version = seed_ver_by_ch.get(ch)
|
||||
|
||||
collected: list[PendingWrite] = []
|
||||
cid_writes = writes_by_ch_by_cid.get(ch, {})
|
||||
if cid_writes:
|
||||
collected: list[PendingWrite] = []
|
||||
seed_cpid = seed_cpid_by_ch.get(ch)
|
||||
# Chain is newest→oldest; replay oldest→newest.
|
||||
for cid in reversed(chained_cpid_by_ch.get(ch, [])):
|
||||
if skip_seed_checkpoint_writes and cid == seed_cpid:
|
||||
continue
|
||||
for type_tag, write_blob, task_id, _idx in cid_writes.get(cid, []):
|
||||
val = self.serde.loads_typed((type_tag, write_blob))
|
||||
collected.append((task_id, ch, val))
|
||||
entry["writes"] = collected
|
||||
for cid in chain_cids:
|
||||
for type_tag, write_blob, task_id, _idx, _path in cid_writes.get(
|
||||
cid, []
|
||||
):
|
||||
val = self.serde.loads_typed((type_tag, write_blob))
|
||||
collected.append((task_id, ch, val))
|
||||
collected.reverse()
|
||||
|
||||
entry: DeltaChannelHistory = {"writes": collected}
|
||||
if seed_version is not None:
|
||||
blob = seed_blob_by_ver.get((ch, seed_version))
|
||||
if blob is not None and blob[0] != "empty":
|
||||
entry["seed"] = self.serde.loads_typed(blob)
|
||||
elif ch in seed_inline_by_ch:
|
||||
# Inline primitive: stored in the checkpoint, not the blobs
|
||||
# table, so stage 2 never returned a row for it.
|
||||
entry["seed"] = seed_inline_by_ch[ch]
|
||||
result[ch] = entry
|
||||
return result
|
||||
|
||||
@@ -553,20 +790,15 @@ class BasePostgresSaver(BaseCheckpointSaver[str]):
|
||||
]
|
||||
|
||||
def _load_writes(
|
||||
self, writes: list[tuple[bytes, bytes, bytes, bytes]]
|
||||
self, writes: list[tuple[bytes, bytes, bytes, bytes, bytes, bytes]] | None
|
||||
) -> list[tuple[str, str, Any]]:
|
||||
return (
|
||||
[
|
||||
(
|
||||
tid.decode(),
|
||||
channel.decode(),
|
||||
self.serde.loads_typed((t.decode(), v)),
|
||||
)
|
||||
for tid, channel, t, v in writes
|
||||
]
|
||||
if writes
|
||||
else []
|
||||
)
|
||||
return [
|
||||
(tid.decode(), channel.decode(), self.serde.loads_typed((t.decode(), v)))
|
||||
for tid, channel, t, v, _, _ in sorted(
|
||||
writes or [],
|
||||
key=lambda w: writes_sort_key(w[4].decode(), w[0].decode(), int(w[5])),
|
||||
)
|
||||
]
|
||||
|
||||
def _dump_writes(
|
||||
self,
|
||||
|
||||
@@ -97,7 +97,7 @@ select
|
||||
) as channel_values,
|
||||
(
|
||||
select
|
||||
array_agg(array[cw.task_id::text::bytea, cw.channel::bytea, cw.type::bytea, cw.blob] order by cw.task_id, cw.idx)
|
||||
array_agg(array[cw.task_id::text::bytea, cw.channel::bytea, cw.type::bytea, cw.blob, convert_to(cw.task_path, 'UTF8'), cw.idx::text::bytea])
|
||||
from checkpoint_writes cw
|
||||
where cw.thread_id = checkpoints.thread_id
|
||||
and cw.checkpoint_ns = checkpoints.checkpoint_ns
|
||||
@@ -334,7 +334,7 @@ class ShallowPostgresSaver(BasePostgresSaver):
|
||||
>>> checkpoint_tuple = memory.get_tuple(config)
|
||||
>>> print(checkpoint_tuple)
|
||||
CheckpointTuple(...)
|
||||
""" # noqa
|
||||
"""
|
||||
thread_id = config["configurable"]["thread_id"]
|
||||
checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
|
||||
args = (thread_id, checkpoint_ns)
|
||||
@@ -885,7 +885,7 @@ class AsyncShallowPostgresSaver(BasePostgresSaver):
|
||||
while True:
|
||||
try:
|
||||
yield asyncio.run_coroutine_threadsafe(
|
||||
anext(aiter_), # type: ignore[arg-type] # noqa: F821
|
||||
anext(aiter_), # type: ignore[arg-type]
|
||||
self.loop,
|
||||
).result()
|
||||
except StopAsyncIteration:
|
||||
|
||||
@@ -4,9 +4,10 @@ import asyncio
|
||||
import concurrent.futures
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import threading
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable, Iterable, Iterator, Sequence
|
||||
from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime
|
||||
from typing import (
|
||||
@@ -35,6 +36,7 @@ from langgraph.store.base import (
|
||||
ensure_embeddings,
|
||||
get_text_at_path,
|
||||
tokenize_path,
|
||||
validate_op_namespace,
|
||||
)
|
||||
from psycopg import Capabilities, Connection, Cursor, Pipeline
|
||||
from psycopg.rows import DictRow, dict_row
|
||||
@@ -238,6 +240,12 @@ class BasePostgresStore(Generic[C]):
|
||||
conn: C
|
||||
_deserializer: Callable[[bytes | orjson.Fragment], dict[str, Any]] | None
|
||||
index_config: PostgresIndexConfig | None
|
||||
ttl_config: TTLConfig | None
|
||||
|
||||
@property
|
||||
def _omit_expired(self) -> bool:
|
||||
"""Whether expired-but-unswept rows should be filtered from reads."""
|
||||
return bool(self.ttl_config and self.ttl_config.get("omit_expired"))
|
||||
|
||||
def _get_batch_GET_ops_queries(
|
||||
self,
|
||||
@@ -258,12 +266,17 @@ class BasePostgresStore(Generic[C]):
|
||||
namespace_groups[op.namespace].append((idx, op.key))
|
||||
refresh_ttls[op.namespace].append(op.refresh_ttl)
|
||||
|
||||
omit_expired = self._omit_expired
|
||||
expiry_clause = (
|
||||
"AND (s.expires_at IS NULL OR s.expires_at > NOW())" if omit_expired else ""
|
||||
)
|
||||
|
||||
results = []
|
||||
for namespace, items in namespace_groups.items():
|
||||
_, keys = zip(*items, strict=False)
|
||||
this_refresh_ttls = refresh_ttls[namespace]
|
||||
|
||||
query = """
|
||||
query = f"""
|
||||
WITH passed_in AS (
|
||||
SELECT unnest(%s::text[]) AS key,
|
||||
unnest(%s::bool[]) AS do_refresh
|
||||
@@ -276,12 +289,14 @@ class BasePostgresStore(Generic[C]):
|
||||
AND s.key = p.key
|
||||
AND p.do_refresh = TRUE
|
||||
AND s.ttl_minutes IS NOT NULL
|
||||
{expiry_clause}
|
||||
RETURNING s.key
|
||||
)
|
||||
SELECT s.key, s.value, s.created_at, s.updated_at
|
||||
FROM store s
|
||||
JOIN passed_in p ON s.key = p.key
|
||||
WHERE s.prefix = %s
|
||||
{expiry_clause}
|
||||
"""
|
||||
ns_text = _namespace_to_text(namespace)
|
||||
params = (
|
||||
@@ -340,7 +355,7 @@ class BasePostgresStore(Generic[C]):
|
||||
(
|
||||
_namespace_to_text(op.namespace),
|
||||
op.key,
|
||||
Jsonb(cast(dict, op.value)),
|
||||
Jsonb(dict(cast(Mapping[str, Any], op.value))),
|
||||
)
|
||||
)
|
||||
if op.ttl is not None:
|
||||
@@ -422,6 +437,13 @@ class BasePostgresStore(Generic[C]):
|
||||
- embedding_requests: list of (original_index_in_search_ops, text_query)
|
||||
"""
|
||||
|
||||
omit_expired = self._omit_expired
|
||||
search_expiry_clause = (
|
||||
"AND (store.expires_at IS NULL OR store.expires_at > NOW())"
|
||||
if omit_expired
|
||||
else ""
|
||||
)
|
||||
|
||||
queries = []
|
||||
embedding_requests = []
|
||||
for idx, (_, op) in enumerate(search_ops):
|
||||
@@ -443,8 +465,9 @@ class BasePostgresStore(Generic[C]):
|
||||
ns_condition = "TRUE"
|
||||
ns_param: Sequence[str] | None = None
|
||||
if op.namespace_prefix:
|
||||
ns_condition = "store.prefix LIKE %s"
|
||||
ns_param = (f"{_namespace_to_text(op.namespace_prefix)}%",)
|
||||
ns_condition, ns_param = _namespace_prefix_condition(
|
||||
op.namespace_prefix
|
||||
)
|
||||
else:
|
||||
ns_param = ()
|
||||
|
||||
@@ -491,7 +514,7 @@ class BasePostgresStore(Generic[C]):
|
||||
{score_operator} AS neg_score
|
||||
FROM store
|
||||
JOIN store_vectors sv ON store.prefix = sv.prefix AND store.key = sv.key
|
||||
WHERE {ns_condition} {extra_filters}
|
||||
WHERE {ns_condition} {extra_filters} {search_expiry_clause}
|
||||
ORDER BY {score_operator} ASC
|
||||
LIMIT %s
|
||||
"""
|
||||
@@ -527,7 +550,7 @@ class BasePostgresStore(Generic[C]):
|
||||
base_query = f"""
|
||||
SELECT store.prefix, store.key, store.value, store.created_at, store.updated_at, NULL AS score
|
||||
FROM store
|
||||
WHERE {ns_condition} {extra_filters}
|
||||
WHERE {ns_condition} {extra_filters} {search_expiry_clause}
|
||||
ORDER BY store.updated_at DESC
|
||||
LIMIT %s
|
||||
OFFSET %s
|
||||
@@ -591,18 +614,23 @@ class BasePostgresStore(Generic[C]):
|
||||
"""
|
||||
params: list[Any] = [op.max_depth, op.max_depth]
|
||||
|
||||
omit_expired = self._omit_expired
|
||||
conditions = []
|
||||
if omit_expired:
|
||||
conditions.append("(expires_at IS NULL OR expires_at > NOW())")
|
||||
if op.match_conditions:
|
||||
for condition in op.match_conditions:
|
||||
if condition.match_type == "prefix":
|
||||
conditions.append("prefix LIKE %s")
|
||||
if condition.match_type in ("prefix", "suffix"):
|
||||
if not condition.path:
|
||||
# An empty path constrains nothing; skipping keeps it a
|
||||
# no-op rather than emitting a pattern that matches no
|
||||
# namespace at all.
|
||||
continue
|
||||
conditions.append("prefix ~ %s")
|
||||
params.append(
|
||||
f"{_namespace_to_text(condition.path, handle_wildcards=True)}%"
|
||||
)
|
||||
elif condition.match_type == "suffix":
|
||||
conditions.append("prefix LIKE %s")
|
||||
params.append(
|
||||
f"%{_namespace_to_text(condition.path, handle_wildcards=True)}"
|
||||
_namespace_match_pattern(
|
||||
condition.path, condition.match_type
|
||||
)
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
@@ -1248,15 +1276,59 @@ def _get_index_params(store: Any) -> tuple[str, dict[str, Any]]:
|
||||
return kind, sanitized
|
||||
|
||||
|
||||
def _namespace_to_text(
|
||||
namespace: tuple[str, ...], handle_wildcards: bool = False
|
||||
) -> str:
|
||||
def _namespace_to_text(namespace: tuple[str, ...]) -> str:
|
||||
"""Convert namespace tuple to text string."""
|
||||
if handle_wildcards:
|
||||
namespace = tuple("%" if val == "*" else val for val in namespace)
|
||||
return ".".join(namespace)
|
||||
|
||||
|
||||
def _escape_like_literal(text: str) -> str:
|
||||
"""Escape LIKE metacharacters so `text` is matched literally.
|
||||
|
||||
Namespace labels may contain `_` and `%`, which would otherwise act as
|
||||
wildcards: `("user_1",)` would match `("userX1",)`. Backslash is escaped
|
||||
first so it cannot escape the following character. Requires an explicit
|
||||
`ESCAPE '\\'` clause on the pattern.
|
||||
"""
|
||||
return text.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
|
||||
|
||||
def _namespace_prefix_condition(namespace_prefix: tuple[str, ...]) -> tuple[str, tuple]:
|
||||
"""Build the SQL scoping a search to a namespace and its descendants.
|
||||
|
||||
Matches the namespace exactly or requires the `.` separator before any
|
||||
remainder, so a prefix of `("foo",)` does not also match `("foobar",)`.
|
||||
|
||||
Both arms stay index-friendly: equality on the `(prefix, key)` primary key,
|
||||
the anchored LIKE on the `prefix text_pattern_ops` index.
|
||||
|
||||
Only the LIKE arm is escaped -- equality does not interpret metacharacters,
|
||||
so escaping it would stop `("user_1",)` from matching itself.
|
||||
"""
|
||||
path = _namespace_to_text(namespace_prefix)
|
||||
condition = r"(store.prefix = %s OR store.prefix LIKE %s ESCAPE '\')"
|
||||
return condition, (path, f"{_escape_like_literal(path)}.%")
|
||||
|
||||
|
||||
def _namespace_match_pattern(path: tuple[str, ...], match_type: str) -> str:
|
||||
"""Build a POSIX regex matching the dot-joined prefix on whole segments.
|
||||
|
||||
Needed because `LIKE` cannot express "any character except the separator".
|
||||
Matches how `InMemoryStore` compares namespaces element-wise.
|
||||
|
||||
`*` matches exactly one segment. Prefix matches stay open-ended but must end
|
||||
on a separator; suffix matches anchor at the end and begin on one.
|
||||
|
||||
Examples:
|
||||
prefix ("uid", "*", "alice") -> ^uid\\.[^.]+\\.alice(\\.|\\Z)
|
||||
suffix ("alice",) -> (^|\\.)alice\\Z
|
||||
"""
|
||||
segments = ("[^.]+" if part == "*" else re.escape(part) for part in path)
|
||||
body = r"\.".join(segments)
|
||||
if match_type == "suffix":
|
||||
return rf"(^|\.){body}\Z"
|
||||
return rf"^{body}(\.|\Z)"
|
||||
|
||||
|
||||
def _row_to_item(
|
||||
namespace: tuple[str, ...],
|
||||
row: Row,
|
||||
@@ -1315,6 +1387,7 @@ def _group_ops(ops: Iterable[Op]) -> tuple[dict[type, list[tuple[int, Op]]], int
|
||||
grouped_ops: dict[type, list[tuple[int, Op]]] = defaultdict(list)
|
||||
tot = 0
|
||||
for idx, op in enumerate(ops):
|
||||
validate_op_namespace(op)
|
||||
grouped_ops[type(op)].append((idx, op))
|
||||
tot += 1
|
||||
return grouped_ops, tot
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "langgraph-checkpoint-postgres"
|
||||
version = "3.1.0"
|
||||
version = "3.2.0"
|
||||
description = "Library with a Postgres implementation of LangGraph checkpoint saver."
|
||||
authors = []
|
||||
requires-python = ">=3.10"
|
||||
@@ -12,7 +12,7 @@ readme = "README.md"
|
||||
license = "MIT"
|
||||
license-files = ['LICENSE']
|
||||
dependencies = [
|
||||
"langgraph-checkpoint>=4.1.0,<5.0.0",
|
||||
"langgraph-checkpoint>=4.3.0,<5.0.0",
|
||||
"orjson>=3.11.5",
|
||||
"psycopg>=3.2.0",
|
||||
"psycopg-pool>=3.2.0",
|
||||
@@ -32,6 +32,7 @@ test = [
|
||||
"pytest-mock",
|
||||
"psycopg[binary]",
|
||||
"langgraph-checkpoint",
|
||||
"langgraph-checkpoint-conformance",
|
||||
"pytest-watcher",
|
||||
]
|
||||
lint = [
|
||||
@@ -49,6 +50,7 @@ default-groups = ['dev']
|
||||
|
||||
[tool.uv.sources]
|
||||
langgraph-checkpoint = { path = "../checkpoint", editable = true }
|
||||
langgraph-checkpoint-conformance = { path = "../checkpoint-conformance", editable = true }
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
include = ["langgraph"]
|
||||
@@ -64,6 +66,8 @@ lint.select = [
|
||||
"UP", # pyupgrade
|
||||
"B", # flake8-bugbear
|
||||
"I", # isort
|
||||
"PLC0415", # import-outside-top-level
|
||||
"RUF100", # unused noqa directive
|
||||
"UP", # pyupgrade
|
||||
]
|
||||
lint.ignore = ["E501", "B008"]
|
||||
|
||||
@@ -380,13 +380,15 @@ async def test_delta_channel_chain_reconstruction(saver_name: str) -> None:
|
||||
"langgraph.channels.delta", reason="langgraph core not installed"
|
||||
)
|
||||
|
||||
from typing import Annotated
|
||||
# Deferred on purpose: langgraph core is not a test dependency of this
|
||||
# package, so these must stay behind the importorskip above.
|
||||
from typing import Annotated # noqa: PLC0415
|
||||
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
from langgraph.channels.delta import DeltaChannel
|
||||
from langgraph.graph import START, StateGraph
|
||||
from langgraph.graph.message import _messages_delta_reducer
|
||||
from typing_extensions import TypedDict
|
||||
from langchain_core.messages import AIMessage, HumanMessage # noqa: PLC0415
|
||||
from langgraph.channels.delta import DeltaChannel # noqa: PLC0415
|
||||
from langgraph.graph import START, StateGraph # noqa: PLC0415
|
||||
from langgraph.graph.message import _messages_delta_reducer # noqa: PLC0415
|
||||
from typing_extensions import TypedDict # noqa: PLC0415
|
||||
|
||||
class State(TypedDict):
|
||||
messages: Annotated[list, DeltaChannel(_messages_delta_reducer)]
|
||||
|
||||
@@ -13,8 +13,10 @@ import pytest
|
||||
from langchain_core.embeddings import Embeddings
|
||||
from langgraph.store.base import (
|
||||
GetOp,
|
||||
InvalidNamespaceError,
|
||||
Item,
|
||||
ListNamespacesOp,
|
||||
MatchCondition,
|
||||
PutOp,
|
||||
SearchOp,
|
||||
)
|
||||
@@ -739,3 +741,197 @@ async def test_store_ttl(store):
|
||||
# Now has been (TTL_SECONDS-2)*2 > TTL_SECONDS + TTL_SECONDS/2
|
||||
results = await store.asearch(ns, query="bar", refresh_ttl=False)
|
||||
assert len(results) == 0
|
||||
|
||||
|
||||
async def _aexpire_now(
|
||||
store: AsyncPostgresStore, ns: tuple[str, ...], key: str
|
||||
) -> None:
|
||||
"""Backdate a row's expires_at into the past without deleting it (unswept)."""
|
||||
async with store._cursor() as cur:
|
||||
await cur.execute(
|
||||
"UPDATE store SET expires_at = NOW() - INTERVAL '1 minute' "
|
||||
"WHERE prefix = %s AND key = %s",
|
||||
(".".join(ns), key),
|
||||
)
|
||||
|
||||
|
||||
async def _arow_exists(
|
||||
store: AsyncPostgresStore, ns: tuple[str, ...], key: str
|
||||
) -> bool:
|
||||
async with store._cursor() as cur:
|
||||
await cur.execute(
|
||||
"SELECT COUNT(*) AS n FROM store WHERE prefix = %s AND key = %s",
|
||||
(".".join(ns), key),
|
||||
)
|
||||
return (await cur.fetchone())["n"] == 1
|
||||
|
||||
|
||||
async def _astored_expires_at(store: AsyncPostgresStore, ns: tuple[str, ...], key: str):
|
||||
async with store._cursor() as cur:
|
||||
await cur.execute(
|
||||
"SELECT expires_at FROM store WHERE prefix = %s AND key = %s",
|
||||
(".".join(ns), key),
|
||||
)
|
||||
return (await cur.fetchone())["expires_at"]
|
||||
|
||||
|
||||
async def test_omit_expired_filters_read_paths(store: AsyncPostgresStore) -> None:
|
||||
await store.stop_ttl_sweeper() # deterministic: no background deletion
|
||||
store.ttl_config["omit_expired"] = True
|
||||
|
||||
expired_ns = ("omit", "expired")
|
||||
control_ns = ("omit", "control")
|
||||
await store.aput(expired_ns, "e", {"data": "gone"}, ttl=TTL_MINUTES)
|
||||
await store.aput(control_ns, "c", {"data": "keep"}, ttl=None)
|
||||
await _aexpire_now(store, expired_ns, "e")
|
||||
|
||||
# The row is expired but physically still present (unswept).
|
||||
assert await _arow_exists(store, expired_ns, "e")
|
||||
|
||||
# aget omits it; the never-expiring control is still returned.
|
||||
assert await store.aget(expired_ns, "e") is None
|
||||
assert await store.aget(control_ns, "c") is not None
|
||||
|
||||
# asearch omits it but returns the control.
|
||||
assert await store.asearch(expired_ns) == []
|
||||
assert [i.key for i in await store.asearch(control_ns)] == ["c"]
|
||||
|
||||
# alist_namespaces drops the expired-only namespace, keeps the control.
|
||||
namespaces = await store.alist_namespaces(prefix=("omit",))
|
||||
assert expired_ns not in namespaces
|
||||
assert control_ns in namespaces
|
||||
|
||||
|
||||
@pytest.mark.parametrize("omit", [None, False], ids=["default", "explicit-false"])
|
||||
async def test_omit_expired_disabled_preserves_expired_rows(
|
||||
store: AsyncPostgresStore, omit
|
||||
) -> None:
|
||||
await store.stop_ttl_sweeper()
|
||||
if omit is not None:
|
||||
store.ttl_config["omit_expired"] = omit
|
||||
|
||||
ns = ("keep",)
|
||||
await store.aput(ns, "k", {"data": "still-here"}, ttl=TTL_MINUTES)
|
||||
await _aexpire_now(store, ns, "k")
|
||||
|
||||
assert await store.aget(ns, "k", refresh_ttl=False) is not None
|
||||
assert [i.key for i in await store.asearch(ns, refresh_ttl=False)] == ["k"]
|
||||
assert ns in await store.alist_namespaces(prefix=("keep",))
|
||||
|
||||
|
||||
async def test_omit_expired_refresh_ttl_only_refreshes_live_rows(
|
||||
store: AsyncPostgresStore,
|
||||
) -> None:
|
||||
await store.stop_ttl_sweeper()
|
||||
store.ttl_config["omit_expired"] = True
|
||||
|
||||
ns = ("refresh",)
|
||||
await store.aput(ns, "expired", {"n": 0}, ttl=TTL_MINUTES)
|
||||
await store.aput(ns, "live_get", {"n": 1}, ttl=TTL_MINUTES)
|
||||
await store.aput(ns, "live_search", {"n": 2}, ttl=TTL_MINUTES)
|
||||
await _aexpire_now(store, ns, "expired")
|
||||
|
||||
expired_before = await _astored_expires_at(store, ns, "expired")
|
||||
get_before = await _astored_expires_at(store, ns, "live_get")
|
||||
search_before = await _astored_expires_at(store, ns, "live_search")
|
||||
|
||||
# refresh_ttl=True must NOT resurrect the expired row (via aget or asearch)...
|
||||
assert await store.aget(ns, "expired", refresh_ttl=True) is None
|
||||
live_keys = [i.key for i in await store.asearch(ns, refresh_ttl=True)]
|
||||
assert "expired" not in live_keys
|
||||
assert await _astored_expires_at(store, ns, "expired") == expired_before
|
||||
|
||||
# ...but must still extend the live rows that were read.
|
||||
assert await store.aget(ns, "live_get", refresh_ttl=True) is not None
|
||||
assert await _astored_expires_at(store, ns, "live_get") > get_before
|
||||
assert await _astored_expires_at(store, ns, "live_search") > search_before
|
||||
|
||||
|
||||
async def test_omit_expired_search_pagination(store: AsyncPostgresStore) -> None:
|
||||
await store.stop_ttl_sweeper()
|
||||
store.ttl_config["omit_expired"] = True
|
||||
|
||||
ns = ("page",)
|
||||
for k in ("a", "b", "c"):
|
||||
await store.aput(ns, k, {"k": k}, ttl=TTL_MINUTES)
|
||||
await store.aput(ns, "expired", {"k": "x"}, ttl=TTL_MINUTES)
|
||||
await _aexpire_now(store, ns, "expired")
|
||||
|
||||
seconds_ago = {"a": 1, "expired": 2, "b": 3, "c": 4}
|
||||
# updated_at DESC orders these a, expired, b, c, so the expired row sits inside
|
||||
# the first limit=2 window. Correct (pre-LIMIT) filtering yields live pages
|
||||
# [a, b] then [c]; post-LIMIT filtering would underfill page 1 to just [a].
|
||||
async with store._cursor() as cur:
|
||||
for key, secs in seconds_ago.items():
|
||||
await cur.execute(
|
||||
"UPDATE store SET updated_at = NOW() - (%s * INTERVAL '1 second') "
|
||||
"WHERE prefix = %s AND key = %s",
|
||||
(secs, ".".join(ns), key),
|
||||
)
|
||||
|
||||
page1 = await store.asearch(ns, limit=2, offset=0)
|
||||
page2 = await store.asearch(ns, limit=2, offset=2)
|
||||
assert [i.key for i in page1] == ["a", "b"]
|
||||
assert [i.key for i in page2] == ["c"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("namespace", [("foo.bar",), ("foo", ""), ("foo", 1)])
|
||||
async def test_abatch_rejects_invalid_namespace_labels(
|
||||
store: AsyncPostgresStore, namespace: tuple
|
||||
) -> None:
|
||||
await store.aput(("foo", "bar"), "key", {"original": True})
|
||||
|
||||
for op in (
|
||||
GetOp(namespace, "key"),
|
||||
GetOp(namespace, "key", refresh_ttl=True),
|
||||
PutOp(namespace, "key", {"changed": True}),
|
||||
PutOp(namespace, "key", None),
|
||||
SearchOp(namespace),
|
||||
ListNamespacesOp((MatchCondition("prefix", namespace),)),
|
||||
ListNamespacesOp((MatchCondition("suffix", namespace),)),
|
||||
):
|
||||
with pytest.raises(InvalidNamespaceError):
|
||||
await store.abatch([op])
|
||||
|
||||
item = await store.aget(("foo", "bar"), "key")
|
||||
assert item is not None and item.value == {"original": True}
|
||||
|
||||
|
||||
async def test_invalid_namespace_only_fails_its_own_call(
|
||||
store: AsyncPostgresStore,
|
||||
) -> None:
|
||||
"""Concurrent calls share one `abatch`, which fails every op if it raises.
|
||||
|
||||
Labels are checked before an op is queued, so one caller's bad label cannot
|
||||
fail another caller's request.
|
||||
"""
|
||||
await store.aput(("foo", "bar"), "key", {"original": True})
|
||||
|
||||
valid, invalid = await asyncio.gather(
|
||||
store.aget(("foo", "bar"), "key"),
|
||||
store.aget(("foo.bar",), "key"),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
assert isinstance(valid, Item) and valid.value == {"original": True}
|
||||
assert isinstance(invalid, InvalidNamespaceError)
|
||||
|
||||
|
||||
async def test_sync_methods_reject_invalid_namespace_labels(
|
||||
store: AsyncPostgresStore,
|
||||
) -> None:
|
||||
"""The sync wrappers run off the event loop thread and must validate too."""
|
||||
await store.aput(("foo", "bar"), "key", {"original": True})
|
||||
|
||||
for call in (
|
||||
lambda: store.get(("foo.bar",), "key"),
|
||||
lambda: store.search(("foo.bar",)),
|
||||
lambda: store.delete(("foo.bar",), "key"),
|
||||
lambda: store.list_namespaces(prefix=("foo.bar",)),
|
||||
lambda: store.batch([GetOp(("foo.bar",), "key")]),
|
||||
):
|
||||
with pytest.raises(InvalidNamespaceError):
|
||||
await asyncio.to_thread(call)
|
||||
|
||||
item = await store.aget(("foo", "bar"), "key")
|
||||
assert item is not None and item.value == {"original": True}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Run delta-channel conformance capabilities against AsyncPostgresSaver."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from langgraph.checkpoint.conformance import validate
|
||||
from langgraph.checkpoint.conformance.initializer import checkpointer_test
|
||||
|
||||
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
|
||||
from tests.conftest import DEFAULT_URI
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delta_channel_conformance():
|
||||
@checkpointer_test(name="AsyncPostgresSaver")
|
||||
async def postgres_saver():
|
||||
async with AsyncPostgresSaver.from_conn_string(DEFAULT_URI) as saver:
|
||||
await saver.setup()
|
||||
yield saver
|
||||
|
||||
report = await validate(
|
||||
postgres_saver,
|
||||
capabilities={
|
||||
"delta_channel_history",
|
||||
},
|
||||
)
|
||||
for cap, result in report.results.items():
|
||||
if result.passed is False:
|
||||
details = "\n".join(result.failures or [])
|
||||
pytest.fail(f"Capability {cap} failed:\n{details}")
|
||||
@@ -0,0 +1,211 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from langgraph.checkpoint.base import (
|
||||
Checkpoint,
|
||||
DeltaChannelHistory,
|
||||
empty_checkpoint,
|
||||
)
|
||||
from langgraph.checkpoint.base.id import uuid6
|
||||
from langgraph.checkpoint.serde.types import _DeltaSnapshot
|
||||
|
||||
from langgraph.checkpoint.postgres import PostgresSaver
|
||||
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
|
||||
from langgraph.checkpoint.postgres.base import _DELTA_PAGE_SIZE, BasePostgresSaver
|
||||
from tests.conftest import DEFAULT_URI
|
||||
|
||||
CHANNEL = "items"
|
||||
STEPS = 8
|
||||
SEED_STEP = 1
|
||||
SEED_VALUE = [10, 20]
|
||||
TARGET_STEP = 4
|
||||
|
||||
# The real page size is the control; the rest split the walk from the target
|
||||
# to its seed across pages.
|
||||
PAGE_SIZES = [_DELTA_PAGE_SIZE, 3, 2, 1]
|
||||
|
||||
|
||||
def _step_args(
|
||||
thread_id: str, step: int, parent: dict | None
|
||||
) -> tuple[dict, Checkpoint, dict[str, Any]]:
|
||||
config: dict = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
|
||||
if parent is not None:
|
||||
config["configurable"]["checkpoint_id"] = parent["configurable"][
|
||||
"checkpoint_id"
|
||||
]
|
||||
checkpoint: Checkpoint = empty_checkpoint()
|
||||
checkpoint["id"] = str(uuid6(clock_seq=step))
|
||||
checkpoint["channel_versions"][CHANNEL] = f"v{step}"
|
||||
if step == SEED_STEP:
|
||||
checkpoint["channel_values"][CHANNEL] = _DeltaSnapshot(list(SEED_VALUE))
|
||||
return config, checkpoint, {CHANNEL: f"v{step}"}
|
||||
return config, checkpoint, {}
|
||||
|
||||
|
||||
async def _abuild_chain(saver: AsyncPostgresSaver) -> list[dict]:
|
||||
thread_id = str(uuid4())
|
||||
parent: dict | None = None
|
||||
configs: list[dict] = []
|
||||
for step in range(STEPS):
|
||||
config, checkpoint, new_versions = _step_args(thread_id, step, parent)
|
||||
parent = await saver.aput(
|
||||
config,
|
||||
checkpoint,
|
||||
{"source": "loop", "step": step, "parents": {}},
|
||||
new_versions,
|
||||
)
|
||||
await saver.aput_writes(parent, [(CHANNEL, f"w{step}")], str(uuid4()))
|
||||
configs.append(parent)
|
||||
return configs
|
||||
|
||||
|
||||
def _build_chain(saver: PostgresSaver) -> list[dict]:
|
||||
thread_id = str(uuid4())
|
||||
parent: dict | None = None
|
||||
configs: list[dict] = []
|
||||
for step in range(STEPS):
|
||||
config, checkpoint, new_versions = _step_args(thread_id, step, parent)
|
||||
parent = saver.put(
|
||||
config,
|
||||
checkpoint,
|
||||
{"source": "loop", "step": step, "parents": {}},
|
||||
new_versions,
|
||||
)
|
||||
saver.put_writes(parent, [(CHANNEL, f"w{step}")], str(uuid4()))
|
||||
configs.append(parent)
|
||||
return configs
|
||||
|
||||
|
||||
def _assert_history(entry: DeltaChannelHistory, page_size: int) -> None:
|
||||
seed = entry.get("seed")
|
||||
assert isinstance(seed, _DeltaSnapshot), (
|
||||
f"page_size={page_size}: expected a snapshot seed, "
|
||||
f"got {entry.get('seed', '<missing>')!r}"
|
||||
)
|
||||
assert seed.value == SEED_VALUE
|
||||
assert [w[2] for w in entry["writes"]] == ["w1", "w2", "w3"], (
|
||||
f"page_size={page_size}: got {[w[2] for w in entry['writes']]}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("page_size", PAGE_SIZES)
|
||||
async def test_async_walk_continues_across_pages(
|
||||
page_size: int, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr("langgraph.checkpoint.postgres.aio._DELTA_PAGE_SIZE", page_size)
|
||||
async with AsyncPostgresSaver.from_conn_string(DEFAULT_URI) as saver:
|
||||
await saver.setup()
|
||||
configs = await _abuild_chain(saver)
|
||||
result = await saver.aget_delta_channel_history(
|
||||
config=configs[TARGET_STEP], channels=[CHANNEL]
|
||||
)
|
||||
_assert_history(result[CHANNEL], page_size)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("page_size", PAGE_SIZES)
|
||||
def test_sync_walk_continues_across_pages(
|
||||
page_size: int, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr("langgraph.checkpoint.postgres._DELTA_PAGE_SIZE", page_size)
|
||||
with PostgresSaver.from_conn_string(DEFAULT_URI) as saver:
|
||||
saver.setup()
|
||||
configs = _build_chain(saver)
|
||||
result = saver.get_delta_channel_history(
|
||||
config=configs[TARGET_STEP], channels=[CHANNEL]
|
||||
)
|
||||
_assert_history(result[CHANNEL], page_size)
|
||||
|
||||
|
||||
def _record_rows_read(monkeypatch: pytest.MonkeyPatch) -> list[str]:
|
||||
read: list[str] = []
|
||||
ingest = BasePostgresSaver._ingest_stage1_page
|
||||
|
||||
def record(rows: Sequence[Mapping[str, Any]], *args: Any) -> str | None:
|
||||
read.extend(row["checkpoint_id"] for row in rows)
|
||||
return ingest(rows, *args)
|
||||
|
||||
monkeypatch.setattr(BasePostgresSaver, "_ingest_stage1_page", staticmethod(record))
|
||||
return read
|
||||
|
||||
|
||||
def _ids_from_target_down(configs: list[dict]) -> list[str]:
|
||||
return [c["configurable"]["checkpoint_id"] for c in configs[TARGET_STEP::-1]]
|
||||
|
||||
|
||||
async def test_async_walk_reads_nothing_newer_than_the_target(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
read = _record_rows_read(monkeypatch)
|
||||
async with AsyncPostgresSaver.from_conn_string(DEFAULT_URI) as saver:
|
||||
await saver.setup()
|
||||
configs = await _abuild_chain(saver)
|
||||
result = await saver.aget_delta_channel_history(
|
||||
config=configs[TARGET_STEP], channels=[CHANNEL]
|
||||
)
|
||||
_assert_history(result[CHANNEL], _DELTA_PAGE_SIZE)
|
||||
assert read == _ids_from_target_down(configs)
|
||||
|
||||
|
||||
def test_sync_walk_reads_nothing_newer_than_the_target(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
read = _record_rows_read(monkeypatch)
|
||||
with PostgresSaver.from_conn_string(DEFAULT_URI) as saver:
|
||||
saver.setup()
|
||||
configs = _build_chain(saver)
|
||||
result = saver.get_delta_channel_history(
|
||||
config=configs[TARGET_STEP], channels=[CHANNEL]
|
||||
)
|
||||
_assert_history(result[CHANNEL], _DELTA_PAGE_SIZE)
|
||||
assert read == _ids_from_target_down(configs)
|
||||
|
||||
|
||||
def _empty_checkpoint_id(config: dict) -> dict:
|
||||
return {"configurable": {**config["configurable"], "checkpoint_id": ""}}
|
||||
|
||||
|
||||
async def test_async_empty_checkpoint_id_reads_from_the_latest_checkpoint() -> None:
|
||||
async with AsyncPostgresSaver.from_conn_string(DEFAULT_URI) as saver:
|
||||
await saver.setup()
|
||||
configs = await _abuild_chain(saver)
|
||||
latest = await saver.aget_delta_channel_history(
|
||||
config=configs[-1], channels=[CHANNEL]
|
||||
)
|
||||
|
||||
result = await saver.aget_delta_channel_history(
|
||||
config=_empty_checkpoint_id(configs[-1]), channels=[CHANNEL]
|
||||
)
|
||||
|
||||
assert latest[CHANNEL]["writes"]
|
||||
assert result == latest
|
||||
|
||||
|
||||
def test_sync_empty_checkpoint_id_reads_from_the_latest_checkpoint() -> None:
|
||||
with PostgresSaver.from_conn_string(DEFAULT_URI) as saver:
|
||||
saver.setup()
|
||||
configs = _build_chain(saver)
|
||||
latest = saver.get_delta_channel_history(config=configs[-1], channels=[CHANNEL])
|
||||
|
||||
result = saver.get_delta_channel_history(
|
||||
config=_empty_checkpoint_id(configs[-1]), channels=[CHANNEL]
|
||||
)
|
||||
|
||||
assert latest[CHANNEL]["writes"]
|
||||
assert result == latest
|
||||
|
||||
|
||||
async def test_root_target_has_no_history_and_still_terminates(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr("langgraph.checkpoint.postgres.aio._DELTA_PAGE_SIZE", 1)
|
||||
async with AsyncPostgresSaver.from_conn_string(DEFAULT_URI) as saver:
|
||||
await saver.setup()
|
||||
configs = await _abuild_chain(saver)
|
||||
result = await saver.aget_delta_channel_history(
|
||||
config=configs[0], channels=[CHANNEL]
|
||||
)
|
||||
assert result[CHANNEL] == {"writes": []}
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Seed detection for `DeltaChannel` histories on Postgres.
|
||||
|
||||
`put` splits stored values in two: primitives stay inline in the checkpoint's
|
||||
`channel_values`, everything else moves to `checkpoint_blobs`. Only
|
||||
`_DeltaSnapshot` leaves an inline marker behind when it moves, so the stage-1
|
||||
walk has to check both places — a blob probe alone misses inline primitives, and
|
||||
an inline-key check alone missed blob-stored plain values, which is what a thread
|
||||
migrated from a pre-delta channel type leaves behind. See #8534.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from langgraph.checkpoint.base import Checkpoint, empty_checkpoint
|
||||
from langgraph.checkpoint.base.id import uuid6
|
||||
from langgraph.checkpoint.serde.types import _DeltaSnapshot
|
||||
|
||||
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
|
||||
from tests.conftest import DEFAULT_URI
|
||||
|
||||
CHANNEL = "items"
|
||||
|
||||
|
||||
async def _build_chain(saver: AsyncPostgresSaver, seed_value: Any) -> tuple[str, dict]:
|
||||
"""Store `seed_value` at step 1, then two steps that store nothing.
|
||||
|
||||
Every step carries a write so the walk has something to collect.
|
||||
Returns `(thread_id, head_config)`.
|
||||
"""
|
||||
thread_id = str(uuid4())
|
||||
parent: dict | None = None
|
||||
for step in range(4):
|
||||
config: dict = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
|
||||
if parent is not None:
|
||||
config["configurable"]["checkpoint_id"] = parent["configurable"][
|
||||
"checkpoint_id"
|
||||
]
|
||||
cp: Checkpoint = empty_checkpoint()
|
||||
cp["id"] = str(uuid6(clock_seq=step))
|
||||
new_versions: dict[str, Any] = {}
|
||||
if step == 1:
|
||||
cp["channel_values"][CHANNEL] = seed_value
|
||||
cp["channel_versions"][CHANNEL] = "v1"
|
||||
new_versions[CHANNEL] = "v1"
|
||||
else:
|
||||
cp["channel_versions"][CHANNEL] = f"v{step}"
|
||||
parent = await saver.aput(
|
||||
config, cp, {"source": "loop", "step": step, "parents": {}}, new_versions
|
||||
)
|
||||
await saver.aput_writes(parent, [(CHANNEL, f"w{step}")], str(uuid4()))
|
||||
assert parent is not None
|
||||
return thread_id, parent
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plain_value_seed_is_found() -> None:
|
||||
"""A pre-delta plain value must be located as the seed.
|
||||
|
||||
Before #8534 the walk ran to the root and returned no seed, which happens
|
||||
to reconstruct correctly for additive reducers while costing an
|
||||
O(thread length) replay on every read.
|
||||
"""
|
||||
async with AsyncPostgresSaver.from_conn_string(DEFAULT_URI) as saver:
|
||||
await saver.setup()
|
||||
_, head = await _build_chain(saver, [10, 20])
|
||||
|
||||
result = await saver.aget_delta_channel_history(config=head, channels=[CHANNEL])
|
||||
entry = result[CHANNEL]
|
||||
|
||||
assert entry.get("seed") == [10, 20], (
|
||||
f"expected the plain value as seed, got {entry.get('seed', '<missing>')}"
|
||||
)
|
||||
# Only the writes between the seed and the head's parent replay: step 1
|
||||
# (the seed's own) and step 2. Step 0 is older than the seed, step 3 is
|
||||
# pending at the head.
|
||||
assert [w[2] for w in entry["writes"]] == ["w1", "w2"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delta_snapshot_seed_is_found() -> None:
|
||||
"""The `_DeltaSnapshot` path keeps working, so both seed kinds agree."""
|
||||
async with AsyncPostgresSaver.from_conn_string(DEFAULT_URI) as saver:
|
||||
await saver.setup()
|
||||
_, head = await _build_chain(saver, _DeltaSnapshot([10, 20]))
|
||||
|
||||
result = await saver.aget_delta_channel_history(config=head, channels=[CHANNEL])
|
||||
entry = result[CHANNEL]
|
||||
|
||||
seed = entry.get("seed")
|
||||
assert isinstance(seed, _DeltaSnapshot), f"expected a snapshot, got {seed!r}"
|
||||
assert seed.value == [10, 20]
|
||||
assert [w[2] for w in entry["writes"]] == ["w1", "w2"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_version_bump_without_a_value_does_not_hide_an_older_seed() -> None:
|
||||
"""A delta-era step bumps `channel_versions` without storing a value, so no
|
||||
blob exists for that version. The probe must report no seed there and keep
|
||||
walking rather than stopping at a version it cannot resolve.
|
||||
|
||||
Step 0 holds the real value; step 1 bumps the version with nothing stored.
|
||||
Walking back from the head has to pass step 1 to reach step 0.
|
||||
"""
|
||||
async with AsyncPostgresSaver.from_conn_string(DEFAULT_URI) as saver:
|
||||
await saver.setup()
|
||||
thread_id = str(uuid4())
|
||||
parent: dict | None = None
|
||||
for step in range(4):
|
||||
config: dict = {
|
||||
"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}
|
||||
}
|
||||
if parent is not None:
|
||||
config["configurable"]["checkpoint_id"] = parent["configurable"][
|
||||
"checkpoint_id"
|
||||
]
|
||||
cp: Checkpoint = empty_checkpoint()
|
||||
cp["id"] = str(uuid6(clock_seq=step))
|
||||
new_versions: dict[str, Any] = {}
|
||||
if step == 0:
|
||||
cp["channel_values"][CHANNEL] = [10, 20]
|
||||
cp["channel_versions"][CHANNEL] = "v0"
|
||||
new_versions[CHANNEL] = "v0"
|
||||
elif step == 1:
|
||||
# Version bumped, value absent -> no blob row written.
|
||||
cp["channel_versions"][CHANNEL] = "v1"
|
||||
new_versions[CHANNEL] = "v1"
|
||||
else:
|
||||
cp["channel_versions"][CHANNEL] = "v1"
|
||||
parent = await saver.aput(
|
||||
config,
|
||||
cp,
|
||||
{"source": "loop", "step": step, "parents": {}},
|
||||
new_versions,
|
||||
)
|
||||
await saver.aput_writes(parent, [(CHANNEL, f"w{step}")], str(uuid4()))
|
||||
assert parent is not None
|
||||
|
||||
result = await saver.aget_delta_channel_history(
|
||||
config=parent, channels=[CHANNEL]
|
||||
)
|
||||
entry = result[CHANNEL]
|
||||
|
||||
assert entry.get("seed") == [10, 20], (
|
||||
"the walk stopped at the empty blob instead of reaching the real "
|
||||
f"value at step 0; got {entry.get('seed', '<missing>')}"
|
||||
)
|
||||
assert [w[2] for w in entry["writes"]] == ["w0", "w1", "w2"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inline_primitive_seed_is_found() -> None:
|
||||
"""`put` keeps `None`, `str`, `int`, `float` and `bool` in the checkpoint's
|
||||
own `channel_values` with no blob row, so a blob probe alone cannot see
|
||||
them. Stage 1 reads the inline value too and uses it when there is no blob.
|
||||
"""
|
||||
async with AsyncPostgresSaver.from_conn_string(DEFAULT_URI) as saver:
|
||||
await saver.setup()
|
||||
for seed_value in (42, "x", 3.5, None):
|
||||
_, head = await _build_chain(saver, seed_value)
|
||||
entry = (
|
||||
await saver.aget_delta_channel_history(config=head, channels=[CHANNEL])
|
||||
)[CHANNEL]
|
||||
if seed_value is None:
|
||||
# A JSON null is indistinguishable from "no value stored", so
|
||||
# the walk keeps going; replay from empty is the correct result.
|
||||
assert "seed" not in entry
|
||||
else:
|
||||
assert entry.get("seed") == seed_value, (
|
||||
f"inline {type(seed_value).__name__} seed not found: "
|
||||
f"{entry.get('seed', '<missing>')!r}"
|
||||
)
|
||||
assert [w[2] for w in entry["writes"]] == ["w1", "w2"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inline_true_is_not_read_as_a_snapshot_marker() -> None:
|
||||
"""`put` inlines a literal `true` in `channel_values` as the marker for a
|
||||
`_DeltaSnapshot`, which is also what a genuine `bool` channel holding
|
||||
`True` looks like. A blob exists only in the snapshot case, so preferring
|
||||
the blob keeps the two apart.
|
||||
"""
|
||||
async with AsyncPostgresSaver.from_conn_string(DEFAULT_URI) as saver:
|
||||
await saver.setup()
|
||||
|
||||
_, head = await _build_chain(saver, True)
|
||||
entry = (
|
||||
await saver.aget_delta_channel_history(config=head, channels=[CHANNEL])
|
||||
)[CHANNEL]
|
||||
assert entry.get("seed") is True, (
|
||||
f"a real inline True must survive, got {entry.get('seed', '<missing>')!r}"
|
||||
)
|
||||
|
||||
_, snap_head = await _build_chain(saver, _DeltaSnapshot(True))
|
||||
snap_entry = (
|
||||
await saver.aget_delta_channel_history(config=snap_head, channels=[CHANNEL])
|
||||
)[CHANNEL]
|
||||
seed = snap_entry.get("seed")
|
||||
assert isinstance(seed, _DeltaSnapshot), (
|
||||
f"the marker must resolve to the blob, not inline true; got {seed!r}"
|
||||
)
|
||||
assert seed.value is True
|
||||
@@ -0,0 +1,540 @@
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from langgraph.checkpoint.base import empty_checkpoint
|
||||
from langgraph.checkpoint.base.id import uuid6
|
||||
from langgraph.checkpoint.conformance import validate
|
||||
from langgraph.checkpoint.conformance.initializer import checkpointer_test
|
||||
from langgraph.checkpoint.serde.types import TASKS, _DeltaSnapshot
|
||||
from psycopg import AsyncConnection
|
||||
|
||||
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
|
||||
from langgraph.checkpoint.postgres.base import (
|
||||
PRUNE_DELTA_THREADS_SQL,
|
||||
PRUNE_HEADS_SQL,
|
||||
PRUNE_REFERENCED_BLOBS_SQL,
|
||||
)
|
||||
from tests.conftest import DEFAULT_POSTGRES_URI, DEFAULT_URI
|
||||
from tests.test_async import _saver as _async_saver
|
||||
from tests.test_sync import _saver as _sync_saver
|
||||
|
||||
SAVERS = ["base", "pool", "pipe", "aio_base", "aio_pool", "aio_pipe"]
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _saver(name: str):
|
||||
if name.startswith("aio_"):
|
||||
async with _async_saver(name.removeprefix("aio_")) as saver:
|
||||
yield saver
|
||||
else:
|
||||
with _sync_saver(name) as saver:
|
||||
yield saver
|
||||
|
||||
|
||||
async def _call(saver: Any, method: str, *args: Any, **kwargs: Any) -> Any:
|
||||
if isinstance(saver, AsyncPostgresSaver):
|
||||
return await getattr(saver, f"a{method}")(*args, **kwargs)
|
||||
return getattr(saver, method)(*args, **kwargs)
|
||||
|
||||
|
||||
async def _rows(saver: Any, thread_id: str) -> dict[str, list[tuple]]:
|
||||
queries = {
|
||||
"checkpoints": "SELECT checkpoint_ns, checkpoint_id FROM checkpoints"
|
||||
" WHERE thread_id = %s ORDER BY 1, 2",
|
||||
"writes": "SELECT checkpoint_ns, checkpoint_id, task_id, idx"
|
||||
" FROM checkpoint_writes WHERE thread_id = %s ORDER BY 1, 2, 3, 4",
|
||||
"blobs": "SELECT checkpoint_ns, channel, version FROM checkpoint_blobs"
|
||||
" WHERE thread_id = %s ORDER BY 1, 2, 3",
|
||||
}
|
||||
rows = {}
|
||||
for table, sql in queries.items():
|
||||
if isinstance(saver, AsyncPostgresSaver):
|
||||
async with saver._cursor() as cur:
|
||||
await cur.execute(sql, (thread_id,))
|
||||
rows[table] = [tuple(r.values()) for r in await cur.fetchall()]
|
||||
else:
|
||||
with saver._cursor() as cur:
|
||||
cur.execute(sql, (thread_id,))
|
||||
rows[table] = [tuple(r.values()) for r in cur.fetchall()]
|
||||
return rows
|
||||
|
||||
|
||||
async def _put(
|
||||
saver: Any,
|
||||
thread_id: str,
|
||||
parent: dict | None,
|
||||
step: int,
|
||||
values: dict[str, Any],
|
||||
versions: dict[str, str],
|
||||
*,
|
||||
ns: str = "",
|
||||
metadata: dict[str, Any] | None = None,
|
||||
v: int = 4,
|
||||
checkpoint_id: str | None = None,
|
||||
) -> dict:
|
||||
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ns}}
|
||||
if parent is not None:
|
||||
config["configurable"]["checkpoint_id"] = parent["configurable"][
|
||||
"checkpoint_id"
|
||||
]
|
||||
checkpoint = empty_checkpoint()
|
||||
checkpoint.update(
|
||||
v=v,
|
||||
id=checkpoint_id or str(uuid6(clock_seq=step)),
|
||||
channel_values=values,
|
||||
channel_versions=versions,
|
||||
)
|
||||
new_versions = {k: versions[k] for k in values}
|
||||
return await _call(
|
||||
saver,
|
||||
"put",
|
||||
config,
|
||||
checkpoint,
|
||||
{"source": "loop", "step": step, "parents": {}, **(metadata or {})},
|
||||
new_versions,
|
||||
)
|
||||
|
||||
|
||||
async def _three_checkpoints(
|
||||
saver: Any,
|
||||
thread_id: str,
|
||||
ns: str = "",
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> dict:
|
||||
parent = None
|
||||
steps = [
|
||||
({"a": [1], "s": ["shared"]}, {"a": "1", "s": "1"}),
|
||||
({"a": [1, 2]}, {"a": "2", "s": "1"}),
|
||||
({"a": [1, 2, 3]}, {"a": "3", "s": "1"}),
|
||||
]
|
||||
for step, (values, versions) in enumerate(steps):
|
||||
parent = await _put(
|
||||
saver, thread_id, parent, step, values, versions, ns=ns, metadata=metadata
|
||||
)
|
||||
await _call(saver, "put_writes", parent, [("a", step)], str(uuid4()))
|
||||
return parent
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _second_saver(saver: AsyncPostgresSaver):
|
||||
assert isinstance(saver.conn, AsyncConnection)
|
||||
uri = DEFAULT_POSTGRES_URI + saver.conn.info.dbname
|
||||
async with AsyncPostgresSaver.from_conn_string(uri) as other:
|
||||
yield other
|
||||
|
||||
|
||||
def _run_once_after(
|
||||
saver: AsyncPostgresSaver,
|
||||
sql: str,
|
||||
action: Callable[[], Awaitable[Any]],
|
||||
*,
|
||||
nth: int = 1,
|
||||
) -> None:
|
||||
seen = 0
|
||||
cursor = saver._cursor
|
||||
|
||||
class Hooked:
|
||||
def __init__(self, cur: Any) -> None:
|
||||
self.cur = cur
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self.cur, name)
|
||||
|
||||
async def execute(self, query: Any, params: Any = None, **kwargs: Any) -> None:
|
||||
nonlocal seen
|
||||
await self.cur.execute(query, params, **kwargs)
|
||||
if query is sql:
|
||||
seen += 1
|
||||
if seen == nth:
|
||||
await action()
|
||||
|
||||
@asynccontextmanager
|
||||
async def hooked(**kwargs: Any):
|
||||
async with cursor(**kwargs) as cur:
|
||||
yield Hooked(cur)
|
||||
|
||||
saver._cursor = hooked
|
||||
|
||||
|
||||
async def _delta_thread(saver: Any, thread_id: str, evidence: str) -> None:
|
||||
if evidence == "counters":
|
||||
first = await _put(saver, thread_id, None, 0, {"m": [1]}, {"m": "1"})
|
||||
await _put(
|
||||
saver,
|
||||
thread_id,
|
||||
first,
|
||||
1,
|
||||
{},
|
||||
{"m": "2"},
|
||||
metadata={"counters_since_delta_snapshot": {"m": [1, 1]}},
|
||||
)
|
||||
elif evidence == "snapshot":
|
||||
first = await _put(
|
||||
saver, thread_id, None, 0, {"m": _DeltaSnapshot([1])}, {"m": "1"}
|
||||
)
|
||||
await _put(saver, thread_id, first, 1, {}, {"m": "2"})
|
||||
else:
|
||||
marker = {"delta_writes_versioned": True}
|
||||
first = await _put(
|
||||
saver, thread_id, None, 0, {"m": [1]}, {"m": "1"}, metadata=marker
|
||||
)
|
||||
await _put(
|
||||
saver, thread_id, first, 1, {"m": [1, 2]}, {"m": "2"}, metadata=marker
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", SAVERS)
|
||||
async def test_keep_latest_keeps_each_namespace_head_with_its_writes_and_blobs(
|
||||
name: str,
|
||||
) -> None:
|
||||
async with _saver(name) as saver:
|
||||
thread_id, other_id = str(uuid4()), str(uuid4())
|
||||
heads = {
|
||||
ns: await _three_checkpoints(saver, thread_id, ns) for ns in ("", "sub:1")
|
||||
}
|
||||
await _three_checkpoints(saver, other_id)
|
||||
before = {ns: await _call(saver, "get_tuple", h) for ns, h in heads.items()}
|
||||
other_before = await _rows(saver, other_id)
|
||||
|
||||
await _call(saver, "prune", [thread_id])
|
||||
|
||||
rows = await _rows(saver, thread_id)
|
||||
assert rows["checkpoints"] == [
|
||||
(ns, heads[ns]["configurable"]["checkpoint_id"]) for ns in ("", "sub:1")
|
||||
]
|
||||
assert [(ns, cid) for ns, cid, *_ in rows["writes"]] == rows["checkpoints"]
|
||||
assert rows["blobs"] == [
|
||||
(ns, ch, ver)
|
||||
for ns in ("", "sub:1")
|
||||
for ch, ver in (("a", "3"), ("s", "1"))
|
||||
]
|
||||
for ns, head in heads.items():
|
||||
assert await _call(saver, "get_tuple", head) == before[ns]._replace(
|
||||
parent_config=None
|
||||
)
|
||||
assert await _rows(saver, other_id) == other_before
|
||||
|
||||
|
||||
@pytest.mark.parametrize("evidence", ["counters", "snapshot", "versioned"])
|
||||
@pytest.mark.parametrize("name", SAVERS)
|
||||
async def test_keep_latest_refuses_a_batch_with_a_delta_channel_thread(
|
||||
name: str, evidence: str
|
||||
) -> None:
|
||||
async with _saver(name) as saver:
|
||||
ordinary_id, delta_id = str(uuid4()), str(uuid4())
|
||||
await _three_checkpoints(saver, ordinary_id)
|
||||
await _delta_thread(saver, delta_id, evidence)
|
||||
before = [await _rows(saver, t) for t in (ordinary_id, delta_id)]
|
||||
|
||||
with pytest.raises(NotImplementedError, match=delta_id):
|
||||
await _call(saver, "prune", [ordinary_id, delta_id])
|
||||
|
||||
assert [await _rows(saver, t) for t in (ordinary_id, delta_id)] == before
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", SAVERS)
|
||||
async def test_keep_latest_prunes_a_thread_storing_a_plain_true(name: str) -> None:
|
||||
async with _saver(name) as saver:
|
||||
thread_id = str(uuid4())
|
||||
first = await _put(
|
||||
saver, thread_id, None, 0, {"flag": True, "a": [1]}, {"flag": "1", "a": "1"}
|
||||
)
|
||||
head = await _put(
|
||||
saver, thread_id, first, 1, {"flag": True}, {"flag": "2", "a": "1"}
|
||||
)
|
||||
|
||||
await _call(saver, "prune", [thread_id])
|
||||
|
||||
assert (await _rows(saver, thread_id))["checkpoints"] == [
|
||||
("", head["configurable"]["checkpoint_id"])
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", SAVERS)
|
||||
async def test_keep_latest_keeps_the_parent_holding_a_pre_v4_heads_sends(
|
||||
name: str,
|
||||
) -> None:
|
||||
async with _saver(name) as saver:
|
||||
thread_id = str(uuid4())
|
||||
first = await _put(saver, thread_id, None, 0, {}, {}, v=1)
|
||||
await _call(saver, "put_writes", first, [(TASKS, "send-1")], str(uuid4()))
|
||||
second = await _put(saver, thread_id, first, 1, {}, {}, v=1)
|
||||
head = await _put(saver, thread_id, second, 2, {}, {}, v=1)
|
||||
await _call(saver, "put_writes", second, [(TASKS, "send-2")], str(uuid4()))
|
||||
|
||||
await _call(saver, "prune", [thread_id])
|
||||
|
||||
assert (await _rows(saver, thread_id))["checkpoints"] == [
|
||||
("", second["configurable"]["checkpoint_id"]),
|
||||
("", head["configurable"]["checkpoint_id"]),
|
||||
]
|
||||
loaded = await _call(saver, "get_tuple", head)
|
||||
assert loaded.checkpoint["channel_values"][TASKS] == ["send-2"]
|
||||
assert loaded.parent_config == second
|
||||
assert (await _call(saver, "get_tuple", second)).parent_config is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", SAVERS)
|
||||
async def test_delete_removes_every_row_of_the_threads(name: str) -> None:
|
||||
async with _saver(name) as saver:
|
||||
ordinary_id, delta_id, other_id = str(uuid4()), str(uuid4()), str(uuid4())
|
||||
await _three_checkpoints(saver, ordinary_id)
|
||||
await _delta_thread(saver, delta_id, "counters")
|
||||
await _three_checkpoints(saver, other_id)
|
||||
other_before = await _rows(saver, other_id)
|
||||
|
||||
await _call(saver, "prune", [ordinary_id, delta_id], strategy="delete")
|
||||
|
||||
empty = {"checkpoints": [], "writes": [], "blobs": []}
|
||||
assert await _rows(saver, ordinary_id) == empty
|
||||
assert await _rows(saver, delta_id) == empty
|
||||
assert await _rows(saver, other_id) == other_before
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", SAVERS)
|
||||
async def test_unknown_strategy_changes_nothing(name: str) -> None:
|
||||
async with _saver(name) as saver:
|
||||
thread_id = str(uuid4())
|
||||
await _three_checkpoints(saver, thread_id)
|
||||
before = await _rows(saver, thread_id)
|
||||
|
||||
with pytest.raises(ValueError, match="keep_all"):
|
||||
await _call(saver, "prune", [thread_id], strategy="keep_all")
|
||||
|
||||
assert await _rows(saver, thread_id) == before
|
||||
|
||||
|
||||
async def test_async_saver_prune_runs_from_another_thread_only() -> None:
|
||||
async with _saver("aio_base") as saver:
|
||||
thread_id = str(uuid4())
|
||||
head = await _three_checkpoints(saver, thread_id)
|
||||
|
||||
with pytest.raises(asyncio.InvalidStateError):
|
||||
saver.prune([thread_id])
|
||||
await asyncio.to_thread(saver.prune, [thread_id])
|
||||
|
||||
assert (await _rows(saver, thread_id))["checkpoints"] == [
|
||||
("", head["configurable"]["checkpoint_id"])
|
||||
]
|
||||
|
||||
|
||||
async def test_prune_conformance() -> None:
|
||||
@checkpointer_test(name="AsyncPostgresSaver")
|
||||
async def postgres_saver():
|
||||
async with AsyncPostgresSaver.from_conn_string(DEFAULT_URI) as saver:
|
||||
await saver.setup()
|
||||
yield saver
|
||||
|
||||
report = await validate(postgres_saver, capabilities={"prune"})
|
||||
|
||||
prune = report.results["prune"]
|
||||
if not prune.passed:
|
||||
details = "\n".join(prune.failures)
|
||||
pytest.fail(f"Capability prune failed (passed={prune.passed}):\n{details}")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("strategy", ["keep_latest", "delete"])
|
||||
@pytest.mark.parametrize("name", ["base", "aio_base"])
|
||||
async def test_prune_rejects_a_single_thread_id_string(
|
||||
name: str, strategy: str
|
||||
) -> None:
|
||||
async with _saver(name) as saver:
|
||||
for thread_id in ("ab", "a", "b"):
|
||||
await _three_checkpoints(saver, thread_id)
|
||||
before = [await _rows(saver, t) for t in ("ab", "a", "b")]
|
||||
|
||||
with pytest.raises(TypeError, match="not a str"):
|
||||
await _call(saver, "prune", "ab", strategy=strategy)
|
||||
|
||||
assert [await _rows(saver, t) for t in ("ab", "a", "b")] == before
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"after", [PRUNE_HEADS_SQL, PRUNE_REFERENCED_BLOBS_SQL], ids=["heads", "blobs"]
|
||||
)
|
||||
async def test_keep_latest_keeps_a_checkpoint_saved_while_it_runs(after: str) -> None:
|
||||
async with _saver("aio_base") as saver, _second_saver(saver) as other:
|
||||
thread_id = str(uuid4())
|
||||
head = await _three_checkpoints(saver, thread_id)
|
||||
saved: list[dict] = []
|
||||
|
||||
async def save_a_child() -> None:
|
||||
saved.append(
|
||||
await _put(
|
||||
other, thread_id, head, 3, {"a": [1, 2, 3, 4]}, {"a": "4", "s": "1"}
|
||||
)
|
||||
)
|
||||
|
||||
_run_once_after(saver, after, save_a_child)
|
||||
await saver.aprune([thread_id])
|
||||
|
||||
loaded = await saver.aget_tuple(saved[0])
|
||||
assert loaded is not None, "the checkpoint saved during the prune was deleted"
|
||||
assert loaded.checkpoint["channel_values"] == {
|
||||
"a": [1, 2, 3, 4],
|
||||
"s": ["shared"],
|
||||
}
|
||||
assert loaded.parent_config == head
|
||||
|
||||
|
||||
async def test_keep_latest_refuses_a_thread_that_turns_delta_after_the_batch_check() -> (
|
||||
None
|
||||
):
|
||||
async with _saver("aio_base") as saver, _second_saver(saver) as other:
|
||||
thread_id = str(uuid4())
|
||||
head = await _three_checkpoints(saver, thread_id)
|
||||
|
||||
async def save_a_delta_checkpoint() -> None:
|
||||
await _put(
|
||||
other,
|
||||
thread_id,
|
||||
head,
|
||||
3,
|
||||
{},
|
||||
{"a": "4", "s": "1"},
|
||||
metadata={"counters_since_delta_snapshot": {"a": [1, 1]}},
|
||||
)
|
||||
|
||||
_run_once_after(saver, PRUNE_DELTA_THREADS_SQL, save_a_delta_checkpoint)
|
||||
with pytest.raises(NotImplementedError, match=thread_id):
|
||||
await saver.aprune([thread_id])
|
||||
|
||||
assert len((await _rows(saver, thread_id))["checkpoints"]) == 4
|
||||
|
||||
|
||||
async def test_keep_latest_keeps_the_history_of_a_delta_checkpoint_saved_after_the_thread_check() -> (
|
||||
None
|
||||
):
|
||||
async with _saver("aio_base") as saver, _second_saver(saver) as other:
|
||||
thread_id = str(uuid4())
|
||||
head = await _three_checkpoints(saver, thread_id)
|
||||
saved: list[dict] = []
|
||||
|
||||
async def save_a_delta_checkpoint() -> None:
|
||||
saved.append(
|
||||
await _put(
|
||||
other,
|
||||
thread_id,
|
||||
head,
|
||||
3,
|
||||
{},
|
||||
{"a": "4", "s": "1"},
|
||||
metadata={"counters_since_delta_snapshot": {"a": [1, 1]}},
|
||||
)
|
||||
)
|
||||
|
||||
_run_once_after(saver, PRUNE_DELTA_THREADS_SQL, save_a_delta_checkpoint, nth=2)
|
||||
await saver.aprune([thread_id])
|
||||
|
||||
assert (await _rows(saver, thread_id))["checkpoints"] == [
|
||||
("", head["configurable"]["checkpoint_id"]),
|
||||
("", saved[0]["configurable"]["checkpoint_id"]),
|
||||
]
|
||||
history = await saver.aget_delta_channel_history(
|
||||
config=saved[0], channels=["a"]
|
||||
)
|
||||
assert history["a"]["seed"] == [1, 2, 3]
|
||||
assert [value for _, _, value in history["a"]["writes"]] == [2]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", SAVERS)
|
||||
async def test_keep_latest_deletes_a_subgraph_call_with_the_checkpoint_it_started_from(
|
||||
name: str,
|
||||
) -> None:
|
||||
async with _saver(name) as saver:
|
||||
thread_id = str(uuid4())
|
||||
older = await _put(saver, thread_id, None, 0, {}, {})
|
||||
head = await _put(saver, thread_id, older, 1, {}, {})
|
||||
unsaved = str(uuid6(clock_seq=2))
|
||||
started_from = {
|
||||
"finished:1": older["configurable"]["checkpoint_id"],
|
||||
"interrupted:2": head["configurable"]["checkpoint_id"],
|
||||
"running:3": unsaved,
|
||||
"checkpointer_true": older["configurable"]["checkpoint_id"],
|
||||
}
|
||||
for ns, root in started_from.items():
|
||||
await _three_checkpoints(
|
||||
saver, thread_id, ns, metadata={"parents": {"": root}}
|
||||
)
|
||||
|
||||
await _call(saver, "prune", [thread_id])
|
||||
|
||||
rows = await _rows(saver, thread_id)
|
||||
assert {ns for ns, *_ in rows["checkpoints"]} == {
|
||||
"",
|
||||
"interrupted:2",
|
||||
"running:3",
|
||||
"checkpointer_true",
|
||||
}
|
||||
assert {ns for ns, *_ in rows["writes"]} == {
|
||||
"interrupted:2",
|
||||
"running:3",
|
||||
"checkpointer_true",
|
||||
}
|
||||
assert {ns for ns, *_ in rows["blobs"]} == {
|
||||
"interrupted:2",
|
||||
"running:3",
|
||||
"checkpointer_true",
|
||||
}
|
||||
|
||||
|
||||
async def test_keep_latest_keeps_a_checkpoint_saved_meanwhile_whatever_its_id() -> None:
|
||||
async with _saver("aio_base") as saver, _second_saver(saver) as other:
|
||||
thread_id = str(uuid4())
|
||||
head = await _three_checkpoints(saver, thread_id)
|
||||
lower_id = "0" + head["configurable"]["checkpoint_id"][1:]
|
||||
saved: list[dict] = []
|
||||
|
||||
async def save_a_child_with_a_lower_id() -> None:
|
||||
child = await _put(
|
||||
other,
|
||||
thread_id,
|
||||
head,
|
||||
3,
|
||||
{"a": [1, 2, 3, 4]},
|
||||
{"a": "4", "s": "1"},
|
||||
checkpoint_id=lower_id,
|
||||
)
|
||||
await _call(other, "put_writes", child, [("a", 4)], str(uuid4()))
|
||||
saved.append(child)
|
||||
|
||||
_run_once_after(saver, PRUNE_HEADS_SQL, save_a_child_with_a_lower_id)
|
||||
await saver.aprune([thread_id])
|
||||
|
||||
loaded = await saver.aget_tuple(saved[0])
|
||||
assert loaded is not None, "the checkpoint saved during the prune was deleted"
|
||||
assert loaded.checkpoint["channel_values"] == {
|
||||
"a": [1, 2, 3, 4],
|
||||
"s": ["shared"],
|
||||
}
|
||||
assert [value for _, _, value in loaded.pending_writes or []] == [4]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", SAVERS)
|
||||
async def test_keep_latest_keeps_writes_saved_before_their_checkpoint(
|
||||
name: str,
|
||||
) -> None:
|
||||
async with _saver(name) as saver:
|
||||
thread_id = str(uuid4())
|
||||
head = await _three_checkpoints(saver, thread_id)
|
||||
next_id = str(uuid6(clock_seq=3))
|
||||
next_config = {
|
||||
"configurable": {
|
||||
"thread_id": thread_id,
|
||||
"checkpoint_ns": "",
|
||||
"checkpoint_id": next_id,
|
||||
}
|
||||
}
|
||||
await _call(saver, "put_writes", next_config, [("a", 4)], str(uuid4()))
|
||||
|
||||
await _call(saver, "prune", [thread_id])
|
||||
await _put(
|
||||
saver, thread_id, head, 3, {}, {"a": "3", "s": "1"}, checkpoint_id=next_id
|
||||
)
|
||||
|
||||
loaded = await _call(saver, "get_tuple", next_config)
|
||||
assert [value for _, _, value in loaded.pending_writes] == [4]
|
||||
@@ -11,6 +11,7 @@ import pytest
|
||||
from langchain_core.embeddings import Embeddings
|
||||
from langgraph.store.base import (
|
||||
GetOp,
|
||||
InvalidNamespaceError,
|
||||
Item,
|
||||
ListNamespacesOp,
|
||||
MatchCondition,
|
||||
@@ -20,6 +21,10 @@ from langgraph.store.base import (
|
||||
from psycopg import Connection
|
||||
|
||||
from langgraph.store.postgres import PostgresStore
|
||||
from langgraph.store.postgres.base import (
|
||||
_escape_like_literal,
|
||||
_namespace_match_pattern,
|
||||
)
|
||||
from tests.conftest import (
|
||||
DEFAULT_URI,
|
||||
VECTOR_TYPES,
|
||||
@@ -326,6 +331,127 @@ def test_list_namespaces(store) -> None:
|
||||
store.delete(namespace, "dummy")
|
||||
|
||||
|
||||
def test_escape_like_literal() -> None:
|
||||
assert _escape_like_literal("users.alice") == "users.alice"
|
||||
assert _escape_like_literal("user_1") == r"user\_1"
|
||||
assert _escape_like_literal("100%") == r"100\%"
|
||||
assert _escape_like_literal("a\\b") == "a\\\\b"
|
||||
assert _escape_like_literal("") == ""
|
||||
|
||||
|
||||
def test_namespace_match_pattern() -> None:
|
||||
assert _namespace_match_pattern(("foo",), "prefix") == r"^foo(\.|\Z)"
|
||||
assert _namespace_match_pattern(("uid", "users"), "prefix") == r"^uid\.users(\.|\Z)"
|
||||
assert (
|
||||
_namespace_match_pattern(("uid", "*", "alice"), "prefix")
|
||||
== r"^uid\.[^.]+\.alice(\.|\Z)"
|
||||
)
|
||||
assert _namespace_match_pattern(("alice",), "suffix") == r"(^|\.)alice\Z"
|
||||
|
||||
# Regex metacharacters in a label are quoted, not interpreted.
|
||||
pattern = _namespace_match_pattern(("a.b+c",), "prefix")
|
||||
assert re.match(pattern, "a.b+c.child")
|
||||
assert not re.match(pattern, "axbbbc")
|
||||
|
||||
|
||||
def test_search_namespace_segment_boundary(store) -> None:
|
||||
"""Prefix scoping must stop at namespace segment boundaries.
|
||||
|
||||
Namespaces are stored dot-joined, so matching the raw text also returns
|
||||
siblings sharing leading characters. Callers isolate tenants by namespace,
|
||||
so prefix-shaped ids (1 vs 12) would cross-read.
|
||||
"""
|
||||
for namespace in [
|
||||
("foo",),
|
||||
("foo", "child"),
|
||||
("foo", "child", "deep"),
|
||||
("foobar",),
|
||||
("foobar", "baz"),
|
||||
("foo2",),
|
||||
]:
|
||||
store.put(namespace, "k", {"v": 1})
|
||||
|
||||
def _namespaces(prefix: tuple[str, ...]) -> set[tuple[str, ...]]:
|
||||
return {item.namespace for item in store.search(prefix, limit=100)}
|
||||
|
||||
assert _namespaces(("foo",)) == {
|
||||
("foo",),
|
||||
("foo", "child"),
|
||||
("foo", "child", "deep"),
|
||||
}
|
||||
# The sibling scope is independent, not merely narrower.
|
||||
assert _namespaces(("foobar",)) == {("foobar",), ("foobar", "baz")}
|
||||
assert _namespaces(("foo", "child")) == {("foo", "child"), ("foo", "child", "deep")}
|
||||
assert _namespaces(("foo2",)) == {("foo2",)}
|
||||
assert _namespaces(("fo",)) == set()
|
||||
|
||||
|
||||
def test_search_empty_prefix_is_unconstrained(store) -> None:
|
||||
"""An empty prefix constrains nothing and must return every namespace."""
|
||||
for namespace in [("a",), ("b", "c"), ("d", "e", "f")]:
|
||||
store.put(namespace, "k", {"v": 1})
|
||||
|
||||
assert {item.namespace for item in store.search((), limit=100)} == {
|
||||
("a",),
|
||||
("b", "c"),
|
||||
("d", "e", "f"),
|
||||
}
|
||||
|
||||
|
||||
def test_search_namespace_like_metacharacters(store) -> None:
|
||||
"""`_` and `%` are legal namespace labels, not LIKE wildcards."""
|
||||
for namespace in [
|
||||
("user_1",),
|
||||
("user_1", "child"),
|
||||
("userX1",),
|
||||
("a%b",),
|
||||
("axxb",),
|
||||
]:
|
||||
store.put(namespace, "k", {"v": 1})
|
||||
|
||||
def _namespaces(prefix: tuple[str, ...]) -> set[tuple[str, ...]]:
|
||||
return {item.namespace for item in store.search(prefix, limit=100)}
|
||||
|
||||
# Also asserts the namespace still matches itself, which catches escaping
|
||||
# the equality arm by mistake.
|
||||
assert _namespaces(("user_1",)) == {("user_1",), ("user_1", "child")}
|
||||
assert _namespaces(("a%b",)) == {("a%b",)}
|
||||
|
||||
|
||||
def test_list_namespaces_segment_boundary(store) -> None:
|
||||
for namespace in [
|
||||
("foo",),
|
||||
("foo", "child"),
|
||||
("foobar",),
|
||||
("foobar", "baz"),
|
||||
("uid", "users", "alice"),
|
||||
("uid", "users", "malice"),
|
||||
("uid", "a", "b", "alice"),
|
||||
]:
|
||||
store.put(namespace, "k", {"v": 1})
|
||||
|
||||
assert set(store.list_namespaces(prefix=["foo"], limit=100)) == {
|
||||
("foo",),
|
||||
("foo", "child"),
|
||||
}
|
||||
# Suffix must align to a segment: "malice" does not end with the "alice"
|
||||
# segment.
|
||||
assert set(store.list_namespaces(suffix=["alice"], limit=100)) == {
|
||||
("uid", "users", "alice"),
|
||||
("uid", "a", "b", "alice"),
|
||||
}
|
||||
# "*" spans exactly one segment.
|
||||
assert set(store.list_namespaces(prefix=["uid", "*", "alice"], limit=100)) == {
|
||||
("uid", "users", "alice"),
|
||||
}
|
||||
# Prefix matching stays open-ended across depth.
|
||||
assert set(store.list_namespaces(prefix=["uid"], limit=100)) == {
|
||||
("uid", "users", "alice"),
|
||||
("uid", "users", "malice"),
|
||||
("uid", "a", "b", "alice"),
|
||||
}
|
||||
|
||||
|
||||
def test_search(store) -> None:
|
||||
# Create test data
|
||||
test_data = [
|
||||
@@ -863,6 +989,133 @@ def test_store_ttl(store):
|
||||
assert len(res) == 0
|
||||
|
||||
|
||||
def _expire_now(store: PostgresStore, ns: tuple[str, ...], key: str) -> None:
|
||||
"""Backdate a row's expires_at into the past without deleting it (unswept)."""
|
||||
with store._cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE store SET expires_at = NOW() - INTERVAL '1 minute' "
|
||||
"WHERE prefix = %s AND key = %s",
|
||||
(".".join(ns), key),
|
||||
)
|
||||
|
||||
|
||||
def _row_exists(store: PostgresStore, ns: tuple[str, ...], key: str) -> bool:
|
||||
with store._cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT COUNT(*) AS n FROM store WHERE prefix = %s AND key = %s",
|
||||
(".".join(ns), key),
|
||||
)
|
||||
return cur.fetchone()["n"] == 1
|
||||
|
||||
|
||||
def _stored_expires_at(store: PostgresStore, ns: tuple[str, ...], key: str):
|
||||
with store._cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT expires_at FROM store WHERE prefix = %s AND key = %s",
|
||||
(".".join(ns), key),
|
||||
)
|
||||
return cur.fetchone()["expires_at"]
|
||||
|
||||
|
||||
def test_omit_expired_filters_read_paths(store: PostgresStore) -> None:
|
||||
store.stop_ttl_sweeper() # deterministic: no background deletion
|
||||
store.ttl_config["omit_expired"] = True
|
||||
|
||||
expired_ns = ("omit", "expired")
|
||||
control_ns = ("omit", "control")
|
||||
store.put(expired_ns, "e", {"data": "gone"}, ttl=TTL_MINUTES)
|
||||
store.put(control_ns, "c", {"data": "keep"}, ttl=None)
|
||||
_expire_now(store, expired_ns, "e")
|
||||
|
||||
# The row is expired but physically still present (unswept).
|
||||
assert _row_exists(store, expired_ns, "e")
|
||||
|
||||
# get omits it; the never-expiring control is still returned.
|
||||
assert store.get(expired_ns, "e") is None
|
||||
assert store.get(control_ns, "c") is not None
|
||||
|
||||
# search omits it but returns the control.
|
||||
assert store.search(expired_ns) == []
|
||||
assert [i.key for i in store.search(control_ns)] == ["c"]
|
||||
|
||||
# list_namespaces drops the expired-only namespace, keeps the control.
|
||||
namespaces = store.list_namespaces(prefix=("omit",))
|
||||
assert expired_ns not in namespaces
|
||||
assert control_ns in namespaces
|
||||
|
||||
|
||||
@pytest.mark.parametrize("omit", [None, False], ids=["default", "explicit-false"])
|
||||
def test_omit_expired_disabled_preserves_expired_rows(
|
||||
store: PostgresStore, omit
|
||||
) -> None:
|
||||
store.stop_ttl_sweeper()
|
||||
if omit is not None:
|
||||
store.ttl_config["omit_expired"] = omit
|
||||
|
||||
ns = ("keep",)
|
||||
store.put(ns, "k", {"data": "still-here"}, ttl=TTL_MINUTES)
|
||||
_expire_now(store, ns, "k")
|
||||
|
||||
assert store.get(ns, "k", refresh_ttl=False) is not None
|
||||
assert [i.key for i in store.search(ns, refresh_ttl=False)] == ["k"]
|
||||
assert ns in store.list_namespaces(prefix=("keep",))
|
||||
|
||||
|
||||
def test_omit_expired_refresh_ttl_only_refreshes_live_rows(
|
||||
store: PostgresStore,
|
||||
) -> None:
|
||||
store.stop_ttl_sweeper()
|
||||
store.ttl_config["omit_expired"] = True
|
||||
|
||||
ns = ("refresh",)
|
||||
store.put(ns, "expired", {"n": 0}, ttl=TTL_MINUTES)
|
||||
store.put(ns, "live_get", {"n": 1}, ttl=TTL_MINUTES)
|
||||
store.put(ns, "live_search", {"n": 2}, ttl=TTL_MINUTES)
|
||||
_expire_now(store, ns, "expired")
|
||||
|
||||
expired_before = _stored_expires_at(store, ns, "expired")
|
||||
get_before = _stored_expires_at(store, ns, "live_get")
|
||||
search_before = _stored_expires_at(store, ns, "live_search")
|
||||
|
||||
# refresh_ttl=True must NOT resurrect the expired row (via get or search)...
|
||||
assert store.get(ns, "expired", refresh_ttl=True) is None
|
||||
assert "expired" not in [i.key for i in store.search(ns, refresh_ttl=True)]
|
||||
assert _stored_expires_at(store, ns, "expired") == expired_before
|
||||
|
||||
# ...but must still extend the live rows that were read.
|
||||
assert store.get(ns, "live_get", refresh_ttl=True) is not None
|
||||
assert _stored_expires_at(store, ns, "live_get") > get_before
|
||||
assert _stored_expires_at(store, ns, "live_search") > search_before
|
||||
|
||||
|
||||
def test_omit_expired_search_pagination(store: PostgresStore) -> None:
|
||||
store.stop_ttl_sweeper()
|
||||
store.ttl_config["omit_expired"] = True
|
||||
|
||||
ns = ("page",)
|
||||
for k in ("a", "b", "c"):
|
||||
store.put(ns, k, {"k": k}, ttl=TTL_MINUTES)
|
||||
store.put(ns, "expired", {"k": "x"}, ttl=TTL_MINUTES)
|
||||
_expire_now(store, ns, "expired")
|
||||
|
||||
seconds_ago = {"a": 1, "expired": 2, "b": 3, "c": 4}
|
||||
# updated_at DESC orders these a, expired, b, c, so the expired row sits inside
|
||||
# the first limit=2 window. Correct (pre-LIMIT) filtering yields live pages
|
||||
# [a, b] then [c]; post-LIMIT filtering would underfill page 1 to just [a].
|
||||
with store._cursor() as cur:
|
||||
for key, secs in seconds_ago.items():
|
||||
cur.execute(
|
||||
"UPDATE store SET updated_at = NOW() - (%s * INTERVAL '1 second') "
|
||||
"WHERE prefix = %s AND key = %s",
|
||||
(secs, ".".join(ns), key),
|
||||
)
|
||||
|
||||
page1 = store.search(ns, limit=2, offset=0)
|
||||
page2 = store.search(ns, limit=2, offset=2)
|
||||
assert [i.key for i in page1] == ["a", "b"]
|
||||
assert [i.key for i in page2] == ["c"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"vector_type,distance_type",
|
||||
[
|
||||
@@ -899,3 +1152,64 @@ def test_non_ascii(
|
||||
assert result3[0].key == "3"
|
||||
assert result4[0].key == "4"
|
||||
assert result5[0].key == "5"
|
||||
|
||||
|
||||
def test_namespace_labels_with_trailing_newline(store) -> None:
|
||||
"""Labels may contain newlines, and must not match a differently-named label."""
|
||||
store.put(("users", "alice"), "k", {"v": 1})
|
||||
store.put(("users", "alice\n"), "k", {"v": 2})
|
||||
|
||||
assert set(store.list_namespaces(suffix=["alice"], limit=100)) == {
|
||||
("users", "alice"),
|
||||
}
|
||||
assert set(store.list_namespaces(prefix=["users", "alice"], limit=100)) == {
|
||||
("users", "alice"),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("namespace", [("foo.bar",), ("foo", ""), ("foo", 1)])
|
||||
@pytest.mark.parametrize(
|
||||
"kind", ["get", "put", "delete", "search", "list_prefix", "list_suffix"]
|
||||
)
|
||||
def test_batch_rejects_invalid_namespace_labels(
|
||||
store, namespace: tuple, kind: str
|
||||
) -> None:
|
||||
"""Ops passed straight to `batch` must not reach another namespace.
|
||||
|
||||
Namespaces are stored dot-joined, so `("foo.bar",)` flattens to the same
|
||||
text as `("foo", "bar")`. `BaseStore` methods validate labels themselves,
|
||||
but `batch` takes ops as given.
|
||||
"""
|
||||
op = {
|
||||
"get": GetOp(namespace, "key"),
|
||||
"put": PutOp(namespace, "key", {"changed": True}),
|
||||
"delete": PutOp(namespace, "key", None),
|
||||
"search": SearchOp(namespace),
|
||||
"list_prefix": ListNamespacesOp((MatchCondition("prefix", namespace),)),
|
||||
"list_suffix": ListNamespacesOp((MatchCondition("suffix", namespace),)),
|
||||
}[kind]
|
||||
store.put(("foo", "bar"), "key", {"original": True})
|
||||
|
||||
with pytest.raises(InvalidNamespaceError):
|
||||
store.batch([PutOp(("valid",), "key", {}), op])
|
||||
|
||||
item = store.get(("foo", "bar"), "key")
|
||||
assert item is not None and item.value == {"original": True}
|
||||
# The whole batch is rejected before any SQL runs.
|
||||
assert store.get(("valid",), "key") is None
|
||||
|
||||
|
||||
def test_batch_allows_empty_search_prefix_and_listing_wildcards(
|
||||
store,
|
||||
) -> None:
|
||||
store.put(("foo", "bar"), "key", {"v": 1})
|
||||
|
||||
found, listed = store.batch(
|
||||
[
|
||||
SearchOp(()),
|
||||
ListNamespacesOp((MatchCondition("prefix", ("foo", "*")),)),
|
||||
]
|
||||
)
|
||||
|
||||
assert [item.namespace for item in found] == [("foo", "bar")]
|
||||
assert listed == [("foo", "bar")]
|
||||
|
||||
Generated
+262
-208
@@ -17,16 +17,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.13.0"
|
||||
version = "4.15.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
||||
{ name = "idna" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
{ name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.15'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -138,11 +138,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "codespell"
|
||||
version = "2.4.2"
|
||||
version = "2.4.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2d/9d/1d0903dff693160f893ca6abcabad545088e7a2ee0a6deae7c24e958be69/codespell-2.4.2.tar.gz", hash = "sha256:3c33be9ae34543807f088aeb4832dfad8cb2dae38da61cac0a7045dd376cfdf3", size = 352058, upload-time = "2026-03-05T18:10:42.936Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/80/19/45e941380f69c042b43423513d201e6592346f992394347f5e7174c31407/codespell-2.4.3.tar.gz", hash = "sha256:cbe085e331227b37bb86ef8bddd08dc768c704ee9a07ca869852c093fa2793e2", size = 352773, upload-time = "2026-07-15T11:51:54.159Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/42/a1/52fa05533e95fe45bcc09bcf8a503874b1c08f221a4e35608017e0938f55/codespell-2.4.2-py3-none-any.whl", hash = "sha256:97e0c1060cf46bd1d5db89a936c98db8c2b804e1fdd4b5c645e82a1ec6b1f886", size = 353715, upload-time = "2026-03-05T18:10:41.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/bf/bdb951d34eb169140b546f44be9ec4525d1acefb9eb5572071f5492b19fc/codespell-2.4.3-py3-none-any.whl", hash = "sha256:af2505b335e8573dbd2d384d1c4ef498f4006f4ba2d6fceca01e55b91f52628a", size = 340736, upload-time = "2026-07-15T11:51:52.925Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -159,7 +159,7 @@ name = "exceptiongroup"
|
||||
version = "1.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
{ name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" } },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
|
||||
wheels = [
|
||||
@@ -244,7 +244,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langchain-core"
|
||||
version = "1.4.0"
|
||||
version = "1.5.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jsonpatch" },
|
||||
@@ -254,29 +254,31 @@ dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "tenacity" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.15'" },
|
||||
{ name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.15'" },
|
||||
{ name = "uuid-utils" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/59/de/679a53472c25860837e32c0442c962fa86e95317a36460e2c9d5c91b17c2/langchain_core-1.4.0.tar.gz", hash = "sha256:1dc341eed802ed9c117c0df3923c991e5e9e226571e5725c194eeb5bd93d1a7f", size = 920260, upload-time = "2026-05-11T18:42:35.919Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6e/58/3ad53096eee1e07728e8219ded30ae308b0f2b8b7b26b8ebb6371917c0f5/langchain_core-1.5.2.tar.gz", hash = "sha256:2d13ab35b42eec63d4669a483776b8cdd778ee764107149369fb369d84c08c41", size = 972322, upload-time = "2026-07-28T16:38:37.977Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/1a/86c38c27b81913a1c6c12448cab55defb5a1097c7dc9a4cea83f55477a2d/langchain_core-1.4.0-py3-none-any.whl", hash = "sha256:23cbbdb46e38ddd1dd5247e6167e96013eae74bea4c5949c550809970a9e565c", size = 548120, upload-time = "2026-05-11T18:42:33.992Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/e4/024e402a65f5ee2eb6ae9667b5ffc5f08776cb4e712f5a11ee1997d56083/langchain_core-1.5.2-py3-none-any.whl", hash = "sha256:a687dd7c3b22c6c1294e1c1eeb61fb6f3a308e6015a1d75b576b3836ad5b5aed", size = 561643, upload-time = "2026-07-28T16:38:36.38Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langchain-protocol"
|
||||
version = "0.0.15"
|
||||
version = "0.0.18"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.15'" },
|
||||
{ name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.15'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4f/24/9777489d6fbbee64af0c8f96d4f840239c408cf694f3394672807dafc490/langchain_protocol-0.0.15.tar.gz", hash = "sha256:9ab2d11ee73944754f10e037e717098d3a6796f0e58afa9cadda6154e7655ade", size = 5862, upload-time = "2026-05-01T22:30:04.748Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d2/59/b5959aea96faa9146e2e49a7a22882b3528c62efafe9a6a95beab30c2305/langchain_protocol-0.0.18.tar.gz", hash = "sha256:ec3e11782f1ed0c9db38e5a9ed01b0e7a0d3fba406faa8aef6594b73c56a63e6", size = 6150, upload-time = "2026-06-18T17:08:26.959Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/7a/9c97a7b9cbe4c5dc6a44cdb1545450c28f0c8ce89b9c1f0ee7fbad896263/langchain_protocol-0.0.15-py3-none-any.whl", hash = "sha256:461eb794358f83d5e42635a5797799ffec7b4702314e34edf73ac21e75d3ef79", size = 6982, upload-time = "2026-05-01T22:30:03.877Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/2e/d82db9eec13ad0f72e7aaad5c4bc730ab111934fdc83c85523206eb9b0a0/langchain_protocol-0.0.18-py3-none-any.whl", hash = "sha256:70b53a86fbf9cedc863555effe44da192ab02d556ddbf2cf95b8873adcf41b5a", size = 7221, upload-time = "2026-06-18T17:08:25.996Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-checkpoint"
|
||||
version = "4.1.1"
|
||||
version = "4.3.0"
|
||||
source = { editable = "../checkpoint" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -322,9 +324,36 @@ test = [
|
||||
{ name = "redis" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-checkpoint-conformance"
|
||||
version = "0.0.2"
|
||||
source = { editable = "../checkpoint-conformance" }
|
||||
dependencies = [
|
||||
{ name = "langgraph-checkpoint" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [{ name = "langgraph-checkpoint", editable = "../checkpoint" }]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "ruff" },
|
||||
{ name = "ty" },
|
||||
]
|
||||
lint = [
|
||||
{ name = "ruff" },
|
||||
{ name = "ty" },
|
||||
]
|
||||
test = [
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-checkpoint-postgres"
|
||||
version = "3.1.0"
|
||||
version = "3.2.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "langgraph-checkpoint" },
|
||||
@@ -338,6 +367,7 @@ dev = [
|
||||
{ name = "anyio" },
|
||||
{ name = "codespell" },
|
||||
{ name = "langgraph-checkpoint" },
|
||||
{ name = "langgraph-checkpoint-conformance" },
|
||||
{ name = "psycopg", extra = ["binary"] },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
@@ -354,6 +384,7 @@ lint = [
|
||||
test = [
|
||||
{ name = "anyio" },
|
||||
{ name = "langgraph-checkpoint" },
|
||||
{ name = "langgraph-checkpoint-conformance" },
|
||||
{ name = "psycopg", extra = ["binary"] },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
@@ -374,6 +405,7 @@ dev = [
|
||||
{ name = "anyio" },
|
||||
{ name = "codespell" },
|
||||
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
|
||||
{ name = "langgraph-checkpoint-conformance", editable = "../checkpoint-conformance" },
|
||||
{ name = "psycopg", extras = ["binary"] },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
@@ -390,6 +422,7 @@ lint = [
|
||||
test = [
|
||||
{ name = "anyio" },
|
||||
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
|
||||
{ name = "langgraph-checkpoint-conformance", editable = "../checkpoint-conformance" },
|
||||
{ name = "psycopg", extras = ["binary"] },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
@@ -420,83 +453,74 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "orjson"
|
||||
version = "3.11.9"
|
||||
version = "3.12.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7e/0c/964746fcafbd16f8ff53219ad9f6b412b34f345c75f384ad434ceaadb538/orjson-3.11.9.tar.gz", hash = "sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f", size = 5599163, upload-time = "2026-05-06T15:11:08.309Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0f/f3/742fb1f62b825f2c010697eaf4e828004bc2a81e7e806666989c132c7c42/orjson-3.12.0.tar.gz", hash = "sha256:d14203fb1aae2ad9b3d52f8a0e82aeb10197ef1c9bc61da7f358bd70b00123d5", size = 4142915, upload-time = "2026-08-14T16:13:30.607Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/10/5d/b95ca542a001135cc250a49370f282f578c8f4e46cc8617d73775297eea8/orjson-3.11.9-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:135869ef917b8704ea0a94e01620e0c05021c15c52036e4663baffe75e72f8ce", size = 228986, upload-time = "2026-05-06T15:09:14.765Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/01/be33fbff646e22f93398429ea645f20d2097aea1a6cdc1e6628e70125f83/orjson-3.11.9-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:115ab5f5f4a0f203cc2a5f0fb09aee503a3f771aa08392949ab5ca230c4fbdbd", size = 132558, upload-time = "2026-05-06T15:09:17.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/61/73d49333bba660a075daccca10970dc6409ce1cf42ae4046646a19468aad/orjson-3.11.9-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4da3c38a2083ca4aaf9c2a36776cce3e9328e6647b10d118948f3cfb4913ffe4", size = 128213, upload-time = "2026-05-06T15:09:18.719Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/7d/30e844b3dac3f74aed66b1f984daf9db3c98c0328c03d965a9e8dc06449e/orjson-3.11.9-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:53b50b0e14084b8f7e29c5ce84c5af0f1160169b30d8a6914231d97d2fe297d4", size = 135430, upload-time = "2026-05-06T15:09:20.257Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/64/bd815f5c610b3facc204f26ba94e87a9eb49b0d83de3d5fc1eee2402d91b/orjson-3.11.9-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:231742b4a11dad8d5380a435962c57e91b7c37b79be858f4ef1c0df1a259897e", size = 146178, upload-time = "2026-05-06T15:09:21.616Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/35/e744fd36c79b339d27beb06068b5a08a8882ef5418804d0ce545a31f718d/orjson-3.11.9-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:34fd2317602587321faab75ab76c623a0117e80841a6413654f04e47f339a8fb", size = 133068, upload-time = "2026-05-06T15:09:23.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/56/d54152b67b63a0b3e556cfc549d6ce84f74d7f425ddeadc6c8a74d913da7/orjson-3.11.9-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:71f3db16e69b667b132e0f305a833d5497da302d801508cbb051ed9a9819da47", size = 134217, upload-time = "2026-05-06T15:09:24.847Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/ee/66154baf69f71c7164a268a5e888908aec5a0819d13c81d5e2755a257758/orjson-3.11.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0b34789fa0da61cf7bef0546b09c738fb195331e017e477096d129e9105ab03d", size = 141917, upload-time = "2026-05-06T15:09:26.647Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/d3/c5824260ca8b9d7ba82648d042a3f8f4815d18c15bb98a1f30edd1bb2d83/orjson-3.11.9-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:87e4d4ab280b0c87424d47695bec2182caf8cfc17879ea78dab76680194abc13", size = 415356, upload-time = "2026-05-06T15:09:28.252Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/cb/509c2e816fe4df641d93dc92f6a89adc8df3ada8ebdee2bd44aba3264c3c/orjson-3.11.9-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ace6c58523302d3b97b6ac5c38a5298a54b473762b6be82726b4265c41029f92", size = 148112, upload-time = "2026-05-06T15:09:29.783Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/b5/3ceae56d2e4962979eedb023ba6a46a4bb65f333960379be0ca470686220/orjson-3.11.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:97d0d932803c1b164fde11cb542a9efcb1e0f63b184537cca65887147906ff48", size = 137112, upload-time = "2026-05-06T15:09:31.432Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/7a/81fa3f2c7bef79b04cf2ab7838e5ac74b1f12511ceab979759b0275d6bb4/orjson-3.11.9-cp310-cp310-win32.whl", hash = "sha256:b3afcf569c15577a9fe64627292daa3e6b3a70f4fb77a5df246a87ec21681b94", size = 131706, upload-time = "2026-05-06T15:09:32.707Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/d8/b64600f9083c7f151ad39717a5877fccbeb0ef6d7efcb55f971ce00b6bee/orjson-3.11.9-cp310-cp310-win_amd64.whl", hash = "sha256:8697ab6a080a5c46edaad50e2bc5bd8c7ca5c66442d24104fa44ec74910a8244", size = 127282, upload-time = "2026-05-06T15:09:33.955Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/51/3fb9e65ae76ee97bd611869a503fa3fc0a6e81dd8b737cf3003f682df7ff/orjson-3.11.9-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:f01c4818b3fc9b0da8e096722a84318071eaa118df35f6ed2344da0e73a5444f", size = 228522, upload-time = "2026-05-06T15:09:35.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/fa/9d54b07cb3f3b0bfd57841478e42d7a0ece4a9f49f9907eecf5a45461687/orjson-3.11.9-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:3ebca4179031ee716ed076ffadc29428e900512f6fccee8614c9983157fcf19c", size = 128463, upload-time = "2026-05-06T15:09:37.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/b1/6ceafc2eefd0a553e3be77ce6c49d107e772485d9568629376171c50e634/orjson-3.11.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48ee05097750de0ff69ed5b7bbcf0732182fd57a24043dcc2a1da780a5ead3a5", size = 132306, upload-time = "2026-05-06T15:09:38.299Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/76/f11311285324a40aab1e3031385c50b635a7cd0734fdaf60c7e89a696f60/orjson-3.11.9-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6082706765a95a6680d812e1daf1c0cfe8adec7831b3ff3b625693f3b461b1c", size = 127988, upload-time = "2026-05-06T15:09:39.597Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/85/0ef63bcf1337f44031ce9b91b1919563f62a37527b3ea4368bb15a22e5d7/orjson-3.11.9-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:277fefe9d76ee17eb14debf399e3533d4d63b5f677a4d3719eb763536af1f4bd", size = 135188, upload-time = "2026-05-06T15:09:40.957Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/94/b0d27090ea8a2095db3c2bd1b1c96f96f19bbb494d7fef33130e846e613d/orjson-3.11.9-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:03db380e3780fa0015ed776a90f20e8e20bb11dde13b216ce19e5718e3dfba62", size = 145937, upload-time = "2026-05-06T15:09:42.249Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/eb/75d50c29c05b8054013e221e598820a365c8e64065312e75e202ed880709/orjson-3.11.9-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33d7d766701847dc6729846362dc27895d2f2d2251264f9d10e7cb9878194877", size = 132758, upload-time = "2026-05-06T15:09:43.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/bd/360686f39348aa88827cb6fbf7dc606fd41c831a35235e1abf1db8e3a9e6/orjson-3.11.9-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:147302878da387104b66bb4a8b0227d1d487e976ce41a8501916161072ed87b1", size = 133971, upload-time = "2026-05-06T15:09:45.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/30/3178eb16f3221aeef068b6f1f1ebe05f656ea5c6dffe9f6c917329fe17a3/orjson-3.11.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3513550321f8c8c811a7c3297b8a630e82dc08e4c10216d07703c997776236cd", size = 141685, upload-time = "2026-05-06T15:09:46.858Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/f1/ff2f19ed0225f9680fafa42febca3570dd59444ebf190980738d376214c2/orjson-3.11.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:c5d001196b89fa9cf0a4ab79766cd835b991a166e4b621ba95089edc50c429ff", size = 415167, upload-time = "2026-05-06T15:09:48.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/61/863bddf0da6e9e586765414debd54b4e58db05f560902b6d00658cb88636/orjson-3.11.9-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:16969c9d369c98eb084889c6e4d2d39b77c7eb38ceccf8da2a9fff62ae908980", size = 147913, upload-time = "2026-05-06T15:09:49.733Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/8a/4081492586d75b073d60c5271a8d0f05a0955cabf1e34c8473f6fcd84235/orjson-3.11.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:63e0efbc991250c0b3143488fa57d95affcabbfc63c99c48d625dd37779aafe2", size = 136959, upload-time = "2026-05-06T15:09:51.311Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/bd/70b6ab193594d7abb875320c0a7c8335e846f28968c432c31042409c3c8d/orjson-3.11.9-cp311-cp311-win32.whl", hash = "sha256:14ed654580c1ed2bc217352ec82f91b047aef82951aa71c7f64e0dcb03c0e180", size = 131533, upload-time = "2026-05-06T15:09:52.637Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/17/1a1a228183d62d1b77e2c30d210f47dd4768b310ebe1607c63e3c0e3a71e/orjson-3.11.9-cp311-cp311-win_amd64.whl", hash = "sha256:57ea77fb70a448ce87d18fca050193202a3da5e54598f6501ca5476fb66cfe02", size = 127106, upload-time = "2026-05-06T15:09:54.204Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/95/285de5fa296d09681ee9c546cd4a8aeb773b701cf343dc125994f4d52953/orjson-3.11.9-cp311-cp311-win_arm64.whl", hash = "sha256:19b72ed11572a2ee51a67a903afbe5af504f84ed6f529c0fe44b0ab3fb5cc697", size = 126848, upload-time = "2026-05-06T15:09:55.551Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/6d/11867a3ffa3a3608d84a4de51ef4dd0896d6b5cc9132fbe1daf593e677bc/orjson-3.11.9-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9ef6fe90aadef185c7b128859f40beb24720b4ecea95379fc9000931179c3a49", size = 228515, upload-time = "2026-05-06T15:09:57.265Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/75/05912954c8b288f34fcf5cd4b9b071cb4f6e77b9961e175e56ebb258089f/orjson-3.11.9-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:e5c9b8f28e726e97d97696c826bc7bea5d71cecd63576dba92924a32c1961291", size = 128409, upload-time = "2026-05-06T15:09:59.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/86/1c3a47df3bc8191ea9ac51603bbb872a95167a364320c269f2557911f406/orjson-3.11.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26a473dbb4162108b27901492546f83c76fdcea3d0eadff00ae7a07e18dcce09", size = 132106, upload-time = "2026-05-06T15:10:00.798Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/cf/b33b5f3e695ae7d63feef9d915c37cc3b8f465493dcd4f8e0b4c697a2366/orjson-3.11.9-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:011382e2a60fda9d46f1cdee31068cfc52ffe952b587d683ec0463002802a0f4", size = 127864, upload-time = "2026-05-06T15:10:02.15Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/6a/6cf69385a58208024fcb8c014e2141b8ce838aba6492b589f8acfff97fab/orjson-3.11.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c2d3dc759490128c5c1711a53eeaa8ee1d437fd0038ffd2b6008abf46db3f882", size = 135213, upload-time = "2026-05-06T15:10:03.515Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/f8/0b1bd3e8f2efcdd376af5c8cfd79eaf13f018080c0089c80ebd724e3c7fb/orjson-3.11.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8ea516b3726d190e1b4297e6f4e7a8650347ae053868a18163b4dd3641d1fff", size = 145994, upload-time = "2026-05-06T15:10:05.083Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/59/dab79f61044c529d2c81aecdc589b1f833a1c8dec11ba3b1c2498a02ca7e/orjson-3.11.9-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:380cdce7ba24989af81d0a7013d0aaec5d0e2a21734c0e2681b1bc4f141957fe", size = 132744, upload-time = "2026-05-06T15:10:06.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/a4/82b7a2fe5d8a67a59ed831b24d59a3d46ea7d207b66e1602d376541d94a6/orjson-3.11.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be4fa4f0af7fa18951f7ab3fc2148e223af211bf03f59e1c6034ec3f97f21d61", size = 134014, upload-time = "2026-05-06T15:10:08.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/c7/375e83a76851b73b2e39f3bcf0e5a19e2b89bad13e5bca97d0b293d27f24/orjson-3.11.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a8f5f8bc7ce7d59f08d9f99fa510c06496164a24cb5f3d34537dbd9ca30132e2", size = 141509, upload-time = "2026-05-06T15:10:09.595Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/7c/49d5d82a3d3097f641f094f552131f1e2723b0b8cb0fa2874ab65ecfffa6/orjson-3.11.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:4d7fde5501b944f83b3e665e1b31343ff6e154b15560a16b7130ea1e594a4206", size = 415127, upload-time = "2026-05-06T15:10:11.049Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/dc/7446c538590d55f455647e5f3c61fc33f7108714e7afcffa6a2a033f8350/orjson-3.11.9-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cde1a448023ba7d5bb4c01c5afb48894380b5e4956e0627266526587ef4e535f", size = 148025, upload-time = "2026-05-06T15:10:12.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/e5/4d2d8af06f788329b4f78f8cc3679bb395392fcaa1e4d8d3c33e85308fa4/orjson-3.11.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e63adb0e1f1ed5d9e168f50a91ceb93ae6420731d222dc7da5c69409aa47aa", size = 136943, upload-time = "2026-05-06T15:10:14.405Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/69/850264ccf6d80f6b174620d30a87f65c9b1490aba33fe6b62798e618cad3/orjson-3.11.9-cp312-cp312-win32.whl", hash = "sha256:2d057a602cdd19a0ad680417527c45b6961a095081c0f46fe0e03e304aac6470", size = 131606, upload-time = "2026-05-06T15:10:15.791Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/d5/973a43fc9c55e20f2051e9830997649f669be0cb3ca52192087c0143f118/orjson-3.11.9-cp312-cp312-win_amd64.whl", hash = "sha256:59e403b1cc5a676da8eaf31f6254801b7341b3e29efa85f92b48d272637e77be", size = 127101, upload-time = "2026-05-06T15:10:17.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/ae/495470f0e4a18f73fa10b7f6b84b464ec4cc5291c4e0c7c2a6c400bef006/orjson-3.11.9-cp312-cp312-win_arm64.whl", hash = "sha256:9af678d6488357948f1f84c6cd1c1d397c014e1ae2f98ae082a44eb48f602624", size = 126736, upload-time = "2026-05-06T15:10:18.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/33/93fcc25907235c344ae73122f8a4e01d2d393ef062b4af7d2e2487a32c37/orjson-3.11.9-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:4bab1b2d6141fe7b32ae71dac905666ece4f94936efbfb13d55bb7739a3a6021", size = 228458, upload-time = "2026-05-06T15:10:20.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/27/b1e6dadb3c080313c03fdd8067b85e6a0460c7d8d6a1c3984ef77b904e4d/orjson-3.11.9-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:844417969855fc7a41be124aafe83dc424592a7f77cd4501900c67307122b92c", size = 128368, upload-time = "2026-05-06T15:10:21.549Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/0f/c9ede0bf052f6b4051e64a7d4fa91b725cccf8321a6a786e86eb03519f00/orjson-3.11.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffe02797b5e9f3a9d8292ddcd289b474ad13e81ad83cd1891a240811f1d2cb81", size = 132070, upload-time = "2026-05-06T15:10:23.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/26/d398e28048dc18205bbe812f2c88cb9b40313db2470778e25964796458fe/orjson-3.11.9-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e4eed3b200023042814d2fc8a5d2e880f13b52e1ed2485e83da4f3962f7dc1a", size = 127892, upload-time = "2026-05-06T15:10:24.714Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/60/52b0054c4c700d5aa7fc5b7ca96917400d8f061307778578e67a10e25852/orjson-3.11.9-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8aff7da9952a5ad1cef8e68017724d96c7b9a66e99e91d6252e1b133d67a7b10", size = 135217, upload-time = "2026-05-06T15:10:26.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/97/1e3dc2b2a28b7b2528f403d2fc1d79ec5f39af3bc143ab65d3ec26426385/orjson-3.11.9-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d4e98d6f3b8afed8bc8cd9718ec0cdf46661826beefb53fe8eafb37f2bf0362", size = 145980, upload-time = "2026-05-06T15:10:28.062Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/39/31fbfe7850f2de32dee7e7e5c09f26d403ab01e440ac96001c6b01ad3c99/orjson-3.11.9-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a81d52442a7c99b3662333235b3adf96a1715864658b35bb797212be7bddb97", size = 132738, upload-time = "2026-05-06T15:10:29.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/08/dca0082dd2a194acb93e5457e73455388e2e2ca464a2672449a9ddbb679d/orjson-3.11.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e39364e726a8fff737309aff059ff67d8a8c8d5b677be7bb49a8b3e84b7e218", size = 134033, upload-time = "2026-05-06T15:10:31.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/d4/5bdb0626801230139987385554c5d4c42255218ac906525bf4347f22cd95/orjson-3.11.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4fd66214623f1b17501df9f0543bef0b833979ab5b6ded1e1d123222866aa8c9", size = 141492, upload-time = "2026-05-06T15:10:32.641Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/88/a21fb53b3ede6703aede6dce4710ed4111e5b201cfa6bbff5e544f9d47d7/orjson-3.11.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8ecc30f10465fa1e0ce13fd01d9e22c316e5053a719a8d915d4545a09a5ff677", size = 415087, upload-time = "2026-05-06T15:10:34.438Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/57/1b30daf70f0d8180e9a73cefbfbdd99e4bf19eb020466502b01fba7e0e50/orjson-3.11.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:97db4c94a7db398a5bd636273324f0b3fd58b350bbbac8bb380ceb825a9b40f4", size = 148031, upload-time = "2026-05-06T15:10:36.358Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/83/45fbb6d962e260807f99441db9613cee868ceda4baceda59b3720a563f97/orjson-3.11.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f78cf8fec5bd627f4082b8dfeac7871b43d7f3274904492a43dab39f18a19a0", size = 136915, upload-time = "2026-05-06T15:10:38.013Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/cc/2d10025f9056d376e4127ec05a5808b218d46f035fdc08178a5411b34250/orjson-3.11.9-cp313-cp313-win32.whl", hash = "sha256:d4087e5c0209a0a8efe4de3303c234b9c44d1174161dcd851e8eea07c7560b32", size = 131613, upload-time = "2026-05-06T15:10:39.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/bd/2775ff28bfe883b9aa1ff348300542eb2ef1ee18d8ae0e3a49846817a865/orjson-3.11.9-cp313-cp313-win_amd64.whl", hash = "sha256:051b102c93b4f634e89f3866b07b9a9a98915ada541f4ec30f177067b2694979", size = 127086, upload-time = "2026-05-06T15:10:41.262Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/2b/d26799e580939e32a7da9a39531bc9e58e15ca32ffaa6a8cb3e9bb0d22cd/orjson-3.11.9-cp313-cp313-win_arm64.whl", hash = "sha256:cce9127885941bd28f080cecf1f1d288336b7e0d812c345b08be88b572796254", size = 126696, upload-time = "2026-05-06T15:10:42.651Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/eb/5da01e356015aee6ecfa1187ced87aef51364e306f5e695dd52719bf0e78/orjson-3.11.9-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b6ef1979adc4bc243523f1a2ba91418030a8e29b0a99cbe7e0e2d6807d4dce6e", size = 228465, upload-time = "2026-05-06T15:10:44.097Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/62/3e0e0c14c957133bcd855395c62b55ed4e3b0af23ffea11b032cb1dcbdb1/orjson-3.11.9-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:f36b7f32c7c0db4a719f1fc5824db4a9c6f8bd1a354debb91faf26ebf3a4c71e", size = 128364, upload-time = "2026-05-06T15:10:45.839Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/5a/07d8aa117211a8ed7630bda80c8c0b14d04e0f8dcf99bcf49656e4a710eb/orjson-3.11.9-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08f4d8ebb44925c794e535b2bebc507cebf32209df81de22ae285fb0d8d66de0", size = 132063, upload-time = "2026-05-06T15:10:47.267Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/ec/4acaf21483e18aa945be74a474c74b434f284b549f275a0a39b9f98956e9/orjson-3.11.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6cc7923789694fd58f001cbcac7e47abc13af4d560ebbfcf3b41a8b1a0748124", size = 122356, upload-time = "2026-05-06T15:10:48.765Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/d8/5f0555e7638801323b7a75850f92e7dfa891bc84fe27a1ba4449170d1200/orjson-3.11.9-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea5c46eb2d3af39e806b986f4b09d5c2706a1f5afde3cbf7544ce6616127173c", size = 129592, upload-time = "2026-05-06T15:10:50.13Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/30/ed9860412a3603ceb3c5955bfd72d28b9d0e7ba6ed81add14f83d7114236/orjson-3.11.9-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5d89a2ed90731df3be64bab0aa44f78bff39fdc9d71c291f4a8023aa46425b7", size = 140491, upload-time = "2026-05-06T15:10:51.582Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/17/adc514dea7ac7c505527febf884934b815d34f0c7b8693c1a8b39c5c4a57/orjson-3.11.9-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25e4aed0312d292c09f61af25bba34e0b2c88546041472b09088c39a4d828af1", size = 127309, upload-time = "2026-05-06T15:10:53.329Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/3e/c0b690253f0b82d86e99949af13533363acfb5432ecb5d53dd5b3bce9c34/orjson-3.11.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaea64f3f467d22e70eeed68bdccb3bc4f83f650446c4a03c59f2cba28a108db", size = 134030, upload-time = "2026-05-06T15:10:54.988Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/7a/bc82a0bb25e9faaf92dc4d9ef002732efc09737706af83e346788641d4a7/orjson-3.11.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a028425d1b440c5d92a6be1e1a020739dfe67ea87d96c6dbe828c1b30041728b", size = 141482, upload-time = "2026-05-06T15:10:56.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/55/e69188b939f77d5d32a9833745ace31ea5ccae3ab613a1ec185d3cd2c4fb/orjson-3.11.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5b192c6cf397e4455b11523c5cf2b18ed084c1bbd61b6c0926344d2129481972", size = 415178, upload-time = "2026-05-06T15:10:58.446Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/1a/b8a5a7ac527e80b9cb11d51e3f6689b709279183264b9ec5c7bc680bb8b5/orjson-3.11.9-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea407d4ccf5891d667d045fecae97a7a1e5e87b3b97f97ae1803c2e741130be0", size = 148089, upload-time = "2026-05-06T15:11:00.441Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/4e/00503f64204bf859b37213a63927028f30fb6268cd8677fb0a5ad48155e1/orjson-3.11.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f63aaf97afd9f6dec5b1a68e1b8da12bfccb4cb9a9a65c3e0b6c847849e7586", size = 136921, upload-time = "2026-05-06T15:11:02.176Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/ba/a23b82a0a8d0ed7bed4e5f5035aae751cad4ff6a1e8d2ecd14d8860f5929/orjson-3.11.9-cp314-cp314-win32.whl", hash = "sha256:e30ab17845bb9fa54ccf67fa4f9f5282652d54faa6d17452f47d0f369d038673", size = 131638, upload-time = "2026-05-06T15:11:03.696Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/c3/0c6798456bade745c75c452342dabacce5798196483e77e643be1f53877d/orjson-3.11.9-cp314-cp314-win_amd64.whl", hash = "sha256:32ef5f4283a3be81913947d19608eacb7c6608026851123790cd9cc8982af34b", size = 127078, upload-time = "2026-05-06T15:11:05.123Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/21/5a3f1e8913103b703a436a5664238e5b965ec392b555fe68943ea3691e6b/orjson-3.11.9-cp314-cp314-win_arm64.whl", hash = "sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9", size = 126687, upload-time = "2026-05-06T15:11:06.602Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/35/819eeb4fa8ee676d38fdbb8213a76fd496f7dbbfdfafa89d34e02b22dfac/orjson-3.12.0-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:747843254519dd43b93eee3153a19e5a509334320c4d2f823ec879232db5c796", size = 224133, upload-time = "2026-08-14T16:12:00.607Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/ab/d9221d4a2b085b073fcddc91728d490f20b9cf010c62c2f42371ab997695/orjson-3.12.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:7c2ad193c8004254f34b499f3bd2c80f043d10754aff2b38f93da574f4883f98", size = 113669, upload-time = "2026-08-14T16:12:02.126Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/12/644cbbcabb26df61d9ef0c66e6f2bf8b687cc7b66137597f2858951f1952/orjson-3.12.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:bc7a872f03522d90e0429e6c0c5cd23084f767bedcb4c58048eec19294613344", size = 130410, upload-time = "2026-08-14T16:12:03.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/6d/e3a8c34d687895aecd8b267a01c46106eb98d8424a83bfa7bacb723854f6/orjson-3.12.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18a87929f31d94a77f7dc93cf527e91f39ce7fe7813d588a4de2507efd32a387", size = 131101, upload-time = "2026-08-14T16:12:04.918Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/20/930824c07685c22af23f26818ed3853b0270488a412b6ab757904b7f787b/orjson-3.12.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9683ee9ea0659da64f36574ef675b8a86330c34c19ea75db1fb93c3ff99e0ef", size = 131479, upload-time = "2026-08-14T16:12:06.11Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/a6/22e863bbbe8917aa292e33e0db597000f9a07eb5e6f52efed623fa16bae1/orjson-3.12.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:103b5db66aa53c1f9e88c2524be4f383e831ba7dfd5f9f5af6336a177c622f11", size = 135865, upload-time = "2026-08-14T16:12:07.392Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/a0/ceb5008914a65e9a19a46a09d94bc67a74d120209fdfa772750023ceb377/orjson-3.12.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bd57d79aefa3f84eec851d6de7a366795b9345cfaf17f82b4820430a7a5fa241", size = 127843, upload-time = "2026-08-14T16:12:08.607Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/3d/61c6b3b84c250cb09cb7229701ff77e4d763773ad7f577d0b6abf2892664/orjson-3.12.0-cp310-cp310-win32.whl", hash = "sha256:3dbce9b6b3074b31a5d5dd322a9c4e5b16f206091ece4194c2e36952847a105e", size = 128293, upload-time = "2026-08-14T16:12:09.819Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/0e/ea0f4a563253b6363195a4f704123c6bfbf156641bd3be5a75de81c5e917/orjson-3.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:3bb17a06f9bd15237b3216c044209fe92597379124018cfc196fbb846cde64df", size = 122216, upload-time = "2026-08-14T16:12:11.261Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/1a/a7075a8e8b0d3f5097d17ac3099017104b6b7b42012041147995d5b2da05/orjson-3.12.0-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:a94f0f0c6fcbb2b5bd9734c57a489c7584a732bbdf04a39e8c83b861e9d03e92", size = 223409, upload-time = "2026-08-14T16:12:12.654Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/34/c2eb3b2900e5597db7841a4c6416ac2d90081bd956b02d4dd1833fa2b96b/orjson-3.12.0-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:a696529ec96a90d9a5f9570207efe403c8b08f8e4aa2783ee3403511e2fdfa10", size = 124015, upload-time = "2026-08-14T16:12:14.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/df/b49081766a75b6a37b3d33bdc0a39e492abab8441dd25e3e1998e7b83fcb/orjson-3.12.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:e4ac5059baab4b3acbd99485de019ff8cda0fdf34b61fa74f7197a53db78bfe8", size = 113471, upload-time = "2026-08-14T16:12:15.81Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/d4/58ea28eeef95c2a27358ed927380a621162cf20bd740bbccf9c3f09a200a/orjson-3.12.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:8e29957429c35bbb5a185a119c523aa2428b7bbf1a293724c7b9375ed8f892a3", size = 129998, upload-time = "2026-08-14T16:12:17.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/f4/1e82aa2efc9916422d804697876ce433c907a1abd7c7e5c6d3d48565e5f9/orjson-3.12.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dce0166feb0a737ab84f598c9a338cbc0b764a036617aa686194f53c7eba0c3e", size = 130891, upload-time = "2026-08-14T16:12:18.762Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/e1/15169e9d22b59a406264f99d6db387c0b0b12b6357a8a0169917c2a713eb/orjson-3.12.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9caf3d09f47c3c70c4451ada20ef9bc4a4cdffa26f49862cf0a253b329aae2d5", size = 131285, upload-time = "2026-08-14T16:12:20.251Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/3a/763dbd426290d044ec3e615a05e70adb6d8b6f95bf17dc355c0081a5e8b6/orjson-3.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b9dca132b1fda5565088e65a6b6e742285e0aeceb6fae549fa8863e16c7d3998", size = 135707, upload-time = "2026-08-14T16:12:21.652Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/d1/3b2038ed168d22e14182ed715d6963f9c073a83a2ba43cfe918a4fc43c64/orjson-3.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a791f793b287bbc135b8e87c34e35c8bfc693e2a8a620fab1ae682b925f9a32e", size = 127669, upload-time = "2026-08-14T16:12:22.926Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/ae/b84b3d3e65f5629ada0edcb1d2bccc55d7c5f89d8b981537ecdc3d6f31ec/orjson-3.12.0-cp311-cp311-win32.whl", hash = "sha256:31ed278a36304390adc3eec5d7f6fd593a7c3e99e5a06cd07866396c4b1b4710", size = 128043, upload-time = "2026-08-14T16:12:24.367Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/24/2ed0e6f51ea3d0af45d807233a851175af75bec83ef5fd0d6a2601904ec0/orjson-3.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:fb2539159dfe8d371914f354360fa50e4a577cc89222a3828b9650a5e5040252", size = 122084, upload-time = "2026-08-14T16:12:25.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/dd/95d25fcfbc9471799ef6bb01c552d64ee5cde93ee40ba2f423dd3442c708/orjson-3.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:61318b6de893c7a9d9f3e5ecbadccbfc26a7eb417ccc7bbf0771de3b4d72f868", size = 127035, upload-time = "2026-08-14T16:12:27.201Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/4a/295da39c651c2faac8bd351a2a346f0fdedd9d50b847ee9dfc27d2207ef6/orjson-3.12.0-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:aa3e43a6846e91d7bde3d5a9c66090fcd8744f569a9b6cffc5e1ca38f6a461c0", size = 223427, upload-time = "2026-08-14T16:12:28.525Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/98/758cf90fbeaaafb7f8141bfac75a432099959f3a2f5db93a412e876415d8/orjson-3.12.0-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:11edb4660a6680abee9788a3a9072208a2c96538cc1322bd79542065229d8e54", size = 123725, upload-time = "2026-08-14T16:12:30.013Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/b5/5b934d251f8651f7e41df180ad0c57a6e1cabe15c7bd331638413a50ebc9/orjson-3.12.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:2d3a9da945a4d96ae758fdaaca56742e6b73b6fd554c5d8876f252a6dad70b83", size = 113375, upload-time = "2026-08-14T16:12:31.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/d2/37efb5b12a176ce3ced29f4144f20da57d02757f78ce549637dc1b4e1fc8/orjson-3.12.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:92ffc09e07233a6ab6d4e067f7841edcbcc134cb4812155cf171ea5255a421d7", size = 129983, upload-time = "2026-08-14T16:12:32.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/22/0644b87c73f13e0092df8f35a1fe280d991e5e90072087411e0dd7e44e0c/orjson-3.12.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf44e374aadde77b1f6109f1030be51433eb61984379852766b6f4e187db7b1e", size = 130629, upload-time = "2026-08-14T16:12:34.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/57/80b986ebfecd9c6a177ddf1c2319717f0cd8feffb2b78946595a18a2fc88/orjson-3.12.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1192a7021b6d071aaf909864f6e924d6a2675ca360485b972b8401749311750b", size = 131245, upload-time = "2026-08-14T16:12:35.713Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/3d/75c5ac5a69161f44492a68fbdde66f4cc4ce48cd5e1fb05918e46f0c8848/orjson-3.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:53c0c474a9d9aff9aebfc0c88de1f28f843d940e6e3a80729abdf6a20274356f", size = 135397, upload-time = "2026-08-14T16:12:37.128Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/93/4d71f2df314a97ff0d27a4559bf5888fc8406e3c6dec90e92291e3511215/orjson-3.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:532ff8cd4bd59a327a953a7dcde922c7fc25b85e29721bb8633265430d3a3873", size = 127693, upload-time = "2026-08-14T16:12:38.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/1d/0dbc6be5adfd1730491072fb60beb6bcdf5d7b2596ee41b7fc2e298bfc09/orjson-3.12.0-cp312-cp312-win32.whl", hash = "sha256:a6cf4b18e7de173f209f2084ffbd736dd72389a396326ee80a7022168be232e5", size = 128000, upload-time = "2026-08-14T16:12:39.954Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/c9/97b1ce0112ebf5e949c775ed5b1755e562233179f3584579673cc24d6378/orjson-3.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:010811c1b69773450a01cef97727a67b223242f350b77d4ca000e59a9ef2155a", size = 122106, upload-time = "2026-08-14T16:12:41.324Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/6a/facd8b312e4a0d3a7fa978c7e15821f74a336adf1d65529faec33b48e18b/orjson-3.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:ad29eece0c601737f2a60edc2752a84e7a0785df3efb62e3012834700a5afe0d", size = 126869, upload-time = "2026-08-14T16:12:42.651Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/cb/d7b78218a987eb8a8ce4eeae0286b1bb679333eb631ea0eeaf6371680bfc/orjson-3.12.0-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9a36ec60f1796f9a3f13e3b98390295e17a1c7c10155b448d264098bf9ee5900", size = 223397, upload-time = "2026-08-14T16:12:44.003Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/4a/bc87c45e7ec639d35ebefd62618e01939531ac8e171426606a01bda05914/orjson-3.12.0-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:ad0422b92d5195443a39f80c3bcf731cc2e00f153bd32063a47b73b057bd0f03", size = 123662, upload-time = "2026-08-14T16:12:45.433Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/ee/c9a4ff3f2dbedbbe9e635d0fa72c8866adede09b6335ef9644f53752f0d8/orjson-3.12.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:5a0fdbc216388f653d3752ff310e710f59253bd4ed6a2bfb3f4f06b84714bbd8", size = 113374, upload-time = "2026-08-14T16:12:46.755Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/09/3f330a026a796c8b4c97a6f429652a5e912e7065039bf96ed25e42aa7b25/orjson-3.12.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:2eb5c56e534127b2b8fa38d2363c8b1b8190367ee0d1d16c041517d880843b94", size = 130029, upload-time = "2026-08-14T16:12:48.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/40/094cc53126a3d22f76cdf83b6ea67338bed01d774037621a785aa8e6e5ea/orjson-3.12.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:784106539f4b9d4b930e0b4eb8d45168507dae001945e71b4675a367f1e5e806", size = 130528, upload-time = "2026-08-14T16:12:49.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/74/89bb236deb9565f99434b13052bb40ddfcce4adf3afbfa3132ee7e421468/orjson-3.12.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c680706fc8396d95e7c4c1f9482563f552137aef91b57237a3ad5aaf64629df", size = 131075, upload-time = "2026-08-14T16:12:50.692Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/ac/1176360d762c01b5bd34acd56fc098e936c491363d8b6b397ad4aa475547/orjson-3.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:83445adc40cba26d6d621185a45128ce455b766af368cad2ab64b970603a7978", size = 135321, upload-time = "2026-08-14T16:12:52.114Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/02/bbd881c8b9276d50b998de38b4e97de8ace1aac940b0ee545aedbf65ed00/orjson-3.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:644d005bc82f917337a95ce270c9f6f92f9834c2bed7b1477572f8db00784222", size = 127472, upload-time = "2026-08-14T16:12:53.517Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/02/a0934d7503e6dcbedd6afac3e7f3f8597fd09389949ad94d0f7540e9dbca/orjson-3.12.0-cp313-cp313-win32.whl", hash = "sha256:d8e78d3d93705e3d27cc17cdb209e44d7a8ea203010cac6ce9c7ffc1ae1996f1", size = 128000, upload-time = "2026-08-14T16:12:55.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/87/69f98f8d40faff103a965a5fbb83f08241b01beaf92badb5413fbc9358cc/orjson-3.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:b85931be5b6763c31283805c9bdaae1ca03ad9f6f12a15f1cbf6745b907932c2", size = 121841, upload-time = "2026-08-14T16:12:56.507Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/07/b83046a4e3cadcc0987d0f160696107c4af706a619b56e4ad01940cadadf/orjson-3.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:6a31348d7dfa64cd9c78bd1f510ff44c48fe64d71094e6b90e364dba3b55949e", size = 126765, upload-time = "2026-08-14T16:12:57.806Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/9d/3931253e6f3148abf2cbe14830367042a4806b362ea520df2303db188fb9/orjson-3.12.0-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9e6fee342a48760e854d743e7a81534d8e2925a6f46e09f750cf56b50fd1de5d", size = 223391, upload-time = "2026-08-14T16:12:59.184Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/0e/b4a4f1e305367245877b967a0bad70fcf001d77c54ac4339a120b66fdae4/orjson-3.12.0-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:8c3bb86dd10f39b3fbf434b7d5dc7cac77d6fc8ac572ae30a10731ede2c4b647", size = 123659, upload-time = "2026-08-14T16:13:00.548Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/f3/6782c6fa85e2702bc66be183c3b421486167dcf266ee4dc1403fe3824870/orjson-3.12.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:2bb3ce43203936072dd8b4917b01d3aecfc02329bfb42510cb7cfb24708adc9c", size = 113337, upload-time = "2026-08-14T16:13:02.009Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/79/b32ab64bacda9d0fa4942ef483bd03cabf0eaf2be819ca9fb7ff610c559d/orjson-3.12.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:6a2a79c89984dc719817d388c8709e0efc2a2795a934eaa746b4882eb6045adc", size = 130112, upload-time = "2026-08-14T16:13:03.404Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/49/6e6142999ca01509219be5e5a9c338a3e5ea011f63e91ff473fbbf3734ed/orjson-3.12.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f06dd838d1e07d9b1de0932ec0485ec92c4d5f5d1ad4817a656268c3e88be1e1", size = 130520, upload-time = "2026-08-14T16:13:04.798Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/d0/3745af0a4cc9867784f29722929cec4d10bd1c877cd754b01ba6d96eb21a/orjson-3.12.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c6b11be792c3d2c6a4be2af4ebf97a68d0bf5f580aca6e86a418a354f6cc846a", size = 131053, upload-time = "2026-08-14T16:13:06.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/f4/6fe5a22fa478fffb190e65c338c84df5c311ef597b363150a17cc57063c0/orjson-3.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:477ecaf6b9f88f873341b91fcc736119ca81b5e002a9f7f308ff5b4f2ce2a70e", size = 135321, upload-time = "2026-08-14T16:13:07.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/41/b1b0ec30289646a81a76e2dbaae2686b96fcccb7cb0323dc1dd78cbc7875/orjson-3.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f3c0683136acdc29afdf88a5bc2f7d3d0e34087788d1d63c0144b805a87a196f", size = 127485, upload-time = "2026-08-14T16:13:08.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/2b/277404bdcc21c93b112b963655b76443ebfe828f8a3ff1de7d90f8850eb3/orjson-3.12.0-cp314-cp314-win32.whl", hash = "sha256:d39f3f5c3927e2dc0913fe5bbc1a2f6b1b9d1bba1de6358340d0ad0d0c00ca92", size = 128048, upload-time = "2026-08-14T16:13:10.305Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/2b/395b36fa2b4ce7af70b651d715e88f80d884b2c2b14a6b53e84d554fb5f0/orjson-3.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:0b1ac5bf6609b2716c7954011c5fef6254922df029f45d032ee4ebf5d363cbed", size = 121858, upload-time = "2026-08-14T16:13:11.634Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/a3/833e895ff452859eebe75093d26691fe9108f1a7a6a08435d7a5780ea652/orjson-3.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:50fae885cb073eac7556353ff3df93312b0d5137b0a5056b2bb63f97ed9a93c7", size = 126749, upload-time = "2026-08-14T16:13:13.117Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/64/99c8947ece10c17176af9aae85c4948f1d109da77440ec14d87239efaf73/orjson-3.12.0-cp315-cp315-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:01efac2074fffb4cb1ea3fab7861e9d0f2a26913854a972f5ac760525dbdaf6e", size = 223398, upload-time = "2026-08-14T16:13:14.694Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/30/cf983fe09f2731420fda097a9f7ef4343f47fa216c228961ad8f6da44f3d/orjson-3.12.0-cp315-cp315-macosx_15_0_arm64.whl", hash = "sha256:ed4ca42bd55955aa34deedcfdfd0e0c31abf51143aae158ae2bc3520b626e517", size = 123655, upload-time = "2026-08-14T16:13:16.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/50/9cb8ae73fa4749dbbc20f617004213b5ff01c20aaeec34c3f31124f2c1d8/orjson-3.12.0-cp315-cp315-manylinux_2_39_aarch64.whl", hash = "sha256:40f92192227505acca4e2533ce565f8e6b9535f7d0d09b0968452f18b7376b38", size = 130515, upload-time = "2026-08-14T16:13:17.601Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/0a/adb6ce1a5b5fbf9cb1790f9961bb668a0dd5429aadaf6cee044724681795/orjson-3.12.0-cp315-cp315-manylinux_2_39_armv7l.whl", hash = "sha256:33efefcf5d88eaf400b47e2eba02f91f319bb9951be61ca500b7d536d3f2079d", size = 113327, upload-time = "2026-08-14T16:13:18.927Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/5c/d17f61581d8dbdde7048f87a330fa24915edec38db4d72b381fec14fbb56/orjson-3.12.0-cp315-cp315-manylinux_2_39_i686.whl", hash = "sha256:8e386b0bc0ddd7cd2056f884b5a0af33592bd01ac66a7ca4b42a65a7e7774a13", size = 130105, upload-time = "2026-08-14T16:13:20.317Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/b7/938befcf33bee4704a92ecec6a2731224c539d939bf9429fd39396d28931/orjson-3.12.0-cp315-cp315-manylinux_2_39_x86_64.whl", hash = "sha256:58c58e1de0006ffb580368d6793c36c7b0b021db066479cf281bf5061e732328", size = 131049, upload-time = "2026-08-14T16:13:21.719Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/15/cfa2021d64d5aa8bb5c9f604ef375e00ec8b657651b5dd650b1b7ad13df1/orjson-3.12.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:08231552159be266a7269555bd9f7c016aee7d9ad6dab06eb58796c5ccb7101c", size = 135320, upload-time = "2026-08-14T16:13:23.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/50/3e75dfe357c1e8f9e287c7a5740260ef15bd23a5299eae8d0835dcad5375/orjson-3.12.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:a15f9a891bce5f5cc5d210e3ad8614d4d1b489a56448c099d6d2a7168b2d954a", size = 127488, upload-time = "2026-08-14T16:13:24.791Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/a6/79aed402eb3ab284dc5b4791a7ad62c5875127de01b8e3f04bd92d551298/orjson-3.12.0-cp315-cp315-win32.whl", hash = "sha256:03091c8a64db4be38746597ceea68f33c238e27acd9bfe99fb59420224ae7a55", size = 128048, upload-time = "2026-08-14T16:13:26.217Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/f7/2723e264aab7248c1ed6ecaad8e5d0cb866c0cffde75442102ffa7491aba/orjson-3.12.0-cp315-cp315-win_amd64.whl", hash = "sha256:2b7bcefb9f40fa242fa6b06377232c048e655747790829609168c01162f60578", size = 121860, upload-time = "2026-08-14T16:13:27.577Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/56/630c9113ec8996778f1f0304b364b091b9a9db5fef5fdc17cca622f5ea24/orjson-3.12.0-cp315-cp315-win_arm64.whl", hash = "sha256:859fc4196855890150bb08e649b30d2c93b249b3e3edd0d3bb2231abf8aa8adc", size = 126754, upload-time = "2026-08-14T16:13:28.962Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -575,15 +599,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "psycopg"
|
||||
version = "3.3.4"
|
||||
version = "3.3.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
{ name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" },
|
||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/db/2f/cb91e5502ec9de1de6f1b76cfbf69531932725361168bb06963620c77e2e/psycopg-3.3.4.tar.gz", hash = "sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc", size = 165799, upload-time = "2026-05-01T23:31:55.179Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/76/26/3ea4ca5eaea1c0debcdf7ee7c1613fbe721dc27a03c461c0817ffd8a0601/psycopg-3.3.6.tar.gz", hash = "sha256:c081f2250df751a943036e42db6df4571c66cd0aabe8291a7a506512b12007d2", size = 168171, upload-time = "2026-09-18T13:22:55.152Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl", hash = "sha256:b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a", size = 213001, upload-time = "2026-05-01T23:20:50.816Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/de/748bd7609c71cae5d737f0ba9192f19329f70180ecda8fff3cac02c5abe3/psycopg-3.3.6-py3-none-any.whl", hash = "sha256:a1db9f7148b06a28606767efaca51fa6f9398c5c0a3810519be69d7000bdb631", size = 215490, upload-time = "2026-09-18T13:15:29.374Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -593,76 +617,88 @@ binary = [
|
||||
|
||||
[[package]]
|
||||
name = "psycopg-binary"
|
||||
version = "3.3.4"
|
||||
version = "3.3.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/bf/70d8a60488f9955cbbcd538beae44d56bb2f1d19e673b72788f2d343ff55/psycopg_binary-3.3.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b7bfff1ca23732b488cbca3076fc11bc98d520ee122514fdb17a8e20d3338f5a", size = 4609750, upload-time = "2026-05-01T23:24:20.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/b0/29e98ba210c9dbc75a6dc91e3f99b9e06ea901a62ca95804e02a1ae13e6b/psycopg_binary-3.3.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:32a6fbf8481e3a370d0d72b860d35948a693cb01281da217f7b2f307636e591a", size = 4676700, upload-time = "2026-05-01T23:25:21.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/ab/3df087b3c12bf74e47c08204172b2fabb5a144679110d5c7ad12d9201323/psycopg_binary-3.3.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bdef84570ebbce1d42b4e7ea952d21c414c5f118ad02fee00c5625f35e134429", size = 5496319, upload-time = "2026-05-01T23:25:28.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/9a/f088207b4cd6772f9e0d8a91807e79fa2458d4eb9eb1ae406c68415f2bec/psycopg_binary-3.3.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa1cbc10768a796c96d3243656016bf4e337c81c71097270bb7b0ad6210d9765", size = 5171906, upload-time = "2026-05-01T23:25:34.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/45/4523a857f253871d75c22e1c2e79fd47e599e736bcba1bad58d83e24be02/psycopg_binary-3.3.4-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf7f73a4a792bc5db58a4b385d8a1467e8d468f7548702fb0ed1e9b7501b1c13", size = 6762621, upload-time = "2026-05-01T23:25:41.392Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/d1/925bf776503345bef428e6c45fb017d0139ddbe0e211814b585c4253dca8/psycopg_binary-3.3.4-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d7b4d40c153fa352ab3cca530f3a0baedf7621b2ebcbd7f084009522c21788fc", size = 5006319, upload-time = "2026-05-01T23:25:51.419Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/aa/99727337206fbba357ca084bf4ea8b29dc986f61842a2685859af61416db/psycopg_binary-3.3.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f9b1c2533af01cd7648378599f82b0b8ae32f293296e6eec5753a625bc97ef28", size = 4535388, upload-time = "2026-05-01T23:25:57.957Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/a4/567ba2c37d19d8c2f63d836385dfd2495aa5897bbee6cfab104d9ee58624/psycopg_binary-3.3.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ad3bc94054876155549fdaedf4a46d1ec69d39a5bcee377148afe498e84c4b8e", size = 4224544, upload-time = "2026-05-01T23:26:03.832Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/23/86457f5a82731685d7701de7bfaa5eb783dd1fecbf875321897d9d9ce33a/psycopg_binary-3.3.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:eb4eed2079c01a4850bf467deacfab56d356d4225040170af03dc9958321242d", size = 3956282, upload-time = "2026-05-01T23:26:09.983Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/d8/249456df16d47de082abd9b73bce8ccdeb0293eb12e590f9150c7cbdb788/psycopg_binary-3.3.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f80e3f2b5331dbbf0901bcb658056c03eeb2c1ef31d774afb0d61598b242e744", size = 4261736, upload-time = "2026-05-01T23:26:16.798Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/6b/c4abe228acafd8a385c1fb615d4f1e3c9b8ad7a4e4f0e84118ba3ffeed9c/psycopg_binary-3.3.4-cp310-cp310-win_amd64.whl", hash = "sha256:574ea21a9651958f1535c5a1c649c7409e9168bcbffa29a3f2f961f58b322949", size = 3570620, upload-time = "2026-05-01T23:26:22.655Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/82/df3312c0ca083d5b43b352f27d4dd8b1e614bd334473074715d9e0000da4/psycopg_binary-3.3.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:612a627d733f695b1de1f9b4bd511c15f999a5d8b915d444bbd7dd71cf3370da", size = 4609813, upload-time = "2026-05-01T23:26:30.612Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/b5/d74d542458d3e8ac0571d8a88f57ca369999b9a82f4fa528052d0d7d3e4c/psycopg_binary-3.3.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:13a7f380824c35896dcac7fe0f61440f7ca49d6dc73f3c13a9a4471e6a3b302e", size = 4676799, upload-time = "2026-05-01T23:26:38.475Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/67/06bab9c60671999f4c6ceff1b334f3ac1f9fc5789eb467c714623ea21de9/psycopg_binary-3.3.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:276904e3452d6a23d474ef9a21eee19f20eed3d53ddd2576af033827e0ba0992", size = 5497050, upload-time = "2026-05-01T23:26:47.061Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/9b/023433e2b20f970de1e22d29132a95281277646da0b2e2879dd4ee94b8c1/psycopg_binary-3.3.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab8cca8ef8fb1ccf5b048ae5bd78ba55b9e4b5d472e3ce5ca39ff4d2a9c249e4", size = 5172428, upload-time = "2026-05-01T23:26:56.708Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/cd/ae16da8fde228a38b2fe9269bbc13cf89e0186173f2265600f02d6a71e64/psycopg_binary-3.3.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7465bfe6087d2d5b42d4c53b9b11ca9f218e477317a4a162a10e3c19e984ba8e", size = 6762746, upload-time = "2026-05-01T23:27:07.023Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/81/0ba09fa5f5f88779093a2541a8e02489825721f258ab88058b11d68b3eb5/psycopg_binary-3.3.4-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22cdbf5f91ef7bb91fe0c5757e1962d3127a8010256eefd9c61fcaf441802097", size = 5006033, upload-time = "2026-05-01T23:27:12.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/6a/629136040cc3497adb442a305710b5913f2a754d4630fc3d3717c4c0df65/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e2631da29253a98bd496e6c4813b24e09a4fe3fb2a9e88513305d6f8747cce95", size = 4534175, upload-time = "2026-05-01T23:27:18.248Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/32/1027f843c6dc2d5d51960ee62cc0c2cf755a4c39455aff1371173edbef7d/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7f7668f30b9dd5163197e5cbf4e0efd54e00f0a859cc566ce56cfc31f4054839", size = 4224203, upload-time = "2026-05-01T23:27:24.3Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/e1/380a724d9093c74adb14d4fce920ea8327838abb61f760b1448586b14a8e/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:cffc3408d77a27973f33e5d909b624cce683db5fc25964b02fe0aae7886c1007", size = 3954509, upload-time = "2026-05-01T23:27:30.815Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/cd/895893ae575a09c97ccfd5def070d88993d955ef34df45a881fd5ff506d6/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0579252a1202cd73e4da137a1426e2dae993ae44e757605344282af3a082848c", size = 4259551, upload-time = "2026-05-01T23:27:38.828Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/c6/2330a20794e37a3ec609ef2fd8522919ec7a4395a1abf979a8e2d1775cd5/psycopg_binary-3.3.4-cp311-cp311-win_amd64.whl", hash = "sha256:41f2ec0fea529832982bcb6c9415de3c86264ebe562b77a467c0fbcd7efbba8d", size = 3572054, upload-time = "2026-05-01T23:27:45.455Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/7d/03818e13ba7f36de93573c93ee3482006d3dfa8b0f8d28df511bad0a1a92/psycopg_binary-3.3.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5ab28a2a7649df3b72e6b674b4c190e448e8e77cf496a65bd846472048de2089", size = 4591122, upload-time = "2026-05-01T23:27:56.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/b9/11b341edf8d54e2694726b273fe9652b254d989f4f63e3ac6816ad6b55f4/psycopg_binary-3.3.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6402a9d8146cf4b3974ded3fd28a971e83dc6a0333eb7822524a3aa20b546578", size = 4669943, upload-time = "2026-05-01T23:28:04.522Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/18/4665bacd65e7865b4372fcd8abb8b9186ada4b0025f8c2ca691b364a556c/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:580ae30a5f95ccd90008ec697d3ed6a4a2047a516407ad904283fa42086936e9", size = 5469697, upload-time = "2026-05-01T23:28:11.337Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/b1/b83136c6e510593d9b0c759ba5384337bc4ad82d19fda675adc4b2703c84/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7510c37550f91a187e3660a8cc50d4b760f8c3b8b2f89ebc5698cd2c7f2c85d", size = 5152995, upload-time = "2026-05-01T23:28:20.529Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/8d/a9821e2a648afe6091989929982a3b0f00b2631a859cb81379728f08fb75/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77df19583501ea288eaf15ac0fe7ad01e6d8091a91d5c41df5c718f307d8e31b", size = 6738180, upload-time = "2026-05-01T23:28:30.654Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/58/2e349e8d23905dc2317b80ac65f48fb6f821a4777a4e994a60da91c4850f/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:018fbed325936da502feb546642c982dcc4b9ffdea32dfef78dbf3b7f7ad4070", size = 4978828, upload-time = "2026-05-01T23:28:37.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/48/57b00d03b4721878326122a1f1e6b0a90b85bcaec56b5b2f8ea6cfa45235/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:17a21953a9e5ff3a16dab692625a3676e2f101db5e40072f39dbee2250194d68", size = 4509757, upload-time = "2026-05-01T23:28:43.078Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/37/33b47d8c007df69aec500df5889767c4d313748e8e9e27a2fef8a6dabcee/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:eb05ee1c2b817d27c537333224c9e83c7afb86fe7296ba970990068baf819b16", size = 4190546, upload-time = "2026-05-01T23:28:50.016Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/c6/32b0835dbc2122617902b649d76a91c1e75406e76bf3d595b0c3bb5ffad6/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:773d573e11f437ce0bdb95b7c18dc58390494f96d43f8b45b9760436114f7652", size = 3926197, upload-time = "2026-05-01T23:28:55.55Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/68/d190ef0c0c5b16ded07831dabc8ddd412f4cdab07ec6e30ed38d9bda0e1f/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e55ccbdfae79a2ed9c6369c3008a3025817ff9d7e27b32a2d84e2a4267e66e", size = 4236627, upload-time = "2026-05-01T23:29:05.336Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/8f/81dcbc2e8454b74d14881275ea45f00791052dac531a9fa8be1730d1685b/psycopg_binary-3.3.4-cp312-cp312-win_amd64.whl", hash = "sha256:494ca54901be8cf9eb7e02c25b731f2317c378efa44f43e8f9bd0e1184ae7be4", size = 3560782, upload-time = "2026-05-01T23:29:11.967Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/43/13e9c406fbbf354580476e248a16b64802a376873ebe6339e30bb655572d/psycopg_binary-3.3.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fbd1d4ed566895ad2d3bf4ddfd8bae90026930ddf29df3b9d91d32c8c47866a7", size = 4590377, upload-time = "2026-05-01T23:29:18.782Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/be/2923cd7c3683e7afdecf4f10796a18de02f5c5ddc0969aa2ad0a8cdd3bbd/psycopg_binary-3.3.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:75a9067e236f9b9ae3535b66fe99bddb33d39c0de10112e49b9ab11eee53dc31", size = 4669023, upload-time = "2026-05-01T23:29:25.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/a0/2c913d6fe13d6a8bd13597d36739bf47af063ad9399e402cfecab16f3c1e/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b56b603ebcea8aa10b46228b8410ba7f13e7c2ee54389d4d9be0927fd8ce2a70", size = 5467423, upload-time = "2026-05-01T23:29:33.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/38/205d10bc1ad0df4a21c5c51659126bd3ea0ef98fcad1e852f78c249bb9c3/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c677c4ad433cb7150c8cd304a0769ae3bcfbe5ea0676eb53faa7b1443b16d0d3", size = 5151137, upload-time = "2026-05-01T23:29:42.013Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/fc/f0381ddcd45eff3bb70dbca6823a996048d7f507b2ec3fc92c6fabc0fe87/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26df2717e59c0473e4465a97dfb1b7afebaa479277870fd5784d1436470db47c", size = 6736671, upload-time = "2026-05-01T23:29:51.626Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/40/fa545ae152c24327651e5624e4902121e808270be36c10b12e9939be09bc/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dc1f79fd16bb1f3f4421417a514607539f17804d95c7ed617265369d1981cae", size = 4979601, upload-time = "2026-05-01T23:29:56.961Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/e4/2f8a47ee97f90cd2b933d0463081d35631ff419de2b8c984a5f369857de0/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:136f199a407b5348b9b857c504aff60c77622a28482e7195839ce1b51238c4cc", size = 4510513, upload-time = "2026-05-01T23:30:07.243Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/0e/94e842ff4a7f98ed162580ca2e8b8864b28c1e0350f2443f8ee47f821167/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b6f5a29e9c775b9f12a1a717aa7a2c80f9e1db6f27ba44a5b59c80ac61d2ffcf", size = 4187243, upload-time = "2026-05-01T23:30:15.352Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/83/fc6c174b672e29b7de996ea77b6cbddf46c891751c3355f6974292baa6b4/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ee17a2cf4943cde261adfad1bbc5bf38d6b3776d7afff74c7cabcbeaeb08c260", size = 3927347, upload-time = "2026-05-01T23:30:21.186Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/65/768364d4a97a15b1a7f47ba52688c1686f22941d8332a8398cefc468e25f/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c4ab71be17bdca30cb34c34c4e1496e2f5d6f20c199c12bad226070b22ef9bf", size = 4236393, upload-time = "2026-05-01T23:30:26.211Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/3b/218efbc9e645becd80cdf651acda05f85cfe546b7a9c0458c7cbc8fe1f74/psycopg_binary-3.3.4-cp313-cp313-win_amd64.whl", hash = "sha256:dbfdb9b6cc79f31104a7b162a2b921b765fcc62af6c00540a167a8de47e4ed38", size = 3564592, upload-time = "2026-05-01T23:30:31.764Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/a6/828c9185701dab71b234c2a76c38a08b098ebfec5020716b4e93807492b5/psycopg_binary-3.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:28b7398fdd19db3232c884fb24550bdfe951221f510e195e233299e4c9b78f97", size = 4607292, upload-time = "2026-05-01T23:30:38.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/58/5b40dbc9d839045c9dae956960e4fb6d20bcabe6c59a2aa34fc3a371913f/psycopg_binary-3.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1fbaa292a3c8bb61b45df1ad3da1908ccee7cb889db9425e3557d9e34e2a4829", size = 4687023, upload-time = "2026-05-01T23:30:47.227Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/a9/793f0ac107a9003b48441d0d1f9f616d96e0f37458dd8dc12528ceff55fb/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94596f9e7633ee3f6440711d43bb70aa31cc0a46a900ab8b4201a366ace5c9e7", size = 5486985, upload-time = "2026-05-01T23:30:55.517Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/26/42e8533497e2592334f68ec529cf5f840f7fa4e99575a4bb61aa184dbfbf/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8c0056529e68dbe9184cd4019a1f3d8f3a4ead2f6fc7a5afcf27d3314edd1277", size = 5168745, upload-time = "2026-05-01T23:31:01.904Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/af/b7151776cc08d5935d45c833ec818a9beb417cf7c08239af1aafbdae78ee/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c09aad7051326e7603c14e50636db9c01f78272dc54b3accff03d46370461e6", size = 6761486, upload-time = "2026-05-01T23:31:14.511Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/ed/c92533b9124712d592cbf1cd6c76da933a2e0acea81dfe1fbe7e735f0cff/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:514404ed543efd620c85602b747df2a23cf1241b4067199e1a66f2d2757aaa41", size = 4997427, upload-time = "2026-05-01T23:31:20.901Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/23/ccadfd0de416aa188356daa199453af24087b042e296088706d190ae0295/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:46893c26858be12cc49ca4226ed6a60b4bfccadd946b3bebb783a60b38788228", size = 4533549, upload-time = "2026-05-01T23:31:26.204Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/a0/c8f43cee36386f7bc891ab41a9d31ea07cf9826038e732da79f26b1e5f34/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:df1d567fc430f6df15c9fcf67d87685fc49bdb325adc0db5af1adfb2f44eb5c9", size = 4210256, upload-time = "2026-05-01T23:31:33.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/2c/c1547871be3790676e8868b38655496422f94f0978dfb66b74bdba2f1676/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6b9016b1714da4dd5ecaaa75b82098aa5a0b87854ce9b092e21c27c4ae23e014", size = 3946204, upload-time = "2026-05-01T23:31:39.626Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/b1/f6670f00fa7ea601584623f6c11602ab92117d83eaff885e0210f6de7418/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:47c656a8a7ba6eb0cff1801a4caaa9c8bdc12d03080e273aff1c8ac39971a77e", size = 4255811, upload-time = "2026-05-01T23:31:44.986Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/e6/5fff07a70d1f945ed90ae131c3bd76cab32beff7c58c6db15ad5820b6d1f/psycopg_binary-3.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8", size = 3666849, upload-time = "2026-05-01T23:31:51.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/92/00350a66de0af05e41d01aa3134e3970045e816afed3f99d58ec1abe15b2/psycopg_binary-3.3.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7beb3e41c9a1e509f3ed85263386588cbe3e975aa67be21f79f44fd35ffaeefc", size = 4728682, upload-time = "2026-09-18T13:15:36.605Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/fc/afa9c7fd316a469af7ede6ebb020eac482f5d827fae57d5310c9bc0c41ae/psycopg_binary-3.3.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:aa73160077345ec21b3f51e8e24b3de2e99586217e497629326eb9b2ea88c52e", size = 4777111, upload-time = "2026-09-18T13:15:46.566Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/44/7c1e015f1bc56b36ff1369f09e852b2d83ccefd5a669a42633a916cdedc4/psycopg_binary-3.3.6-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f87dbdc42e78ee0f7ea180c03f8c78e80a949e373066629bd90fefff10552dff", size = 5592755, upload-time = "2026-09-18T13:15:52.886Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/ae/314a251ca918cdac380bce1b87839ade9355382ea749e6ef3ba75ba0c09f/psycopg_binary-3.3.6-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a9348c5b43a3bb5ef8c2e89d5237c9c87eeafb01d338c84a7aebbc5cd0313299", size = 5267949, upload-time = "2026-09-18T13:16:00.53Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/9f/3bb0cfe9bb0f31ca57cf486ddc8c9ac51251aed8181bf88ff870b2623105/psycopg_binary-3.3.6-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a52991594ac4db888c7d39bccef331797e30cb31a95cae02cf2607f83a42dc2", size = 6862403, upload-time = "2026-09-18T13:16:10.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/d6/7032c10309c3155e9b24300fdcc9a1afa539cfd20ce52fdef74a46f10161/psycopg_binary-3.3.6-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ea8beeb5541780b4b50b462eeacbc4f594ce3b911dc20c81c75f267876f71d2", size = 5107271, upload-time = "2026-09-18T13:16:16.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/cc/79add2cf92684cf1a81da134b32caa662c25c72d0cc905d181ef4455f834/psycopg_binary-3.3.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:198a48e68cc99ccac03ba95ac857e73aa66f3bf6be77019fafb0832a05f7ad03", size = 4627984, upload-time = "2026-09-18T13:16:23.889Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/48/6dfb14f9350c14af6a2edb3c31262051b8cd94e2186e4b831e46dbbe8cd9/psycopg_binary-3.3.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fa34eb47969297471db7b7f193622c7e3ee839ec05abd05f1fe104d5b1b1dcf4", size = 4322054, upload-time = "2026-09-18T13:16:29.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/35/2982338716a91cbb4dfc866be015be4457ee8106a445aabf3d1fb6a270e0/psycopg_binary-3.3.6-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:b979a42815410432420275412633960807178b1ce26591a16ce06e78a5bd4bb2", size = 4048471, upload-time = "2026-09-18T13:16:34.119Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/e1/171b1db1542c5f76a678b7ee0a7800bebc9735a0a03417c76cf948bfd63c/psycopg_binary-3.3.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:889e42acec10450185e0cdfb396f375e2c1a8d7737c114830a7fde4654f59e30", size = 4353931, upload-time = "2026-09-18T13:16:38.692Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/89/4424e62a944eef40bd9326ada4ae23802b28eab6502af91e84ef7bba74fb/psycopg_binary-3.3.6-cp310-cp310-win_amd64.whl", hash = "sha256:cbd5f73073ed19c378d4c35499db1e3e703a5b1a324e521204065967bfaa7a18", size = 3674362, upload-time = "2026-09-18T13:16:44.454Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/86/b71166048974d49c6d136b2ed1c0e5bec0b974d8c4de5cbce7e86a9e412a/psycopg_binary-3.3.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:be4f9b3c9338ac5dd217c5847e21521b396c8117f78dc420d495a5c49bbef874", size = 4728002, upload-time = "2026-09-18T13:16:53.393Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/1d/1e06c0de7ed5aed898acb87544eac6ef0bc7d752a67ec6e5d6b835e9b40c/psycopg_binary-3.3.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f0535693ce476a722b718b002d5d2c27d47e71ca945276ac194409c98e74c492", size = 4775403, upload-time = "2026-09-18T13:16:58.939Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/02/2ffcbc43f8e4bbc38e5286a22013bcac01898d13cd38325f60dd5428a8af/psycopg_binary-3.3.6-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3c9e663b2e800e3218994cf948c11bcc2844e6491b34aa80d089baf6531827bf", size = 5594145, upload-time = "2026-09-18T13:17:08.515Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/25/031dae2c7d2e7e77dcf5b1962c1e0684fa548d7af0ff6707b6b5e6054ca7/psycopg_binary-3.3.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a2e44a342d2aee40508e28a563d8961c39d9bbd8cae36d8578f0a3c6658aab0f", size = 5267985, upload-time = "2026-09-18T13:17:16.24Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/e5/94c89ada3c003a4d858178f3bba49a35e0297ef2aad659b80eb5e380e690/psycopg_binary-3.3.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f598f19fa9a91540b5cee17932ffd227b7b53a481605bcc4573c0eafa647300", size = 6860986, upload-time = "2026-09-18T13:17:23.348Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/a0/81bf499d095adee8413bd19822a6872fbfa21663ec78014a68d83a8db83c/psycopg_binary-3.3.6-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ff05561e4a067d35507dc5c90f1deb2ec1c9703ac5cccc1bc26e08a197f9c5a", size = 5106702, upload-time = "2026-09-18T13:17:28.847Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/75/99d56da64c27bd985fd82c6ecbf7976b724ac638fdd1654ef995323a1a26/psycopg_binary-3.3.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:566dd827f17728efdf7d88a5b066f815170f6fdad13967ae952842d90e6aaa9f", size = 4627090, upload-time = "2026-09-18T13:17:36.668Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/0c/0222171d11233332c6a24b1cef1578215f0ffddf3642eb8dd8c4448ad69f/psycopg_binary-3.3.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9b2f11794e017ce340934e35de46181c46ef71ec75ea3d85dd75cd836761c01e", size = 4320353, upload-time = "2026-09-18T13:17:42.526Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/6f/e1cc2a28dd1228c67c969ba6fd37cd8726b312e2ff51380f847ddb38ccde/psycopg_binary-3.3.6-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:910ace140e3e7b7596898d083f37a8fe90c5c40684252ad4e682364b2cd3deba", size = 4047253, upload-time = "2026-09-18T13:17:47.068Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/fd/38b64790ce7a515b1dbd2bab3d119637a858aeb22c380cf4859bc4ce0e42/psycopg_binary-3.3.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37e517c146b185f9c0c6e8d0a0ebbdeeeb67896af28466e032bc810d0c7dc7a7", size = 4353208, upload-time = "2026-09-18T13:17:52.41Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/dc/45386530ceb2a8c789a226de9b9b34eca8fccf1feba2e4ef68a6aca50c56/psycopg_binary-3.3.6-cp311-cp311-win_amd64.whl", hash = "sha256:c7f92daa0d2a1c76f07264abddf8cbabd30152a2f09c3270e50f0c7efdf5dcac", size = 3675638, upload-time = "2026-09-18T13:17:58.112Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/01/2cdd1824e58b4467ee0b9498664cd28c42d8794db6b1e35b6bcb834f0044/psycopg_binary-3.3.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3f84dab25e0385692ee13274c68678377e0b1a70ab9d14e56264cbf61f60c62d", size = 4707086, upload-time = "2026-09-18T13:18:05.138Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/76/de9948ac06895261c84d5b9fbe283d8f3c5bc9f070691b8d9eaa1b51e322/psycopg_binary-3.3.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:612382ac3ed13651c7fa44b5fee9fbf7baaa2ddbc6f500391672682c5f1df9e0", size = 4769607, upload-time = "2026-09-18T13:18:12.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/a9/72436c9915ee4905964689e7f0e182ce7767cc0a0390b3ce703be8177625/psycopg_binary-3.3.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:366db6e97e66b37211475f20c4c1324a2dc0dd825e46d4e87f9d599304d276f9", size = 5554134, upload-time = "2026-09-18T13:18:21.175Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/42/948bb3d2617795093512613fd96ba380e922992c7908fbc073858147d196/psycopg_binary-3.3.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1679a1cb93fbe5a6d1fd58d82cbddcc6fcb8c61446ba7cae6eb2a7b19bc585de", size = 5235723, upload-time = "2026-09-18T13:18:27.071Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/47/93e823ff1b0088400703410939c9bda3e63ed9c850b3ee088e8769f4c10b/psycopg_binary-3.3.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:37d40450659401600e6d043ff586c89a71a69f33cbb8bcdba6cdb2569beecdbe", size = 6833587, upload-time = "2026-09-18T13:18:33.794Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/2d/ecc69c847795aa704041a9f5667a6b0938a088cf1853636d762a6938e493/psycopg_binary-3.3.6-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a5165300324efd5a772c48a88ab3a928513ab3979fca76553e62ee815f7b2b9c", size = 5070013, upload-time = "2026-09-18T13:18:39.628Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/36/6126f0dac21713dcae91404f2a76da18598a6252339a8c669c46370d43b2/psycopg_binary-3.3.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d636338c8f21b0df2f84657b00bc34f9313f826ef93f1155bc743607e4a0c5eb", size = 4597367, upload-time = "2026-09-18T13:18:45.023Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/29/7ecfc04243b46c89ffd49924e9c5634ea904ef96c7d0f37e4073623584c1/psycopg_binary-3.3.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a4ee3bdd5468a725f2a4d9aab8a74b6d0279f768c8b5d3aeb102c5307ff3d59c", size = 4275419, upload-time = "2026-09-18T13:18:49.299Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/90/2f46d2e0de79706ac170df0a3637fe63c4498fc04f131f6049520b78b806/psycopg_binary-3.3.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:289aadd6a00e151203c081f708348ec89f1e483c9b510ef4ac3981f847f01f79", size = 4007358, upload-time = "2026-09-18T13:18:53.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/48/6744e91291b751a8cf12d63d719977974bb94c84ceba913e7ddb2e478e51/psycopg_binary-3.3.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f21d057f3e5f5491067e5b292498073b73847d48799b099803fef100775fcc52", size = 4320156, upload-time = "2026-09-18T13:18:59.258Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/9b/94ff7fce53a64d5b286e2ec454e0a025cf3d6e6b4a9189bef16aa5de98b2/psycopg_binary-3.3.6-cp312-cp312-win_amd64.whl", hash = "sha256:e23a66a763fbe83fcc210bc77c27e5a5ea380ebf091c06f34d8561b695e5a40f", size = 3658864, upload-time = "2026-09-18T13:19:06.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/c3/c072584b69ad44a747b448cfc9766fecb8aae56e372a017e2ef668790057/psycopg_binary-3.3.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ad8f35e67cc16d1fad1fa8c88972dc9b3a3141ea67897399904edab96a301b6", size = 4712284, upload-time = "2026-09-18T13:19:13.451Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/b9/4283b785339e8e2318d03048994b093d650ea6289fabaa806b765dc0d449/psycopg_binary-3.3.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:373704aea331d3f3e3402c125a1543f5875e2986ebb54f97d1647942161f803f", size = 4772031, upload-time = "2026-09-18T13:19:18.524Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/72/7a1321d359246769fff1affffbd0132785a28f7f63c18524c15a502398f4/psycopg_binary-3.3.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b82491019b884d62318b5f30706c3d7e6d4e5a6cb7eabcb3edc0c1b0fdaceae9", size = 5556392, upload-time = "2026-09-18T13:19:24.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/b0/c6f8a0585a5dacbea74e130bcfc66629390e8f5bbc79d2a8e806e8952150/psycopg_binary-3.3.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cec5ea900390897d0b46130f60bc2883bf19c314f9044235217c8be88b0ef269", size = 5237855, upload-time = "2026-09-18T13:19:31.257Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/fc/c3a7a8bbef7e945ec584ac61d460a612363ea398511cd0e220242b1d69f1/psycopg_binary-3.3.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98c02090d88f2ebc0ec1e8da538f77d225ce0fffecf372aa39262e62a1b054ef", size = 6833856, upload-time = "2026-09-18T13:19:43.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/f2/8e80b921db728ebb68fc105bd7c4277f908210ad755bd6481d5ea7add740/psycopg_binary-3.3.6-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ee2c4728c691245e24501fcd7a97b5b381236b9985bc445bba88cdce7d1b5784", size = 5070730, upload-time = "2026-09-18T13:19:49.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/6a/5b313e0c5348244f0e973aff3258bf86766656256d5ece8d541a53e35b4a/psycopg_binary-3.3.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f19cc87343eaa55255e76b31259a570072ac95d6ae82c92dd34b97691f5e49dc", size = 4598089, upload-time = "2026-09-18T13:19:56.426Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/e9/db7f76ec24bf6699e92bf604e5c4bae10664a681a8999ef42aa0faf0f2c6/psycopg_binary-3.3.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdccb3a0e184b03e9baa673b15a809cf36c339c85dbda0ebc25a698846dfbee8", size = 4278481, upload-time = "2026-09-18T13:20:04.681Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/83/72c67013656f4d6b547caabffb193e91d57e63f90eefdcc6d045c400e97d/psycopg_binary-3.3.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9892188bb15e5803beb51afe8a25add6b56be391a53058e8bca03b74e1e6bf22", size = 4009229, upload-time = "2026-09-18T13:20:11.905Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/35/5e4500df2c999eb0faed8b184e6958b834172128274f06167a5deef4c19c/psycopg_binary-3.3.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3af90f92769d8cc10f94515ee7a0aef36ea85ca733a0ce22858f6e0953f41138", size = 4321467, upload-time = "2026-09-18T13:20:17.949Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/7f/e350e1cf498ba2565c3f87b12f429d2012eb86b76c2b3845a19ee5fbb4d6/psycopg_binary-3.3.6-cp313-cp313-win_amd64.whl", hash = "sha256:0ebfad5d131de9f892ae9e70cc7616207768b6714b66a52d4612b8ceaf78b372", size = 3658179, upload-time = "2026-09-18T13:20:22.691Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/b9/60711317c284a442511644ea7185b56ebe627606d6741e732cd16108c47b/psycopg_binary-3.3.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:b3f75dee0f9afafabe4edc52c4842f1e1878ed2069bd05b22d6fe961e97e4dba", size = 4720512, upload-time = "2026-09-18T13:20:29.278Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/da/28befc84454cbc6374550de7746f591f8fe1b6165c1fce249652cc8291c4/psycopg_binary-3.3.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5927b7ba63153cd8e9862987290a2b783a5c590daf2a4ef981700cc3569166d4", size = 4782318, upload-time = "2026-09-18T13:20:35.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/8a/0d21c2c833cdc0d4244c77e858e0ed37fa2abec2623be4fd686f617109ce/psycopg_binary-3.3.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0bf08b749cc144f33b44a91b78e3f71c60eb07963746a0df5a100b36ce3d7475", size = 5567460, upload-time = "2026-09-18T13:20:41.902Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/6d/7692d0d4e656b6cc9868d8acc2e3b42f17a0db4a625400a6d093cb0533a1/psycopg_binary-3.3.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:31cd942c23f613276b81a6e6598cefa12960058b0f46e1e874b540c793f6aca5", size = 5246902, upload-time = "2026-09-18T13:20:47.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/c1/b8a1f18fb1b7558a17f57f7cb3fc8bc93189feea2958925950b3acb15743/psycopg_binary-3.3.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4690cf67738f0e0e49a32aeec99bf0e4595cc2b4f1af984a4345394b1dcff91a", size = 6847192, upload-time = "2026-09-18T13:20:56.874Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/76/404f33519167c65cca88ec4998776f1dbebccc301ee977f0e62c47fb0826/psycopg_binary-3.3.6-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ad1c785e784cfd87e8436c6b7702f2d321fc39601bbaf29bc63a41a867091638", size = 5079573, upload-time = "2026-09-18T13:21:04.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/d9/79e8fbc8f37262a415f3550f0bcc5f98037442bf3d12ef6cbae2056655ae/psycopg_binary-3.3.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:79a2a1c3449f6c3409427078ed1cec10de79f3023cb5f2504f0597d350ad46c7", size = 4613633, upload-time = "2026-09-18T13:21:10.664Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/47/96225db74be7d2ce04b3a58678b53cda610225055edf5faa775c9f501d8b/psycopg_binary-3.3.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:86147cb5d140341c3363fb5bacce31f8d5543902a46699d3c536b101bbceaf9e", size = 4293375, upload-time = "2026-09-18T13:21:16.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/d2/18e9c779a5efd565250329adaf529ecc2b8b2ed5be5cb0f6ccee208cbfd9/psycopg_binary-3.3.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7308c93cf0b19bbaf8e6ff0a6ad50d3c442385739245fe15a8d593bf841734a6", size = 4019883, upload-time = "2026-09-18T13:21:21.587Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/28/0cc654afc6c2cda982767f5679d3646b30b1ec86545bdaa9402202d6776c/psycopg_binary-3.3.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:05a83ac9fd52b9bca7cb5ab04b3691163170bd16f53defa27216ea3aa07ee781", size = 4332607, upload-time = "2026-09-18T13:21:27.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/3e/0a753a74fbd7aef120f286c016e09d3cc3f1daf7688f4a145d27281260b2/psycopg_binary-3.3.6-cp314-cp314-win_amd64.whl", hash = "sha256:1fbd30e537dab22cafdf080608f10148fe2a5f3a61294ddb5113caac8a623840", size = 3755671, upload-time = "2026-09-18T13:21:33.855Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/b1/a372b9c02aea50148e71c9853e19efca8fa5ae2010a8e27243b9b8f790c0/psycopg_binary-3.3.6-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:bf8c8481d026b85dd70c5fa7dde85b2333aed0b32a2602bcd38a900cbd78a49c", size = 4719571, upload-time = "2026-09-18T13:21:41.437Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/7c/811e3828c6b82e2f10c6c9cdd963cfc66f3e024026e5a69ac18530bad984/psycopg_binary-3.3.6-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:b599defe9190b17e9907c8b4d114c181e702c87efcd1b8a0ad40971cdcc4634a", size = 4781230, upload-time = "2026-09-18T13:21:49.516Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/15/9a784eed813ea9e97c294af3ead63d02b7b203502c66380336c50065e441/psycopg_binary-3.3.6-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b8ece331509f7a975b90501f41e83ad905e4141753fedf3f2711b2bc70a8efbc", size = 5566111, upload-time = "2026-09-18T13:21:58.089Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/16/47194e002007c27337b11e49bf459c4b19727463f9aff2e1a90917bcc806/psycopg_binary-3.3.6-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c61617eaae0112ca154da87ffb99b73af2c74067acac28dfb9a4455b019dff2e", size = 5249963, upload-time = "2026-09-18T13:22:06.695Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/84/5dcf9f310b11f0675cd860c6b2c70f58ce61798a3ee3f6f962b53fa358ca/psycopg_binary-3.3.6-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c6d19cb4999d03231e8730a5f66c8f5068bc3b532677eb39dab0f600bff3e312", size = 6847925, upload-time = "2026-09-18T13:22:13.088Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/06/1957a06dc22963c418c27b284929579de84f29c37ad1abe6dc6ee9e8cf25/psycopg_binary-3.3.6-cp315-cp315-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8cbb54454dbf1bbf2ff08dd7693e8d94ac94b1a20f70f4b3b813d52ecb5cbc1", size = 5087720, upload-time = "2026-09-18T13:22:17.959Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/43/ac07d042bae99b57bf123bb473632f29af544008094da0ffd285ab8011e2/psycopg_binary-3.3.6-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:dc75da5a20951049f7b773145f998f69d181adad9c58a0ff36e0cf1d73c10e10", size = 4613412, upload-time = "2026-09-18T13:22:26.719Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/b1/019156fbeafcefb4cccc9d109de4699493bceb8313c7545c8349e089dfbc/psycopg_binary-3.3.6-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:955e3dd94da361e052d2e49acf591017158dc8f8ed2c8a42c2e3943403c39dc2", size = 4292618, upload-time = "2026-09-18T13:22:33.042Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/0f/62113dc6b1df65983a1f2fc816c04b1edfa22f2ae9d4abee74ed267f4a96/psycopg_binary-3.3.6-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:c7753871eb57e6a5f4646f6168590c6653073dea5e9e720b201c8875332df4c8", size = 4027121, upload-time = "2026-09-18T13:22:38.334Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/d5/cf0cbd1ea5a7d8167fe2c6953efde19101f7b193bd61a23e6d622ad6854c/psycopg_binary-3.3.6-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:303732e798fe6729f8e12021b9c96107df8e95ecec4dd487c67b98ec2a59435e", size = 4336388, upload-time = "2026-09-18T13:22:45.576Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/33/e2a5b36edf8aa422f6fa4b894756eb33dc93b36df5f65121280bb8b929c4/psycopg_binary-3.3.6-cp315-cp315-win_amd64.whl", hash = "sha256:2f122603f36050937982abf9668d8bc4769a79f7c93a65013b1c49f1cab7b56b", size = 3756154, upload-time = "2026-09-18T13:22:51.283Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psycopg-pool"
|
||||
version = "3.3.1"
|
||||
version = "3.3.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.15'" },
|
||||
{ name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.15'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/90/82/7a23d26039827ecd4ebe93905651029ddd307c5182ad59296dfb6f67b528/psycopg_pool-3.3.1.tar.gz", hash = "sha256:b10b10b7a175d5cc1592147dc5b7eec8a9e0834eb3ed2c4a92c858e2f51eb63c", size = 31661, upload-time = "2026-05-01T23:31:59.809Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/74/5e/c0664b968b102ff68b811d999c728546c48d5c1eec03e3bbaf88c0cb4472/psycopg_pool-3.3.3.tar.gz", hash = "sha256:df87b5d9d0ad7db37f6cdad4fa8ce113d250f5997f6db38e9a99192fb67f9e1d", size = 32006, upload-time = "2026-09-22T15:53:24.947Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/37/ed/89c2c620af0e1660354cd8aabf9f5b21f911597ce22acb37c805d6c86bc8/psycopg_pool-3.3.1-py3-none-any.whl", hash = "sha256:2af5b432941c4c9ad5c87b3fa410aec910ec8f7c122855897983a06c45f2e4b5", size = 40023, upload-time = "2026-05-01T23:31:53.136Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/b4/452c6607a0f479465cd8a9b0d9956919fcb150050c1f83f9f11e6b8ee8dc/psycopg_pool-3.3.3-py3-none-any.whl", hash = "sha256:9b9cd6a4fcec47a410f7e82d408540e7f77b478509e91b44c1a5457a13e5ff37", size = 40304, upload-time = "2026-09-22T15:53:23.712Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -672,7 +708,8 @@ source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-types" },
|
||||
{ name = "pydantic-core" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.15'" },
|
||||
{ name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.15'" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
|
||||
@@ -685,7 +722,8 @@ name = "pydantic-core"
|
||||
version = "2.41.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.15'" },
|
||||
{ name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.15'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
|
||||
wheels = [
|
||||
@@ -809,7 +847,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.3"
|
||||
version = "9.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
@@ -820,9 +858,9 @@ dependencies = [
|
||||
{ name = "pygments" },
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -832,7 +870,7 @@ source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" },
|
||||
{ name = "pytest" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
{ name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
|
||||
wheels = [
|
||||
@@ -841,14 +879,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pytest-mock"
|
||||
version = "3.15.1"
|
||||
version = "3.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7a/7f/6ed29931d5c8cd396e7c0a55412e6cc88020373365c8685985dea53d26d7/pytest_mock-3.16.0.tar.gz", hash = "sha256:5a8395528b8f498205f3718f575228d0edaed7425fff638f87d1a6c3e0383636", size = 35362, upload-time = "2026-09-27T14:57:55.46Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/5b/b83a9bf1a3b4ec222f9fa083147ff6816245223da0ab92370e7e056f113f/pytest_mock-3.16.0-py3-none-any.whl", hash = "sha256:007cfeb257801d88d9c0b2a7b5a15a15e73b71968dfd72e7bf8c4a2f8393aec8", size = 10016, upload-time = "2026-09-27T14:57:54.283Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -957,27 +995,27 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.15"
|
||||
version = "0.16.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/84/6f/a76f7d96e5c962f5b69cee865e49c15c1116897c01990faa8a57edb62e7f/ruff-0.15.15.tar.gz", hash = "sha256:b8dff018130b46d8e5bf0f926ef6b60cf871d6d5ae45fc9334e09632daa741d6", size = 4706985, upload-time = "2026-05-28T14:16:57.784Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/bf/c935ca98e73fe8ce65b87ef08a280c0c1e85295d569228c15e87d8fdfaf1/ruff-0.16.9.tar.gz", hash = "sha256:12b625c6cfba78d285d9f48eda5f053374f1e53cb10ef17342a383750db99161", size = 4948764, upload-time = "2026-09-24T20:37:49.416Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/9d/3a45c05b8ab04b4705989de70a79008e27c8003296a0feaee9edc18dd7e9/ruff-0.15.15-py3-none-linux_armv6l.whl", hash = "sha256:cf93e5388f412e1b108b1f8b34a6e036b70fe8aff89393befad96fe48670311b", size = 10710652, upload-time = "2026-05-28T14:16:06.701Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/66/da974431624bf3b49f6ee1f9543c02d929ff1cba78b0d5a79c38cf21f744/ruff-0.15.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ac5a646d1f6a7dadd5d50842dae2c1f9862ac887ef5d1b1375e02def791fde6e", size = 11096615, upload-time = "2026-05-28T14:16:23.313Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/09/7443452e5d290230a712103f2fdceeef7184f3ec99a2bd01c8be78aaceb5/ruff-0.15.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:77d955a431430c66f72dd94e379ad38a16daea3d25094872ac4edf9e797be530", size = 10436683, upload-time = "2026-05-28T14:16:40.974Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/01/d330c26a57fa4f3943a14424904027428315b700fe4d14a84bb123a649e5/ruff-0.15.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7614ee79c69788cf6cedd568069ade9cecc22a1ad20494efe8d0c9ebb4b622d4", size = 10769064, upload-time = "2026-05-28T14:16:28.905Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/85/cc8770f8bdff541b1da8392d1634141fe4a0e3f4ee596605959b7906c27f/ruff-0.15.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3cdb1679e06a1f6b47bc384714ae96f6e2fb65ca441eb78c43d2ca554176ce1f", size = 10511987, upload-time = "2026-05-28T14:16:43.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/29/8c190c1472b63013583ba391f3342036e02010544c1270455ed8e519bdf3/ruff-0.15.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2728b93d7b23a603ea2c0ac6eb73d760bd38ec9de35f35fb41e18f7a3fee7622", size = 11275100, upload-time = "2026-05-28T14:16:55.244Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/6b/7e145ce2cc8e63d6834eca03d83a0e18d121def5c69f91b4cf4011ed4879/ruff-0.15.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be582fcc0db438902c7792b08d6ddf6c9b9e21addaa10092c2c741cfb09e5a45", size = 12176903, upload-time = "2026-05-28T14:16:14.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/a3/d5974637f68e451f7fadf015cf3101d1cd7d8ba5027cffe0b9e3826ebe6b/ruff-0.15.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7aa77465b8ecaf1a27bea098d696f7fed5e1eccbd10b321b682d6de586ae5627", size = 11404550, upload-time = "2026-05-28T14:16:20.138Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/1c/e6e5e568f22be4fb05d6244234aba384c06b451252453b821e1a529263cf/ruff-0.15.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48decfa11d740de4889de623be1463308346312f2409a56e24aa280c86162dc4", size = 11382027, upload-time = "2026-05-28T14:16:46.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/01/170921b49fcd2e8858825593f91cf7146c3e40a5c3e6df763e4bb0484dde/ruff-0.15.15-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a5015088452ca0081387063649ec67f06d3d1d6b8b936a1f836b5e9657ecd48c", size = 11366041, upload-time = "2026-05-28T14:16:26.247Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/54/a7bad711d7de93254e15e06a4c375b89a03d18de45d3e5dcc86a4472fb1a/ruff-0.15.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f5294aab6356c81600fcdea3a62bb1b924dfd5e91767c12318d3f68f86af57cd", size = 10741795, upload-time = "2026-05-28T14:16:17.11Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/31/38c075963668f8b41c6914ee0f6f318727fbe30ab9145cb29e6df464c5fa/ruff-0.15.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:db5bd4d802415cca656dc1616070b725952d6ae95eb5d4831e49fbd94a38f75f", size = 10511117, upload-time = "2026-05-28T14:16:31.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/96/6ff689e1f7e375d1d97075eca022f74c2bab59554a432fe4d2e6f091986a/ruff-0.15.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:587a6278ed42059191c1a466e490bd7930fb50bd2e255398bc29616c895a61cb", size = 10994867, upload-time = "2026-05-28T14:16:35.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/c2/5dce0ab9f92a8d534fa62b9bf9caca3eddb8c1a81b616f5e195ada4f0d6e/ruff-0.15.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:df0c1c084f5f4be9812f61518a45c440d3c30d69ce4bf6c5270e66d38338f02a", size = 11482101, upload-time = "2026-05-28T14:16:49.598Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/c0/1003b60edd697c649faf61f1a34094b1abb38fb3d1181e3f895781250a08/ruff-0.15.15-py3-none-win32.whl", hash = "sha256:29428ea79694afbe756d45fd59b36f22b6b020dc0443cf7de0173046236964b9", size = 10716774, upload-time = "2026-05-28T14:16:52.337Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/a8/1269eddd6945a06c23f055ef7848886e37cf9d6a8bebb386a3115f01470c/ruff-0.15.15-py3-none-win_amd64.whl", hash = "sha256:8df0323902e15e24bc4bf246da830573d3cf3352bd0b9a164eab335d111ff4a4", size = 11868463, upload-time = "2026-05-28T14:16:11.333Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/b2/920464c907b191e37469d477a1aa8bc048b8f36c4c1610dfa4ab87b39e18/ruff-0.15.15-py3-none-win_arm64.whl", hash = "sha256:3c8ceca6792f38196b8f589bc92eccd03eef286602da92e5dc05cc42ef6441b7", size = 11138498, upload-time = "2026-05-28T14:16:38.425Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/26/df51322b52ee1ada7eff2d071ea09d11d5c2d1dcc9f02594f5785c4d1635/ruff-0.16.9-py3-none-linux_armv6l.whl", hash = "sha256:95e6f022090368ab3b824c36276839c53b2adf1a3f4c09fefc33dfc400f6da96", size = 10082922, upload-time = "2026-09-24T20:37:13.045Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/27/7bf51f5a7aa375e9f339280a303aab44ca75dc1525f1cdc5991761685b0f/ruff-0.16.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a5f27be168556594a86d2f415db0cf43f5291917849318f873c7e2791f7a8c67", size = 10236360, upload-time = "2026-09-24T20:37:16.049Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/63/09659283f92f02dff45809da194a70da2f688d87c55d8875c4fae3536072/ruff-0.16.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1632eb1d6197f33bd00b1acbc5b71009e89a8895c158e2d2b03a834fac964ab6", size = 9892940, upload-time = "2026-09-24T20:37:17.957Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/58/98de1b72ec172f5f8f1731236fe21585b3998bf7dfe9fcc44ae9ba626012/ruff-0.16.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3f951b14d865d5952c89d40a5ca07e87abe24fa5453299878411e127748fb1c", size = 10032114, upload-time = "2026-09-24T20:37:19.942Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/7d/f1e17c54ab59d4bad1dce8ee3e22a7a1d0ef4745240decacdcf3832b5bb2/ruff-0.16.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:447fc07e1573afff7cb02803462b12b6c8ece7cf10e2cd78565fa6d7a1c0bf8d", size = 9910227, upload-time = "2026-09-24T20:37:21.872Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/19/436f647a65075bbd3bab2668b3bdaa5120559b294694018cdcefabbbf30b/ruff-0.16.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a3e039a6a40ed976c491722b60e0ae4a4aa1a86057f540ee7a37a5d19ae9120", size = 10547484, upload-time = "2026-09-24T20:37:24.229Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/59/38430a6bc2f6d8095447ac39625cf8b6e9344a47e6c26225c8ba1bff3ffb/ruff-0.16.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4684dded7db60aa57cb118fa158630f5feade4af5782903b6053484bdf9bd129", size = 11412367, upload-time = "2026-09-24T20:37:26.307Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/bd/bbb6d7fc7f208c8b8c50dd5dc8206e4cfdb1e7a8fb852606a3adf370c880/ruff-0.16.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d29c934357e45642fda2f34c0b1f4025b4a6c01e15e4bf0016879d60078a142c", size = 10869787, upload-time = "2026-09-24T20:37:28.35Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/b8/9c543074918061abbefc3bd139bee22de35abedb00dde0f2d27288838962/ruff-0.16.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a21713e629d3e5bdb2f5c2def1cc7f04f47fa8e1a7eb0571b4a28e1da64bc728", size = 10406494, upload-time = "2026-09-24T20:37:30.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/7a/5a8851bd146e7ccf8fd4b003f6c75c11f8fbdb0e60673b45097986b6bf41/ruff-0.16.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7baa24ef5fc8e77aa93879e1d3f43754a01ae488e869f1ae30cf431afd4d2452", size = 10590083, upload-time = "2026-09-24T20:37:32.439Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/f0/4c3467188f23f806960b46fa76575a7cd0514c9ba90562650b71efc96980/ruff-0.16.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a41aac6230aadfaa133bdfa1614488531ffa3e0837567ae04c0da2058a9c0f9e", size = 10119151, upload-time = "2026-09-24T20:37:34.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/34/5a4def5adea572ce6aea0bb64f21f928ee317b80e0db747d7979b01d7261/ruff-0.16.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c2529fb5896d49115b0e9aa8f887490b34bbe76baf879ec2264ac59406869ce7", size = 9911544, upload-time = "2026-09-24T20:37:36.796Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/5d/d15ebea7499eef9373318c0ee6ca127832927c6529731f6d48e18dce7ca9/ruff-0.16.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:41e3870277694177429b56406d65dfbdb2c2802c52b715edaf6a0b829c69d4ee", size = 10269884, upload-time = "2026-09-24T20:37:38.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/56/c5d3cd119ded7a3c7aba0e961b69cb3df701c91662c98ad694467d060ce1/ruff-0.16.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8adbe4e58af167f767d7b2ba5e83c42e878350796cf78c2f5e14ab9903a92588", size = 10749366, upload-time = "2026-09-24T20:37:41.042Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/fe/734ec7527029ac757ecf821f143c9f3fcf69149c21f53a044a899b430f5a/ruff-0.16.9-py3-none-win32.whl", hash = "sha256:0e1dbc2073624dee6618d41d0098690a7244654af746704b64759e12b6b6b385", size = 10152355, upload-time = "2026-09-24T20:37:43.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/21/26e4643629b3ebb44f0a06f9c9a53058d63d989415f63a9a3c28e2ee7f22/ruff-0.16.9-py3-none-win_amd64.whl", hash = "sha256:6bd40fec8cd4c8a3d4dd589bd8ad4e6320c13c29234159bfd959a40d529d597b", size = 10592965, upload-time = "2026-09-24T20:37:44.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/60/5fb1a39dbb5ae314d5f59bc7348a63c1d5c20f3cd83914c4b5cb0be31d2d/ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284", size = 10458649, upload-time = "2026-09-24T20:37:46.882Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1045,44 +1083,60 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ty"
|
||||
version = "0.0.43"
|
||||
version = "0.0.84"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0d/37/4ec04de0659b93be37d956dfceca13b1ecab9c959f28d8a1d5e514603f36/ty-0.0.43.tar.gz", hash = "sha256:ea4cff50548f2a1877e848d3abe9e293cde8ab94757a7eb93fc0d4013f98be8e", size = 5798429, upload-time = "2026-06-04T00:52:10.013Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e1/f6/34f8869c45ea87fe6a931ffa824b1fe4428167173543d92988d631add355/ty-0.0.84.tar.gz", hash = "sha256:0cefdb0cd5d399418dbe83c349ba605047b7dff815ae6f460c80e8522b40be67", size = 7409537, upload-time = "2026-09-24T13:16:24.367Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/db/74/1916026a78f20019a2f03adbd6fb4430ddb7ce1e52c2e17a90856a6d192e/ty-0.0.43-py3-none-linux_armv6l.whl", hash = "sha256:3bf70f5446480562bf6c9f639df4b5cb60716b8f8d1a6b8e5811d5c7eccd8bf2", size = 11598153, upload-time = "2026-06-04T00:52:20.646Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/af/58bb0089d2635216c8fa6612dd486a3f986d0ab1c46a41527ab95e57f0e3/ty-0.0.43-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7184741f8b15425a1bc64b950ad005cb353573288ac0e8a04f5481ceb3832596", size = 11357811, upload-time = "2026-06-04T00:52:24.683Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/9c/32c6b14f3feddf87b59c7a50709e2b3da408258f2f583f05575f77bc8f7b/ty-0.0.43-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8c306379ca9a35f6ae5270fe9bda7af4b46d91822725a2586d78c8b9b5493b62", size = 10772024, upload-time = "2026-06-04T00:52:14.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/fa/98aa4a74bd00cd5efc424923cd1daffbf1e40a0338041cafb203379d746f/ty-0.0.43-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d624b884c9c1fd244ad2a5f026364e7162a22b3f537025941ada2e363e676414", size = 11291034, upload-time = "2026-06-04T00:52:37.249Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/db/4de086c38ce96dcada2bd451f43171d2c237f96d8ed19a1ea8fe51bb8ef4/ty-0.0.43-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:281fc4c00fbc196045141faa085055bddc58846b04a2800204701415a1b9c6aa", size = 11364724, upload-time = "2026-06-04T00:52:33.138Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/d3/e3cd8e3233a6fd8362a49aa025b79e9f40151a2a86d811ace154c6eb7445/ty-0.0.43-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f57d6cc28de89024b48d1788e4758c05299d5749d4a51c02e71ac655ec23d9a5", size = 11890555, upload-time = "2026-06-04T00:52:22.711Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/7b/6f46d444e8241606bbde098df3dca93f2ec0b834a42055db85ee7d33646f/ty-0.0.43-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0a1d6ad6c5e7792c7eac0a01e550f2c2004462e01a64a91ea1636aba6fef6e71", size = 12450968, upload-time = "2026-06-04T00:52:28.94Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/e1/79fbe51f2e4b9d8347f2013cd7ed0b63f3b499038c02dc0357e9b28a3a47/ty-0.0.43-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:66d474395d7635fb618bdbb58b4e3360259a2056d0a5621b82754b9da2cd8a04", size = 12064187, upload-time = "2026-06-04T00:52:12.039Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/3f/c758a3a8df5b90d331f2b60c8f16021ee64d75e78f99d67cc4efc9bf5f4b/ty-0.0.43-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2663a0003a8b60fb98db7f6f6e673df80b21d0fe3a9868a26fb06b4e049b6fc4", size = 11943208, upload-time = "2026-06-04T00:52:31.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/5f/f516442749cf1b45ca6720a5d41df2738a486ed9ace774c03d515db89084/ty-0.0.43-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:d5a6c352d374d889189d5ec82b54b26a5885f769f7b7787f7f875500dcb8673e", size = 12143572, upload-time = "2026-06-04T00:52:18.457Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/bf/0d83c7f43bf4c10f3678bfe7d938e51c445298c7b923f155c5204730c2df/ty-0.0.43-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e7dbbeedfad3ca250d74fcc355fa9ab6b38d2a17f22d6304f615716939dbbb27", size = 11279355, upload-time = "2026-06-04T00:52:26.726Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/de/a6c978bef6d9e949f79f4782d9e4ee4df0893713e73b055d84c1a5116b9a/ty-0.0.43-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:24b18a0273ee46154996cfcfa27438f851f440c925587ec200df6f98dffe67d3", size = 11408412, upload-time = "2026-06-04T00:52:35.282Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/b1/d13857c23867f0f76b92e38e5841c64ca5e76dc5d4bf27f52cb81d8ab685/ty-0.0.43-py3-none-musllinux_1_2_i686.whl", hash = "sha256:2ef681951520d692b7e9c0b5e56aacf4f98ccae47cf6ffccaf2c7b6b33dc226e", size = 11541709, upload-time = "2026-06-04T00:52:16.451Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/f1/cd6afc6f6a687e238bf5e12189f7920e81a0bdef6c3dba4c784ef140f7d9/ty-0.0.43-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:2af105de7437143aa4676b28016b5bee661aaaa4eff52be5867fb25119641ceb", size = 12041266, upload-time = "2026-06-04T00:52:43.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/ba/51ca7c3335da2b8d0a3e477fa4986be9f4a53b05bfab862967d8d2e6ca60/ty-0.0.43-py3-none-win32.whl", hash = "sha256:e4773115b0d6486ee30f1657fc8bdffe7e3a3f5300ab77ef2495da6e83e4694f", size = 10858724, upload-time = "2026-06-04T00:52:07.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/29/5d80453e5f7c520145fa058851da87230dbd7ca761a7675447a9fe504e0b/ty-0.0.43-py3-none-win_amd64.whl", hash = "sha256:48d3545094a4ae6395492c7e6ac90550fce969e0ed2815fbf8c5da9756676b7d", size = 11976157, upload-time = "2026-06-04T00:52:41.438Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/ed/befe5a543e5b95e754ed38ee95239e44efda9bc5f578db4ac1bc8dd758d6/ty-0.0.43-py3-none-win_arm64.whl", hash = "sha256:740ca33d7f75f655a4e7d475bc42dfb825c13219bb073fad30fcc04d35790c74", size = 11308680, upload-time = "2026-06-04T00:52:39.233Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/0c/600648778e1c79133ce6b6a5c4c2531ae16b90c9b30f5041b38b574f61b8/ty-0.0.84-py3-none-linux_armv6l.whl", hash = "sha256:868438e2b9abfc835dd5b5b07ed693db95d8348ac40e3228252511d63ae87695", size = 13935333, upload-time = "2026-09-24T13:15:31.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/3b/27f06f49945c36170765fad82076898039ae3873338e9865f876dd62f12c/ty-0.0.84-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b00f39b521f0b559cde0f6abf8dd47213b513b04f33d58efeb9b98486a16bb17", size = 13511328, upload-time = "2026-09-24T13:15:34.446Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/b6/b12dd130e7f30141f0645614f454c925991cd389b1cc6c8284fd6bf32326/ty-0.0.84-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8f78c2915567f2ee62ce0c24d86091bc76f81577677755e242d09951db588b9a", size = 13445445, upload-time = "2026-09-24T13:15:37.476Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/96/f012de4b3c1d9a4773326621380d768b4d48c47e620dc012775c5e01c7d7/ty-0.0.84-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:61155cad0921e890d86d1d911a40b3a0e924ef4bdc3b4f3865a6c89c7e3012dc", size = 13471235, upload-time = "2026-09-24T13:15:40.64Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/32/dca630f5f5353fa7bf67eebccf1549df99b16656d21071d0df6677c826a5/ty-0.0.84-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:417c3bf14947ed16b0b92976ab333c27915590354e96d4f581709068d3811355", size = 13680704, upload-time = "2026-09-24T13:15:43.817Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/8d/33702c8f62f05ad45b33644fb914d483de6a0a7bc8e51884e9b8773e6d69/ty-0.0.84-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e06ef117759f654f5379efe9ae79bda8a186016c016f1023d05c34f8e15c1546", size = 14518923, upload-time = "2026-09-24T13:15:46.802Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/a1/da3246e6ef2ae0e842bca4058b25c54bee55ab058ffff4ee54e72517ec01/ty-0.0.84-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a05d1d53db8b64410ab38c6e32c5898510584869d1d5dd9c0605db81c9aef7df", size = 15027731, upload-time = "2026-09-24T13:15:49.755Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/17/b0099098a560aeb4f9fde6c912f9a81622c3e44e6555e1fbe322b368ec31/ty-0.0.84-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b92400494ab855cfb1204ac1101e3f53b2d09a8e21d171758b2cf9acdab1809e", size = 14748260, upload-time = "2026-09-24T13:15:52.713Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/d5/32cd67aedb271f006e716ad093806c96c5d7e2af86be8255469dbd3d76c8/ty-0.0.84-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af17eb391ae3027fed9be1780bea555c2b8a25ba68e184c627b4981b2c9eaab7", size = 14205858, upload-time = "2026-09-24T13:15:55.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/c4/638ac62ca2e8eca1b92e3e16273085744fada728b993500819b5c258ca15/ty-0.0.84-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:44cf06af0e7ec78ea6d8ff33a3450fc19627756a4fba681f7410d4e3ef63047a", size = 14573196, upload-time = "2026-09-24T13:15:59.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/f6/9ee81c5d8921e5fd5f10889ae563c95cd3272b31df2ada528919ca1cef6b/ty-0.0.84-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c6e56b443ff9ba462e34bfbc1e582004c2719ed35f8f93a433744b0ba0afb2d9", size = 13480760, upload-time = "2026-09-24T13:16:02.313Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/83/fbe5d1177667ae16d84c3242846b143809c9cc09568b333e5a9b4d5908f6/ty-0.0.84-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8b6562fcc48820c1030e2a4509ef2c14ef93043ca4860280074314ac3a825b95", size = 13696346, upload-time = "2026-09-24T13:16:05.391Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/9a/1b4ac9ddc34cc0ab4624bc219825d66af52f1ad87f4778ad08ad03fa06d5/ty-0.0.84-py3-none-musllinux_1_2_i686.whl", hash = "sha256:dee7421451efcbd70f03e95a8f2c8d9e5ec3d35edcd27b4095ee00ee062abfc4", size = 13959410, upload-time = "2026-09-24T13:16:08.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/04/70e289437a67dfd686056ab4fd91880261a8233033a6a95d29393741fb97/ty-0.0.84-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:31e96307cf621babe969307dd657783d672e2d8bdbd06493710da635bf63fa07", size = 14350294, upload-time = "2026-09-24T13:16:11.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/f4/ae7dbd118425c850981d23efb850f0d9d7f6b8d2b852bde2767434f668b4/ty-0.0.84-py3-none-win32.whl", hash = "sha256:4bdc85f91cbaae35825f2b17bb4ac5de0563367875cb0f42b2bf07a1bf7ff2ba", size = 13176439, upload-time = "2026-09-24T13:16:15.049Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/ec/994d87252f065dda5a500bc8afc726d18f6582d81d6f4ff1feb8a267c800/ty-0.0.84-py3-none-win_amd64.whl", hash = "sha256:71cc9aa7a7c89d4e12c6a814e9c0c3310ad5bee44efedbd6f5a42d91e9ac0f0d", size = 13962879, upload-time = "2026-09-24T13:16:18.191Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/1c/e6e91852b72566b1ab9aca504712857dfb89324c12092a5bd61d42d34427/ty-0.0.84-py3-none-win_arm64.whl", hash = "sha256:6c9d3718c4a0d318bc9c92af9b2aa85c3294e7dff54cf1d69ad79568f56516da", size = 13656453, upload-time = "2026-09-24T13:16:21.51Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.15'",
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version < '3.15'",
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-inspection"
|
||||
version = "0.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.15'" },
|
||||
{ name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.15'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
|
||||
wheels = [
|
||||
@@ -1100,11 +1154,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.7.0"
|
||||
version = "2.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -40,24 +40,12 @@ with SqliteSaver.from_conn_string(":memory:") as checkpointer:
|
||||
"v": 4,
|
||||
"ts": "2024-07-31T20:14:19.804150+00:00",
|
||||
"id": "1ef4f797-8335-6428-8001-8a1503f9b875",
|
||||
"channel_values": {
|
||||
"my_key": "meow",
|
||||
"node": "node"
|
||||
},
|
||||
"channel_versions": {
|
||||
"__start__": 2,
|
||||
"my_key": 3,
|
||||
"start:node": 3,
|
||||
"node": 3
|
||||
},
|
||||
"channel_values": {"my_key": "meow", "node": "node"},
|
||||
"channel_versions": {"__start__": 2, "my_key": 3, "start:node": 3, "node": 3},
|
||||
"versions_seen": {
|
||||
"__input__": {},
|
||||
"__start__": {
|
||||
"__start__": 1
|
||||
},
|
||||
"node": {
|
||||
"start:node": 2
|
||||
}
|
||||
"__start__": {"__start__": 1},
|
||||
"node": {"start:node": 2},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -81,24 +69,12 @@ async with AsyncSqliteSaver.from_conn_string(":memory:") as checkpointer:
|
||||
"v": 4,
|
||||
"ts": "2024-07-31T20:14:19.804150+00:00",
|
||||
"id": "1ef4f797-8335-6428-8001-8a1503f9b875",
|
||||
"channel_values": {
|
||||
"my_key": "meow",
|
||||
"node": "node"
|
||||
},
|
||||
"channel_versions": {
|
||||
"__start__": 2,
|
||||
"my_key": 3,
|
||||
"start:node": 3,
|
||||
"node": 3
|
||||
},
|
||||
"channel_values": {"my_key": "meow", "node": "node"},
|
||||
"channel_versions": {"__start__": 2, "my_key": 3, "start:node": 3, "node": 3},
|
||||
"versions_seen": {
|
||||
"__input__": {},
|
||||
"__start__": {
|
||||
"__start__": 1
|
||||
},
|
||||
"node": {
|
||||
"start:node": 2
|
||||
}
|
||||
"__start__": {"__start__": 1},
|
||||
"node": {"start:node": 2},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -24,14 +24,16 @@ from langgraph.checkpoint.base import (
|
||||
from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
|
||||
|
||||
from langgraph.checkpoint.sqlite._delta import (
|
||||
DELTA_WALK_SQL,
|
||||
DELTA_STAGE1_SQL,
|
||||
build_delta_channels_writes_history,
|
||||
build_delta_writes_fetch_sql,
|
||||
parse_supersteps_since_last_snapshot_by_channel,
|
||||
resolve_delta_chains,
|
||||
step_walk_supersteps,
|
||||
build_delta_stage2_sql,
|
||||
step_walk_with_row,
|
||||
)
|
||||
from langgraph.checkpoint.sqlite.utils import (
|
||||
load_pending_writes,
|
||||
pending_writes_sql,
|
||||
search_where,
|
||||
)
|
||||
from langgraph.checkpoint.sqlite.utils import search_where
|
||||
|
||||
_AIO_ERROR_MSG = (
|
||||
"The SqliteSaver does not support async methods. "
|
||||
@@ -79,10 +81,11 @@ class SqliteSaver(BaseCheckpointSaver[str]):
|
||||
>>> result = graph.invoke(3, config)
|
||||
>>> graph.get_state(config)
|
||||
StateSnapshot(values=4, next=(), config={'configurable': {'thread_id': '1', 'checkpoint_ns': '', 'checkpoint_id': '0c62ca34-ac19-445d-bbb0-5b4984975b2a'}}, parent_config=None)
|
||||
""" # noqa
|
||||
"""
|
||||
|
||||
conn: sqlite3.Connection
|
||||
is_setup: bool
|
||||
_has_task_path: bool = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -156,6 +159,7 @@ class SqliteSaver(BaseCheckpointSaver[str]):
|
||||
checkpoint_ns TEXT NOT NULL DEFAULT '',
|
||||
checkpoint_id TEXT NOT NULL,
|
||||
task_id TEXT NOT NULL,
|
||||
task_path TEXT NOT NULL DEFAULT '',
|
||||
idx INTEGER NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
type TEXT,
|
||||
@@ -164,6 +168,19 @@ class SqliteSaver(BaseCheckpointSaver[str]):
|
||||
);
|
||||
"""
|
||||
)
|
||||
# sqlite has no ADD COLUMN IF NOT EXISTS; this migrates databases
|
||||
# created before `task_path` existed and is a no-op on the rest.
|
||||
try:
|
||||
self.conn.execute(
|
||||
"ALTER TABLE writes ADD COLUMN task_path TEXT NOT NULL DEFAULT ''"
|
||||
)
|
||||
except sqlite3.OperationalError as e:
|
||||
# A read-only database from before the column can still be read;
|
||||
# its rows would all read back as '' anyway.
|
||||
if "readonly database" in str(e):
|
||||
self._has_task_path = False
|
||||
elif "duplicate column name" not in str(e):
|
||||
raise
|
||||
|
||||
self.is_setup = True
|
||||
|
||||
@@ -224,7 +241,7 @@ class SqliteSaver(BaseCheckpointSaver[str]):
|
||||
>>> checkpoint_tuple = memory.get_tuple(config)
|
||||
>>> print(checkpoint_tuple)
|
||||
CheckpointTuple(...)
|
||||
""" # noqa
|
||||
"""
|
||||
checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
|
||||
with self.cursor(transaction=False) as cur:
|
||||
# find the latest checkpoint for the thread_id
|
||||
@@ -262,7 +279,7 @@ class SqliteSaver(BaseCheckpointSaver[str]):
|
||||
}
|
||||
# find any pending writes
|
||||
cur.execute(
|
||||
"SELECT task_id, channel, type, value FROM writes WHERE thread_id = ? AND checkpoint_ns = ? AND checkpoint_id = ? ORDER BY task_id, idx",
|
||||
pending_writes_sql(self._has_task_path),
|
||||
(
|
||||
str(config["configurable"]["thread_id"]),
|
||||
checkpoint_ns,
|
||||
@@ -288,10 +305,7 @@ class SqliteSaver(BaseCheckpointSaver[str]):
|
||||
if parent_checkpoint_id
|
||||
else None
|
||||
),
|
||||
[
|
||||
(task_id, channel, self.serde.loads_typed((type, value)))
|
||||
for task_id, channel, type, value in cur
|
||||
],
|
||||
load_pending_writes(cur, self.serde),
|
||||
)
|
||||
|
||||
def list(
|
||||
@@ -353,7 +367,7 @@ class SqliteSaver(BaseCheckpointSaver[str]):
|
||||
metadata,
|
||||
) in cur:
|
||||
wcur.execute(
|
||||
"SELECT task_id, channel, type, value FROM writes WHERE thread_id = ? AND checkpoint_ns = ? AND checkpoint_id = ? ORDER BY task_id, idx",
|
||||
pending_writes_sql(self._has_task_path),
|
||||
(thread_id, checkpoint_ns, checkpoint_id),
|
||||
)
|
||||
yield CheckpointTuple(
|
||||
@@ -380,10 +394,7 @@ class SqliteSaver(BaseCheckpointSaver[str]):
|
||||
if parent_checkpoint_id
|
||||
else None
|
||||
),
|
||||
[
|
||||
(task_id, channel, self.serde.loads_typed((type, value)))
|
||||
for task_id, channel, type, value in wcur
|
||||
],
|
||||
load_pending_writes(wcur, self.serde),
|
||||
)
|
||||
|
||||
def put(
|
||||
@@ -462,9 +473,9 @@ class SqliteSaver(BaseCheckpointSaver[str]):
|
||||
task_path: Path of the task creating the writes.
|
||||
"""
|
||||
query = (
|
||||
"INSERT OR REPLACE INTO writes (thread_id, checkpoint_ns, checkpoint_id, task_id, idx, channel, type, value) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
"INSERT OR REPLACE INTO writes (thread_id, checkpoint_ns, checkpoint_id, task_id, task_path, idx, channel, type, value) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
if all(w[0] in WRITES_IDX_MAP for w in writes)
|
||||
else "INSERT OR IGNORE INTO writes (thread_id, checkpoint_ns, checkpoint_id, task_id, idx, channel, type, value) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
else "INSERT OR IGNORE INTO writes (thread_id, checkpoint_ns, checkpoint_id, task_id, task_path, idx, channel, type, value) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
)
|
||||
with self.cursor() as cur:
|
||||
cur.executemany(
|
||||
@@ -475,6 +486,7 @@ class SqliteSaver(BaseCheckpointSaver[str]):
|
||||
str(config["configurable"]["checkpoint_ns"]),
|
||||
str(config["configurable"]["checkpoint_id"]),
|
||||
task_id,
|
||||
task_path,
|
||||
WRITES_IDX_MAP.get(channel, idx),
|
||||
channel,
|
||||
*self.serde.dumps_typed(value),
|
||||
@@ -507,117 +519,81 @@ class SqliteSaver(BaseCheckpointSaver[str]):
|
||||
) -> Mapping[str, DeltaChannelHistory]:
|
||||
"""Fast-path override of `BaseCheckpointSaver.get_delta_channel_history`.
|
||||
|
||||
Walks the parent chain `supersteps` hops (read from the target's
|
||||
`counters_since_delta_snapshot` metadata) to each channel's seed
|
||||
snapshot, then collects the writes between the seed and the target.
|
||||
Two-stage query:
|
||||
|
||||
* WALK: stream a newest-first slice of `checkpoints` returning
|
||||
`(checkpoint_id, parent_checkpoint_id, type, checkpoint)` per
|
||||
ancestor. Sqlite has no JSONB, so we deserialize only the seed
|
||||
checkpoints to read their inline `channel_values`. Stops once the
|
||||
shared chain reaches the deepest requested `supersteps` or the
|
||||
root is reached.
|
||||
* Stage 1 (streamed): recursive CTE over `checkpoints` following
|
||||
`parent_checkpoint_id` from the target, returning
|
||||
`(checkpoint_id, type, checkpoint)` per ancestor. Sqlite has no
|
||||
JSONB, so we ship the full serialized checkpoint blob and inspect
|
||||
`channel_values` in Python. Stops reading when every channel has
|
||||
found its seed or the chain is exhausted.
|
||||
|
||||
* FETCH (per-channel UNION ALL): one branch per channel reading
|
||||
`writes` filtered to that channel's `chain_cids`. No separate
|
||||
seed-blob fetch — sqlite stores `channel_values` inline.
|
||||
* Stage 2 (per-channel UNION ALL): one branch per channel reading
|
||||
`writes` filtered to that channel's specific `chain_cids`. No
|
||||
separate seed-blob fetch — sqlite stores `channel_values` inline
|
||||
in the checkpoint blob, so seeds come back from stage 1.
|
||||
"""
|
||||
if not channels:
|
||||
return {}
|
||||
channels = list(channels)
|
||||
thread_id = str(config["configurable"]["thread_id"])
|
||||
if not thread_id:
|
||||
raise ValueError("empty thread ID")
|
||||
checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
|
||||
|
||||
# Resolve the target checkpoint id + its metadata (for supersteps).
|
||||
checkpoint_id = get_checkpoint_id(config)
|
||||
with self.cursor(transaction=False) as cur:
|
||||
if checkpoint_id is None:
|
||||
cur.execute(
|
||||
"SELECT checkpoint_id, metadata FROM checkpoints "
|
||||
"WHERE thread_id = ? AND checkpoint_ns = ? "
|
||||
"ORDER BY checkpoint_id DESC LIMIT 1",
|
||||
(thread_id, checkpoint_ns),
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"SELECT checkpoint_id, metadata FROM checkpoints "
|
||||
"WHERE thread_id = ? AND checkpoint_ns = ? "
|
||||
"AND checkpoint_id = ?",
|
||||
(thread_id, checkpoint_ns, checkpoint_id),
|
||||
)
|
||||
target_row = cur.fetchone()
|
||||
if target_row is None:
|
||||
return {ch: {"writes": []} for ch in channels}
|
||||
target_id = str(target_row[0])
|
||||
metadata = json.loads(target_row[1]) if target_row[1] is not None else {}
|
||||
supersteps_by_ch = parse_supersteps_since_last_snapshot_by_channel(
|
||||
metadata, channels
|
||||
)
|
||||
max_supersteps = max(supersteps_by_ch.values(), default=0)
|
||||
needed_depths = set(supersteps_by_ch.values())
|
||||
if not checkpoint_id:
|
||||
target = self.get_tuple(config)
|
||||
if target is None:
|
||||
return {ch: {"writes": []} for ch in channels}
|
||||
checkpoint_id = target.config["configurable"]["checkpoint_id"]
|
||||
|
||||
shared_cpid_chain: list[str] = []
|
||||
chain_by_ch: dict[str, list[str]] = {ch: [] for ch in channels}
|
||||
seed_val_by_ch: dict[str, Any] = {}
|
||||
walk_state: dict[str, Any] = {}
|
||||
seed_values_by_depth: dict[int, dict[str, Any]] = {}
|
||||
seeded: set[str] = set()
|
||||
|
||||
with self.cursor(transaction=False) as cur:
|
||||
if max_supersteps > 0:
|
||||
cur.execute(DELTA_WALK_SQL, (thread_id, checkpoint_ns, target_id))
|
||||
for row in cur:
|
||||
cid, parent_cid, type_tag, blob = row
|
||||
if step_walk_supersteps(
|
||||
cid=cid,
|
||||
parent_cid=parent_cid,
|
||||
type_tag=type_tag,
|
||||
blob=blob,
|
||||
target_id=target_id,
|
||||
serde=self.serde,
|
||||
shared_cpid_chain=shared_cpid_chain,
|
||||
walk_state=walk_state,
|
||||
max_supersteps=max_supersteps,
|
||||
needed_depths=needed_depths,
|
||||
seed_values_by_depth=seed_values_by_depth,
|
||||
channels=channels,
|
||||
):
|
||||
break
|
||||
|
||||
(
|
||||
chained_cpid_by_ch,
|
||||
seed_cpid_by_ch,
|
||||
seed_value_by_ch,
|
||||
) = resolve_delta_chains(
|
||||
channels=channels,
|
||||
supersteps_by_ch=supersteps_by_ch,
|
||||
shared_cpid_chain=shared_cpid_chain,
|
||||
seed_values_by_depth=seed_values_by_depth,
|
||||
has_reached_root=bool(walk_state.get("reached_root")),
|
||||
thread_id=thread_id,
|
||||
cur.execute(
|
||||
DELTA_STAGE1_SQL,
|
||||
(thread_id, checkpoint_ns, checkpoint_id, thread_id, checkpoint_ns),
|
||||
)
|
||||
for row in cur:
|
||||
cid, type_tag, blob = row
|
||||
if step_walk_with_row(
|
||||
cid=cid,
|
||||
type_tag=type_tag,
|
||||
blob=blob,
|
||||
target_id=checkpoint_id,
|
||||
serde=self.serde,
|
||||
chain_by_ch=chain_by_ch,
|
||||
seed_val_by_ch=seed_val_by_ch,
|
||||
walk_state=walk_state,
|
||||
seeded=seeded,
|
||||
channels=channels,
|
||||
):
|
||||
break
|
||||
|
||||
channels_with_chain = [ch for ch in channels if chained_cpid_by_ch[ch]]
|
||||
fetch_sql = build_delta_writes_fetch_sql(
|
||||
chain_lens=[len(chained_cpid_by_ch[ch]) for ch in channels_with_chain],
|
||||
channels_with_chain = [ch for ch in channels if chain_by_ch[ch]]
|
||||
stage2_sql = build_delta_stage2_sql(
|
||||
has_task_path=self._has_task_path,
|
||||
chain_lens=[len(chain_by_ch[ch]) for ch in channels_with_chain],
|
||||
)
|
||||
if fetch_sql:
|
||||
fetch_params: list[Any] = []
|
||||
if stage2_sql:
|
||||
stage2_params: list[Any] = []
|
||||
for ch in channels_with_chain:
|
||||
fetch_params.extend(
|
||||
[thread_id, checkpoint_ns, ch, *chained_cpid_by_ch[ch]]
|
||||
stage2_params.extend(
|
||||
[thread_id, checkpoint_ns, ch, *chain_by_ch[ch]]
|
||||
)
|
||||
cur.execute(fetch_sql, fetch_params)
|
||||
cur.execute(stage2_sql, stage2_params)
|
||||
stage2_rows = cast(
|
||||
"list[tuple[str, str, str, int, str, bytes]]", cur.fetchall()
|
||||
"list[tuple[str, str, str, int, str, bytes, str]]", cur.fetchall()
|
||||
)
|
||||
else:
|
||||
stage2_rows = []
|
||||
|
||||
return build_delta_channels_writes_history(
|
||||
channels=channels,
|
||||
chained_cpid_by_ch=chained_cpid_by_ch,
|
||||
seed_cpid_by_ch=seed_cpid_by_ch,
|
||||
seed_value_by_ch=seed_value_by_ch,
|
||||
chain_by_ch=chain_by_ch,
|
||||
seed_val_by_ch=seed_val_by_ch,
|
||||
seeded=seeded,
|
||||
stage2_rows=stage2_rows,
|
||||
serde=self.serde,
|
||||
)
|
||||
|
||||
@@ -1,63 +1,73 @@
|
||||
"""Shared helpers for `get_delta_channel_history` on sqlite savers.
|
||||
|
||||
Mirrors the supersteps-based two-pass shape of `BasePostgresSaver`
|
||||
(ancestor walk bounded by `counters_since_delta_snapshot` + per-channel
|
||||
UNION ALL writes fetch), adapted for sqlite's constraints:
|
||||
Mirrors the two-stage shape of `BasePostgresSaver` (ancestor walk +
|
||||
per-channel UNION ALL writes fetch), but adapted for sqlite's
|
||||
constraints. The structural differences:
|
||||
|
||||
* No JSONB — to inspect `channel_values` for a checkpoint we must
|
||||
deserialize the full blob. The WALK streams the cursor row-by-row and
|
||||
deserializes only the seed checkpoints (the ones at a channel's
|
||||
`supersteps` depth), freeing each blob before advancing.
|
||||
deserialize the full blob. Stage 1 streams the cursor row-by-row and
|
||||
deserializes only the rows the merged walk visits, freeing each blob
|
||||
before advancing.
|
||||
* No separate blob table — `channel_values` lives inline in the
|
||||
checkpoint, so seeds come back from the WALK with no second fetch.
|
||||
* Single shared parent-chain walk: each requested channel slices the
|
||||
same chain to its own `supersteps` depth.
|
||||
checkpoint, so seeds come back from stage 1 with no second fetch.
|
||||
* Single merged walk (not K independent walks): each visited cid is
|
||||
deserialized exactly once, regardless of how many channels are still
|
||||
seeking their seed.
|
||||
|
||||
Walk depth is driven by the *supersteps since last snapshot* counter,
|
||||
not by scanning `channel_values` for the snapshot marker — the only
|
||||
reliable way to locate seeds that aren't a `_DeltaSnapshot` sentinel
|
||||
(e.g. legacy plain-value blobs from a thread migrated off a non-delta
|
||||
channel).
|
||||
The streaming design keeps peak in-flight memory at roughly one
|
||||
deserialized checkpoint at a time, instead of holding the entire
|
||||
ancestor chain's worth of raw blobs as a `fetchall()`-materialized list.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Any
|
||||
|
||||
from langgraph.checkpoint.base import (
|
||||
DeltaChannelHistory,
|
||||
PendingWrite,
|
||||
_parse_supersteps_since_last_snapshot_by_channel,
|
||||
)
|
||||
from langgraph.checkpoint.serde.types import _DeltaSnapshot
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Re-exported under the package-local name used by the sqlite savers.
|
||||
parse_supersteps_since_last_snapshot_by_channel = (
|
||||
_parse_supersteps_since_last_snapshot_by_channel
|
||||
writes_sort_key,
|
||||
)
|
||||
|
||||
# The WALK streams ancestors of `target_id` newest-first. The `<=`
|
||||
# predicate keeps target itself in the stream so we can read its
|
||||
# `parent_checkpoint_id` from the first matching row without a separate
|
||||
# lookup; target's own writes/seed are not part of the contract.
|
||||
DELTA_WALK_SQL = (
|
||||
# Stage 1 streams target, then its ancestors nearest-first, by following
|
||||
# `parent_checkpoint_id` rather than id order: ids are only monotonic within
|
||||
# one process, so a range scan by id can miss a parent whose id sorts above
|
||||
# its child's. Target is the anchor row; its own writes/seed are skipped
|
||||
# (matches the `BaseCheckpointSaver` contract).
|
||||
#
|
||||
# `put` is `INSERT OR REPLACE`, so re-putting an existing id under a
|
||||
# descendant's config makes the chain a loop. `step_walk_with_row` stops on a
|
||||
# repeated id; sqlite yields recursive rows lazily, so abandoning the cursor
|
||||
# ends the recursion.
|
||||
#
|
||||
# `CROSS JOIN` pins `ancestors` as the outer loop, so each step is one primary
|
||||
# key lookup. With a plain `JOIN` and no `ANALYZE` stats, sqlite can put
|
||||
# `checkpoints` outside and scan the whole thread per step.
|
||||
DELTA_STAGE1_SQL = (
|
||||
"WITH RECURSIVE ancestors(checkpoint_id, parent_checkpoint_id, type, "
|
||||
"checkpoint) AS ("
|
||||
"SELECT checkpoint_id, parent_checkpoint_id, type, checkpoint "
|
||||
"FROM checkpoints "
|
||||
"WHERE thread_id = ? AND checkpoint_ns = ? AND checkpoint_id <= ? "
|
||||
"ORDER BY checkpoint_id DESC"
|
||||
"WHERE thread_id = ? AND checkpoint_ns = ? AND checkpoint_id = ? "
|
||||
"UNION ALL "
|
||||
"SELECT c.checkpoint_id, c.parent_checkpoint_id, c.type, c.checkpoint "
|
||||
"FROM ancestors a CROSS JOIN checkpoints c "
|
||||
"ON c.checkpoint_id = a.parent_checkpoint_id "
|
||||
"WHERE c.thread_id = ? AND c.checkpoint_ns = ?"
|
||||
") "
|
||||
"SELECT checkpoint_id, type, checkpoint FROM ancestors"
|
||||
)
|
||||
|
||||
|
||||
def build_delta_writes_fetch_sql(*, chain_lens: Sequence[int]) -> str:
|
||||
"""Per-channel UNION ALL fetching writes from `writes`.
|
||||
def build_delta_stage2_sql(
|
||||
*, chain_lens: Sequence[int], has_task_path: bool = True
|
||||
) -> str:
|
||||
"""Stage-2 per-channel UNION ALL fetching writes from `writes`.
|
||||
|
||||
One branch per channel with a non-empty chain. Each branch inlines its
|
||||
own `IN (?, ?, ...)` placeholder list because sqlite has no array-bind
|
||||
equivalent of postgres's `= ANY(?)`. Caller passes parameters in
|
||||
equivalent of postgres's `= ANY(%s)`. Caller passes parameters in
|
||||
matching order: `[thread_id, checkpoint_ns, channel, *chain_cids]` per
|
||||
branch.
|
||||
|
||||
@@ -66,11 +76,12 @@ def build_delta_writes_fetch_sql(*, chain_lens: Sequence[int]) -> str:
|
||||
of a single `channel = ANY(channels)` filter when channels have
|
||||
different chain depths — same rationale as postgres.
|
||||
"""
|
||||
task_path = "task_path" if has_task_path else "''"
|
||||
branches: list[str] = []
|
||||
for n in chain_lens:
|
||||
cid_placeholders = ",".join("?" * n)
|
||||
branches.append(
|
||||
"SELECT checkpoint_id, channel, task_id, idx, type, value "
|
||||
f"SELECT checkpoint_id, channel, task_id, idx, type, value, {task_path} "
|
||||
"FROM writes "
|
||||
"WHERE thread_id = ? AND checkpoint_ns = ? AND channel = ? "
|
||||
f"AND checkpoint_id IN ({cid_placeholders})"
|
||||
@@ -78,184 +89,104 @@ def build_delta_writes_fetch_sql(*, chain_lens: Sequence[int]) -> str:
|
||||
return " UNION ALL ".join(branches)
|
||||
|
||||
|
||||
def step_walk_supersteps(
|
||||
def step_walk_with_row(
|
||||
*,
|
||||
cid: str,
|
||||
parent_cid: str | None,
|
||||
type_tag: str,
|
||||
blob: bytes,
|
||||
target_id: str,
|
||||
serde: Any,
|
||||
shared_cpid_chain: list[str],
|
||||
chain_by_ch: dict[str, list[str]],
|
||||
seed_val_by_ch: dict[str, Any],
|
||||
walk_state: dict[str, Any],
|
||||
max_supersteps: int,
|
||||
needed_depths: set[int],
|
||||
seed_values_by_depth: dict[int, dict[str, Any]],
|
||||
seeded: set[str],
|
||||
channels: Sequence[str],
|
||||
) -> bool:
|
||||
"""Process one streamed WALK row, extending the shared parent chain.
|
||||
"""Process one streamed stage-1 row in the merged ancestor walk.
|
||||
|
||||
The cursor returns `(cid, parent_cid, type, checkpoint)` rows in
|
||||
`checkpoint_id` DESC order starting at target. The first row is target
|
||||
itself; we read its `parent_cid` to seed the walk and skip it (target's
|
||||
own writes/seed are not part of the contract). Off-path rows (a sibling
|
||||
branch on the same thread) advance the cursor without doing work.
|
||||
The cursor returns (cid, type, blob) rows in walk order starting at
|
||||
target. The first row is target itself and is skipped (target's own
|
||||
writes/seed are not part of the contract).
|
||||
|
||||
For each on-path ancestor we append its cid to `shared_cpid_chain`
|
||||
(newest first). When the ancestor sits at a depth some channel needs as
|
||||
its seed (`len(chain)` ∈ `needed_depths`), we deserialize it once and
|
||||
record its `channel_values` for the requested channels. The
|
||||
deserialized checkpoint is dropped immediately — peak in-flight is one
|
||||
deserialized checkpoint.
|
||||
For each subsequent row we deserialize the blob, append the cid to
|
||||
every not-yet-seeded channel's chain, and check `channel_values` for
|
||||
seeds. The deserialized checkpoint is dropped before advancing — no
|
||||
cross-row cache, so peak in-flight is one deserialized checkpoint.
|
||||
|
||||
Sets `walk_state["reached_root"]` when an ancestor has no parent.
|
||||
Returns True when the walk can stop: chain reached `max_supersteps`, or
|
||||
the root was reached.
|
||||
Returns True when the caller can stop iterating and close the cursor:
|
||||
every requested channel is seeded, or the chain revisited a checkpoint.
|
||||
"""
|
||||
if "started" not in walk_state:
|
||||
if cid == target_id:
|
||||
walk_state["started"] = True
|
||||
walk_state["cur_cid"] = parent_cid
|
||||
if parent_cid is None:
|
||||
walk_state["reached_root"] = True
|
||||
return True
|
||||
walk_state["active"] = {ch for ch in channels if ch not in seeded}
|
||||
walk_state["walked"] = {cid}
|
||||
# Not target yet (or target not present): keep streaming.
|
||||
return False
|
||||
if len(shared_cpid_chain) >= max_supersteps:
|
||||
active: set[str] = walk_state["active"]
|
||||
if not active:
|
||||
return True
|
||||
if cid != walk_state["cur_cid"]:
|
||||
# Off-path row from a sibling branch — skip without deserializing.
|
||||
return False
|
||||
shared_cpid_chain.append(cid)
|
||||
depth = len(shared_cpid_chain) # 1-indexed position along the chain
|
||||
# Capture channel_values at any depth a channel may use as its seed: the
|
||||
# exact `supersteps` depths, plus the root-most checkpoint (the seed
|
||||
# candidate when the chain is shorter than `supersteps`).
|
||||
if depth in needed_depths or parent_cid is None:
|
||||
ckpt = serde.loads_typed((type_tag, blob))
|
||||
channel_values: Mapping[str, Any] = ckpt.get("channel_values") or {}
|
||||
seed_values_by_depth[depth] = {
|
||||
ch: channel_values[ch] for ch in channels if ch in channel_values
|
||||
}
|
||||
del ckpt, channel_values
|
||||
if parent_cid is None:
|
||||
walk_state["reached_root"] = True
|
||||
walked: set[str] = walk_state["walked"]
|
||||
if cid in walked:
|
||||
return True
|
||||
walk_state["cur_cid"] = parent_cid
|
||||
return len(shared_cpid_chain) >= max_supersteps
|
||||
|
||||
|
||||
def resolve_delta_chains(
|
||||
*,
|
||||
channels: Sequence[str],
|
||||
supersteps_by_ch: Mapping[str, int],
|
||||
shared_cpid_chain: Sequence[str],
|
||||
seed_values_by_depth: Mapping[int, Mapping[str, Any]],
|
||||
has_reached_root: bool,
|
||||
thread_id: str,
|
||||
) -> tuple[
|
||||
dict[str, list[str]],
|
||||
dict[str, str | None],
|
||||
dict[str, Any],
|
||||
]:
|
||||
"""Slice the shared parent chain into per-channel chain/seed mappings.
|
||||
|
||||
For each channel the seed snapshot sits `supersteps` hops back: the seed
|
||||
checkpoint is `shared_cpid_chain[supersteps - 1]` and the chain is
|
||||
`shared_cpid_chain[:supersteps]` (newest first), with the seed's inline
|
||||
`channel_values[ch]` captured during the walk.
|
||||
|
||||
When the chain is shorter than `supersteps` but the walk reached the
|
||||
root, the persisted chain is "compressed" relative to the logical
|
||||
superstep count (`durability="exit"`, or a thread that never
|
||||
snapshotted). The seed candidate is then the oldest persisted checkpoint
|
||||
(`shared_cpid_chain[-1]`); if its `channel_values[ch]` is absent the
|
||||
channel has no seed and replays the full chain on an empty baseline.
|
||||
"""
|
||||
chained_cpid_by_ch: dict[str, list[str]] = {ch: [] for ch in channels}
|
||||
seed_cpid_by_ch: dict[str, str | None] = {ch: None for ch in channels}
|
||||
seed_value_by_ch: dict[str, Any] = {}
|
||||
for ch in channels:
|
||||
bound = supersteps_by_ch.get(ch, 0)
|
||||
if bound <= 0:
|
||||
continue
|
||||
if len(shared_cpid_chain) >= bound:
|
||||
seed_depth = bound
|
||||
elif has_reached_root:
|
||||
seed_depth = len(shared_cpid_chain)
|
||||
else:
|
||||
logger.warning(
|
||||
"cannot find seed snapshot for delta channel "
|
||||
"(thread_id=%s, channel=%s)",
|
||||
thread_id,
|
||||
ch,
|
||||
)
|
||||
continue
|
||||
if seed_depth <= 0:
|
||||
continue
|
||||
chained_cpid_by_ch[ch] = list(shared_cpid_chain[:seed_depth])
|
||||
seed_cpid_by_ch[ch] = shared_cpid_chain[seed_depth - 1]
|
||||
seed_vals = seed_values_by_depth.get(seed_depth, {})
|
||||
if ch in seed_vals:
|
||||
seed_value_by_ch[ch] = seed_vals[ch]
|
||||
return chained_cpid_by_ch, seed_cpid_by_ch, seed_value_by_ch
|
||||
walked.add(cid)
|
||||
for ch in active:
|
||||
chain_by_ch[ch].append(cid)
|
||||
ckpt = serde.loads_typed((type_tag, blob))
|
||||
channel_values: Mapping[str, Any] = ckpt.get("channel_values") or {}
|
||||
for ch in [ch for ch in active if ch in channel_values]:
|
||||
seed_val_by_ch[ch] = channel_values[ch]
|
||||
seeded.add(ch)
|
||||
active.discard(ch)
|
||||
del ckpt, channel_values
|
||||
return not active
|
||||
|
||||
|
||||
def build_delta_channels_writes_history(
|
||||
*,
|
||||
channels: Sequence[str],
|
||||
chained_cpid_by_ch: Mapping[str, Sequence[str]],
|
||||
seed_cpid_by_ch: Mapping[str, str | None],
|
||||
seed_value_by_ch: Mapping[str, Any],
|
||||
stage2_rows: Sequence[tuple[str, str, str, int, str, bytes]],
|
||||
chain_by_ch: Mapping[str, list[str]],
|
||||
seed_val_by_ch: Mapping[str, Any],
|
||||
seeded: set[str],
|
||||
stage2_rows: Sequence[tuple[str, str, str, int, str, bytes, str]],
|
||||
serde: Any,
|
||||
) -> dict[str, DeltaChannelHistory]:
|
||||
"""Demux writes rows per channel; produce per-channel histories.
|
||||
"""Demux stage-2 rows per channel; produce per-channel histories.
|
||||
|
||||
`stage2_rows` are `(checkpoint_id, channel, task_id, idx, type, value)`.
|
||||
Final write order is oldest→newest globally and `(task_id, idx)` within
|
||||
a checkpoint, matching the contract on `DeltaChannelHistory.writes`.
|
||||
Stage-2 rows are
|
||||
`(checkpoint_id, channel, task_id, idx, type, value, task_path)`.
|
||||
Final write order is oldest→newest globally and `writes_sort_key`
|
||||
within a checkpoint, matching the contract on
|
||||
`DeltaChannelHistory.writes`.
|
||||
|
||||
The seed checkpoint's own writes are replayed on top of a
|
||||
`_DeltaSnapshot` seed (the snapshot is the value *prior* to its own
|
||||
writes), but skipped for a migrated plain-value seed (a legacy
|
||||
non-delta blob already incorporates those writes). `seed` is omitted
|
||||
when no seed was located (implicit empty baseline); consumers treat
|
||||
absence as "start empty".
|
||||
`seed` is omitted when the walk reached a true root with no snapshot
|
||||
found (channel never entered `seeded`); consumers treat absence as
|
||||
"start empty".
|
||||
"""
|
||||
writes_by_ch_by_cid: dict[str, dict[str, list[tuple[str, bytes, str, int]]]] = {
|
||||
ch: {} for ch in channels
|
||||
}
|
||||
for cid, ch, task_id, idx, type_tag, value_blob in stage2_rows:
|
||||
writes_by_ch_by_cid: dict[
|
||||
str, dict[str, list[tuple[str, bytes, str, int, str]]]
|
||||
] = {ch: {} for ch in channels}
|
||||
for cid, ch, task_id, idx, type_tag, value_blob, task_path in stage2_rows:
|
||||
writes_by_ch_by_cid.setdefault(ch, {}).setdefault(cid, []).append(
|
||||
(type_tag, value_blob, task_id, idx)
|
||||
(type_tag, value_blob, task_id, idx, task_path)
|
||||
)
|
||||
for cid_map in writes_by_ch_by_cid.values():
|
||||
for ws in cid_map.values():
|
||||
ws.sort(key=lambda w: (w[2], w[3]))
|
||||
ws.sort(key=lambda w: writes_sort_key(w[4], w[2], w[3]))
|
||||
|
||||
result: dict[str, DeltaChannelHistory] = {}
|
||||
for ch in channels:
|
||||
entry: DeltaChannelHistory = {"writes": []}
|
||||
|
||||
skip_seed_checkpoint_writes = False
|
||||
if ch in seed_value_by_ch:
|
||||
seed_value = seed_value_by_ch[ch]
|
||||
entry["seed"] = seed_value
|
||||
skip_seed_checkpoint_writes = not isinstance(seed_value, _DeltaSnapshot)
|
||||
|
||||
chain_cids = chain_by_ch.get(ch, [])
|
||||
cid_writes = writes_by_ch_by_cid.get(ch, {})
|
||||
if cid_writes:
|
||||
collected: list[PendingWrite] = []
|
||||
seed_cpid = seed_cpid_by_ch.get(ch)
|
||||
# Chain is newest→oldest; replay oldest→newest.
|
||||
for cid in reversed(list(chained_cpid_by_ch.get(ch, []))):
|
||||
if skip_seed_checkpoint_writes and cid == seed_cpid:
|
||||
continue
|
||||
for type_tag, value_blob, task_id, _idx in cid_writes.get(cid, []):
|
||||
collected.append(
|
||||
(task_id, ch, serde.loads_typed((type_tag, value_blob)))
|
||||
)
|
||||
entry["writes"] = collected
|
||||
collected: list[PendingWrite] = []
|
||||
# Chain is newest-first; iterate oldest-first for the public order.
|
||||
for cid in reversed(chain_cids):
|
||||
for type_tag, value_blob, task_id, _idx, _path in cid_writes.get(cid, []):
|
||||
collected.append(
|
||||
(task_id, ch, serde.loads_typed((type_tag, value_blob)))
|
||||
)
|
||||
entry: DeltaChannelHistory = {"writes": collected}
|
||||
if ch in seeded:
|
||||
entry["seed"] = seed_val_by_ch[ch]
|
||||
result[ch] = entry
|
||||
return result
|
||||
|
||||
@@ -25,14 +25,16 @@ from langgraph.checkpoint.base import (
|
||||
from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
|
||||
|
||||
from langgraph.checkpoint.sqlite._delta import (
|
||||
DELTA_WALK_SQL,
|
||||
DELTA_STAGE1_SQL,
|
||||
build_delta_channels_writes_history,
|
||||
build_delta_writes_fetch_sql,
|
||||
parse_supersteps_since_last_snapshot_by_channel,
|
||||
resolve_delta_chains,
|
||||
step_walk_supersteps,
|
||||
build_delta_stage2_sql,
|
||||
step_walk_with_row,
|
||||
)
|
||||
from langgraph.checkpoint.sqlite.utils import (
|
||||
load_pending_writes,
|
||||
pending_writes_sql,
|
||||
search_where,
|
||||
)
|
||||
from langgraph.checkpoint.sqlite.utils import search_where
|
||||
|
||||
T = TypeVar("T", bound=Callable)
|
||||
|
||||
@@ -116,6 +118,7 @@ class AsyncSqliteSaver(BaseCheckpointSaver[str]):
|
||||
|
||||
lock: asyncio.Lock
|
||||
is_setup: bool
|
||||
_has_task_path: bool = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -214,7 +217,7 @@ class AsyncSqliteSaver(BaseCheckpointSaver[str]):
|
||||
while True:
|
||||
try:
|
||||
yield asyncio.run_coroutine_threadsafe(
|
||||
anext(aiter_), # type: ignore[arg-type] # noqa: F821
|
||||
anext(aiter_), # type: ignore[arg-type]
|
||||
self.loop,
|
||||
).result()
|
||||
except StopAsyncIteration:
|
||||
@@ -333,6 +336,7 @@ class AsyncSqliteSaver(BaseCheckpointSaver[str]):
|
||||
checkpoint_ns TEXT NOT NULL DEFAULT '',
|
||||
checkpoint_id TEXT NOT NULL,
|
||||
task_id TEXT NOT NULL,
|
||||
task_path TEXT NOT NULL DEFAULT '',
|
||||
idx INTEGER NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
type TEXT,
|
||||
@@ -343,6 +347,21 @@ class AsyncSqliteSaver(BaseCheckpointSaver[str]):
|
||||
):
|
||||
await self.conn.commit()
|
||||
|
||||
# sqlite has no ADD COLUMN IF NOT EXISTS; this migrates databases
|
||||
# created before `task_path` existed and is a no-op on the rest.
|
||||
try:
|
||||
await self.conn.execute(
|
||||
"ALTER TABLE writes ADD COLUMN task_path TEXT NOT NULL DEFAULT ''"
|
||||
)
|
||||
await self.conn.commit()
|
||||
except aiosqlite.OperationalError as e:
|
||||
# A read-only database from before the column can still be read;
|
||||
# its rows would all read back as '' anyway.
|
||||
if "readonly database" in str(e):
|
||||
self._has_task_path = False
|
||||
elif "duplicate column name" not in str(e):
|
||||
raise
|
||||
|
||||
self.is_setup = True
|
||||
|
||||
async def aget_tuple(self, config: RunnableConfig) -> CheckpointTuple | None:
|
||||
@@ -397,7 +416,7 @@ class AsyncSqliteSaver(BaseCheckpointSaver[str]):
|
||||
}
|
||||
# find any pending writes
|
||||
await cur.execute(
|
||||
"SELECT task_id, channel, type, value FROM writes WHERE thread_id = ? AND checkpoint_ns = ? AND checkpoint_id = ? ORDER BY task_id, idx",
|
||||
pending_writes_sql(self._has_task_path),
|
||||
(
|
||||
str(config["configurable"]["thread_id"]),
|
||||
checkpoint_ns,
|
||||
@@ -423,10 +442,7 @@ class AsyncSqliteSaver(BaseCheckpointSaver[str]):
|
||||
if parent_checkpoint_id
|
||||
else None
|
||||
),
|
||||
[
|
||||
(task_id, channel, self.serde.loads_typed((type, value)))
|
||||
async for task_id, channel, type, value in cur
|
||||
],
|
||||
load_pending_writes(await cur.fetchall(), self.serde),
|
||||
)
|
||||
|
||||
async def alist(
|
||||
@@ -475,7 +491,7 @@ class AsyncSqliteSaver(BaseCheckpointSaver[str]):
|
||||
metadata,
|
||||
) in cur:
|
||||
await wcur.execute(
|
||||
"SELECT task_id, channel, type, value FROM writes WHERE thread_id = ? AND checkpoint_ns = ? AND checkpoint_id = ? ORDER BY task_id, idx",
|
||||
pending_writes_sql(self._has_task_path),
|
||||
(thread_id, checkpoint_ns, checkpoint_id),
|
||||
)
|
||||
yield CheckpointTuple(
|
||||
@@ -502,10 +518,7 @@ class AsyncSqliteSaver(BaseCheckpointSaver[str]):
|
||||
if parent_checkpoint_id
|
||||
else None
|
||||
),
|
||||
[
|
||||
(task_id, channel, self.serde.loads_typed((type, value)))
|
||||
async for task_id, channel, type, value in wcur
|
||||
],
|
||||
load_pending_writes(await wcur.fetchall(), self.serde),
|
||||
)
|
||||
|
||||
async def aput(
|
||||
@@ -578,9 +591,9 @@ class AsyncSqliteSaver(BaseCheckpointSaver[str]):
|
||||
task_path: Path of the task creating the writes.
|
||||
"""
|
||||
query = (
|
||||
"INSERT OR REPLACE INTO writes (thread_id, checkpoint_ns, checkpoint_id, task_id, idx, channel, type, value) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
"INSERT OR REPLACE INTO writes (thread_id, checkpoint_ns, checkpoint_id, task_id, task_path, idx, channel, type, value) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
if all(w[0] in WRITES_IDX_MAP for w in writes)
|
||||
else "INSERT OR IGNORE INTO writes (thread_id, checkpoint_ns, checkpoint_id, task_id, idx, channel, type, value) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
else "INSERT OR IGNORE INTO writes (thread_id, checkpoint_ns, checkpoint_id, task_id, task_path, idx, channel, type, value) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
)
|
||||
await self.setup()
|
||||
async with self.lock, self.conn.cursor() as cur:
|
||||
@@ -592,6 +605,7 @@ class AsyncSqliteSaver(BaseCheckpointSaver[str]):
|
||||
str(config["configurable"]["checkpoint_ns"]),
|
||||
str(config["configurable"]["checkpoint_id"]),
|
||||
task_id,
|
||||
task_path,
|
||||
WRITES_IDX_MAP.get(channel, idx),
|
||||
channel,
|
||||
*self.serde.dumps_typed(value),
|
||||
@@ -627,100 +641,64 @@ class AsyncSqliteSaver(BaseCheckpointSaver[str]):
|
||||
"""Fast-path override of `BaseCheckpointSaver.aget_delta_channel_history`.
|
||||
|
||||
See `SqliteSaver.get_delta_channel_history` for design notes; this
|
||||
is the async equivalent using `aiosqlite` cursors. The WALK streams
|
||||
the parent chain newest-first, bounded by the target's
|
||||
`counters_since_delta_snapshot` supersteps, and deserializes only
|
||||
seed checkpoints; FETCH pulls the relevant writes via per-channel
|
||||
UNION ALL.
|
||||
is the async equivalent using `aiosqlite` cursors. Stage 1 streams
|
||||
the parent chain from the target and Python-deserializes each
|
||||
checkpoint blob to find per-channel snapshots; stage 2 fetches
|
||||
only the relevant writes via per-channel UNION ALL.
|
||||
"""
|
||||
if not channels:
|
||||
return {}
|
||||
channels = list(channels)
|
||||
await self.setup()
|
||||
thread_id = str(config["configurable"]["thread_id"])
|
||||
if not thread_id:
|
||||
raise ValueError("empty thread ID")
|
||||
checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
|
||||
|
||||
# Resolve the target checkpoint id + its metadata (for supersteps).
|
||||
checkpoint_id = get_checkpoint_id(config)
|
||||
async with self.lock, self.conn.cursor() as cur:
|
||||
if checkpoint_id is None:
|
||||
await cur.execute(
|
||||
"SELECT checkpoint_id, metadata FROM checkpoints "
|
||||
"WHERE thread_id = ? AND checkpoint_ns = ? "
|
||||
"ORDER BY checkpoint_id DESC LIMIT 1",
|
||||
(thread_id, checkpoint_ns),
|
||||
)
|
||||
else:
|
||||
await cur.execute(
|
||||
"SELECT checkpoint_id, metadata FROM checkpoints "
|
||||
"WHERE thread_id = ? AND checkpoint_ns = ? "
|
||||
"AND checkpoint_id = ?",
|
||||
(thread_id, checkpoint_ns, checkpoint_id),
|
||||
)
|
||||
target_row = await cur.fetchone()
|
||||
if target_row is None:
|
||||
return {ch: {"writes": []} for ch in channels}
|
||||
target_id = str(target_row[0])
|
||||
metadata = json.loads(target_row[1]) if target_row[1] is not None else {}
|
||||
supersteps_by_ch = parse_supersteps_since_last_snapshot_by_channel(
|
||||
metadata, channels
|
||||
)
|
||||
max_supersteps = max(supersteps_by_ch.values(), default=0)
|
||||
needed_depths = set(supersteps_by_ch.values())
|
||||
if not checkpoint_id:
|
||||
target = await self.aget_tuple(config)
|
||||
if target is None:
|
||||
return {ch: {"writes": []} for ch in channels}
|
||||
checkpoint_id = target.config["configurable"]["checkpoint_id"]
|
||||
|
||||
shared_cpid_chain: list[str] = []
|
||||
chain_by_ch: dict[str, list[str]] = {ch: [] for ch in channels}
|
||||
seed_val_by_ch: dict[str, Any] = {}
|
||||
walk_state: dict[str, Any] = {}
|
||||
seed_values_by_depth: dict[int, dict[str, Any]] = {}
|
||||
seeded: set[str] = set()
|
||||
|
||||
async with self.lock, self.conn.cursor() as cur:
|
||||
if max_supersteps > 0:
|
||||
await cur.execute(DELTA_WALK_SQL, (thread_id, checkpoint_ns, target_id))
|
||||
async for row in cur:
|
||||
cid, parent_cid, type_tag, blob = row
|
||||
if step_walk_supersteps(
|
||||
cid=cid,
|
||||
parent_cid=parent_cid,
|
||||
type_tag=type_tag,
|
||||
blob=blob,
|
||||
target_id=target_id,
|
||||
serde=self.serde,
|
||||
shared_cpid_chain=shared_cpid_chain,
|
||||
walk_state=walk_state,
|
||||
max_supersteps=max_supersteps,
|
||||
needed_depths=needed_depths,
|
||||
seed_values_by_depth=seed_values_by_depth,
|
||||
channels=channels,
|
||||
):
|
||||
break
|
||||
|
||||
(
|
||||
chained_cpid_by_ch,
|
||||
seed_cpid_by_ch,
|
||||
seed_value_by_ch,
|
||||
) = resolve_delta_chains(
|
||||
channels=channels,
|
||||
supersteps_by_ch=supersteps_by_ch,
|
||||
shared_cpid_chain=shared_cpid_chain,
|
||||
seed_values_by_depth=seed_values_by_depth,
|
||||
has_reached_root=bool(walk_state.get("reached_root")),
|
||||
thread_id=thread_id,
|
||||
await cur.execute(
|
||||
DELTA_STAGE1_SQL,
|
||||
(thread_id, checkpoint_ns, checkpoint_id, thread_id, checkpoint_ns),
|
||||
)
|
||||
async for row in cur:
|
||||
cid, type_tag, blob = row
|
||||
if step_walk_with_row(
|
||||
cid=cid,
|
||||
type_tag=type_tag,
|
||||
blob=blob,
|
||||
target_id=checkpoint_id,
|
||||
serde=self.serde,
|
||||
chain_by_ch=chain_by_ch,
|
||||
seed_val_by_ch=seed_val_by_ch,
|
||||
walk_state=walk_state,
|
||||
seeded=seeded,
|
||||
channels=channels,
|
||||
):
|
||||
break
|
||||
|
||||
channels_with_chain = [ch for ch in channels if chained_cpid_by_ch[ch]]
|
||||
fetch_sql = build_delta_writes_fetch_sql(
|
||||
chain_lens=[len(chained_cpid_by_ch[ch]) for ch in channels_with_chain],
|
||||
channels_with_chain = [ch for ch in channels if chain_by_ch[ch]]
|
||||
stage2_sql = build_delta_stage2_sql(
|
||||
has_task_path=self._has_task_path,
|
||||
chain_lens=[len(chain_by_ch[ch]) for ch in channels_with_chain],
|
||||
)
|
||||
if fetch_sql:
|
||||
fetch_params: list[Any] = []
|
||||
if stage2_sql:
|
||||
stage2_params: list[Any] = []
|
||||
for ch in channels_with_chain:
|
||||
fetch_params.extend(
|
||||
[thread_id, checkpoint_ns, ch, *chained_cpid_by_ch[ch]]
|
||||
stage2_params.extend(
|
||||
[thread_id, checkpoint_ns, ch, *chain_by_ch[ch]]
|
||||
)
|
||||
await cur.execute(fetch_sql, fetch_params)
|
||||
await cur.execute(stage2_sql, stage2_params)
|
||||
stage2_rows = cast(
|
||||
"list[tuple[str, str, str, int, str, bytes]]",
|
||||
"list[tuple[str, str, str, int, str, bytes, str]]",
|
||||
await cur.fetchall(),
|
||||
)
|
||||
else:
|
||||
@@ -728,9 +706,9 @@ class AsyncSqliteSaver(BaseCheckpointSaver[str]):
|
||||
|
||||
return build_delta_channels_writes_history(
|
||||
channels=channels,
|
||||
chained_cpid_by_ch=chained_cpid_by_ch,
|
||||
seed_cpid_by_ch=seed_cpid_by_ch,
|
||||
seed_value_by_ch=seed_value_by_ch,
|
||||
chain_by_ch=chain_by_ch,
|
||||
seed_val_by_ch=seed_val_by_ch,
|
||||
seeded=seeded,
|
||||
stage2_rows=stage2_rows,
|
||||
serde=self.serde,
|
||||
)
|
||||
|
||||
@@ -2,11 +2,12 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Sequence
|
||||
from collections.abc import Iterable, Sequence
|
||||
from typing import Any
|
||||
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from langgraph.checkpoint.base import get_checkpoint_id
|
||||
from langgraph.checkpoint.base import PendingWrite, get_checkpoint_id, writes_sort_key
|
||||
from langgraph.checkpoint.serde.base import SerializerProtocol
|
||||
|
||||
_FILTER_PATTERN = re.compile(r"^[a-zA-Z0-9_.-]+$")
|
||||
|
||||
@@ -114,3 +115,23 @@ def search_where(
|
||||
param_values.append(get_checkpoint_id(before))
|
||||
|
||||
return ("WHERE " + " AND ".join(wheres) if wheres else "", param_values)
|
||||
|
||||
|
||||
def pending_writes_sql(has_task_path: bool) -> str:
|
||||
task_path = "task_path" if has_task_path else "''"
|
||||
return (
|
||||
f"SELECT task_id, channel, type, value, {task_path}, idx FROM writes "
|
||||
"WHERE thread_id = ? AND checkpoint_ns = ? AND checkpoint_id = ?"
|
||||
)
|
||||
|
||||
|
||||
def load_pending_writes(
|
||||
rows: Iterable[Any], serde: SerializerProtocol
|
||||
) -> list[PendingWrite]:
|
||||
"""Deserialize `pending_writes_sql` rows in `writes_sort_key` order."""
|
||||
return [
|
||||
(task_id, channel, serde.loads_typed((type_, value)))
|
||||
for task_id, channel, type_, value, _, _ in sorted(
|
||||
rows, key=lambda r: writes_sort_key(r[4], r[0], r[5])
|
||||
)
|
||||
]
|
||||
|
||||
@@ -24,11 +24,13 @@ from langgraph.store.base.batch import AsyncBatchedBaseStore
|
||||
|
||||
from langgraph.store.sqlite.base import (
|
||||
_PLACEHOLDER,
|
||||
NS_MATCH_FUNCTION,
|
||||
BaseSqliteStore,
|
||||
SqliteIndexConfig,
|
||||
_decode_ns_text,
|
||||
_ensure_index_config,
|
||||
_group_ops,
|
||||
_namespace_match,
|
||||
_row_to_item,
|
||||
_row_to_search_item,
|
||||
)
|
||||
@@ -150,6 +152,13 @@ class AsyncSqliteStore(AsyncBatchedBaseStore, BaseSqliteStore):
|
||||
if self.is_setup:
|
||||
return
|
||||
|
||||
# list_namespaces needs segment-aware matching, which SQLite cannot
|
||||
# express in LIKE or GLOB. Registered here rather than in __init__
|
||||
# because aiosqlite's create_function is a coroutine.
|
||||
await self.conn.create_function(
|
||||
NS_MATCH_FUNCTION, 2, _namespace_match, deterministic=True
|
||||
)
|
||||
|
||||
# Create migrations table if it doesn't exist
|
||||
await self.conn.execute(
|
||||
"""
|
||||
@@ -538,7 +547,7 @@ class AsyncSqliteStore(AsyncBatchedBaseStore, BaseSqliteStore):
|
||||
await cur.execute(query, params)
|
||||
rows = await cur.fetchall()
|
||||
|
||||
if needs_refresh and rows and self.ttl_config:
|
||||
if needs_refresh and self.ttl_config:
|
||||
keys_to_refresh = []
|
||||
for row_data in rows:
|
||||
# Assuming row_data[0] is prefix (text), row_data[1] is key (text)
|
||||
|
||||
@@ -7,7 +7,7 @@ import re
|
||||
import sqlite3
|
||||
import threading
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable, Iterable, Iterator, Sequence
|
||||
from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence
|
||||
from contextlib import contextmanager
|
||||
from typing import Any, Literal, NamedTuple, cast
|
||||
|
||||
@@ -28,6 +28,7 @@ from langgraph.store.base import (
|
||||
ensure_embeddings,
|
||||
get_text_at_path,
|
||||
tokenize_path,
|
||||
validate_op_namespace,
|
||||
)
|
||||
|
||||
_AIO_ERROR_MSG = (
|
||||
@@ -93,12 +94,74 @@ class SqliteIndexConfig(IndexConfig):
|
||||
pass
|
||||
|
||||
|
||||
def _namespace_to_text(
|
||||
namespace: tuple[str, ...], handle_wildcards: bool = False
|
||||
) -> str:
|
||||
NS_MATCH_FUNCTION = "_langgraph_namespace_match"
|
||||
"""SQLite user function backing segment-aware namespace matching.
|
||||
|
||||
Registered under a private name rather than overriding `REGEXP`, so a caller's
|
||||
own `REGEXP` is left untouched.
|
||||
"""
|
||||
|
||||
|
||||
def _namespace_match(prefix: str | None, pattern: str) -> int:
|
||||
"""Backing implementation of `NS_MATCH_FUNCTION`."""
|
||||
if prefix is None:
|
||||
return 0
|
||||
return 1 if re.search(pattern, prefix) else 0
|
||||
|
||||
|
||||
def _escape_glob_literal(text: str) -> str:
|
||||
"""Escape GLOB metacharacters so `text` is matched literally.
|
||||
|
||||
GLOB has no `ESCAPE` clause, so metacharacters are wrapped in a character
|
||||
class instead. `]` is literal outside a class and needs no escaping.
|
||||
"""
|
||||
return text.replace("[", "[[]").replace("*", "[*]").replace("?", "[?]")
|
||||
|
||||
|
||||
def _namespace_prefix_condition(
|
||||
namespace_prefix: tuple[str, ...], column: str = "prefix"
|
||||
) -> tuple[str, tuple[str, ...]]:
|
||||
"""Build the SQL scoping a search to a namespace and its descendants.
|
||||
|
||||
Matches the namespace exactly or requires the `.` separator before any
|
||||
remainder, so a prefix of `("foo",)` does not also match `("foobar",)`.
|
||||
|
||||
Uses GLOB rather than LIKE because SQLite's LIKE is case-insensitive for
|
||||
ASCII, which would match `("FOO",)` for a prefix of `("foo",)` even though
|
||||
`get`/`put`/`delete` compare with `=` and treat those as distinct.
|
||||
|
||||
An empty prefix is unconstrained and matches every namespace.
|
||||
"""
|
||||
if not namespace_prefix:
|
||||
return "TRUE", ()
|
||||
path = _namespace_to_text(namespace_prefix)
|
||||
condition = f"({column} = ? OR {column} GLOB ?)"
|
||||
return condition, (path, f"{_escape_glob_literal(path)}.*")
|
||||
|
||||
|
||||
def _namespace_match_pattern(path: tuple[str, ...], match_type: str) -> str:
|
||||
"""Build a regex matching the dot-joined prefix on whole namespace segments.
|
||||
|
||||
Needed because neither LIKE nor GLOB can express "any character except the
|
||||
separator": GLOB has character classes but no quantifier, so `[^.]*` still
|
||||
crosses `.`. Matches how `InMemoryStore` compares namespaces element-wise.
|
||||
|
||||
`*` matches exactly one segment. Prefix matches stay open-ended but must end
|
||||
on a separator; suffix matches anchor at the end and begin on one.
|
||||
|
||||
Examples:
|
||||
prefix ("uid", "*", "alice") -> ^uid\\.[^.]+\\.alice(\\.|\\Z)
|
||||
suffix ("alice",) -> (^|\\.)alice\\Z
|
||||
"""
|
||||
segments = ("[^.]+" if part == "*" else re.escape(part) for part in path)
|
||||
body = r"\.".join(segments)
|
||||
if match_type == "suffix":
|
||||
return rf"(^|\.){body}\Z"
|
||||
return rf"^{body}(\.|\Z)"
|
||||
|
||||
|
||||
def _namespace_to_text(namespace: tuple[str, ...]) -> str:
|
||||
"""Convert namespace tuple to text string."""
|
||||
if handle_wildcards:
|
||||
namespace = tuple("%" if val == "*" else val for val in namespace)
|
||||
return ".".join(namespace)
|
||||
|
||||
|
||||
@@ -195,6 +258,7 @@ def _group_ops(ops: Iterable[Op]) -> tuple[dict[type, list[tuple[int, Op]]], int
|
||||
grouped_ops: dict[type, list[tuple[int, Op]]] = defaultdict(list)
|
||||
tot = 0
|
||||
for idx, op in enumerate(ops):
|
||||
validate_op_namespace(op)
|
||||
grouped_ops[type(op)].append((idx, op))
|
||||
tot += 1
|
||||
return grouped_ops, tot
|
||||
@@ -325,7 +389,7 @@ class BaseSqliteStore:
|
||||
[
|
||||
_namespace_to_text(op.namespace),
|
||||
op.key,
|
||||
orjson.dumps(cast(dict, op.value)),
|
||||
orjson.dumps(dict(cast(Mapping[str, Any], op.value))),
|
||||
expires_at,
|
||||
op.ttl,
|
||||
]
|
||||
@@ -461,8 +525,11 @@ class BaseSqliteStore:
|
||||
else " AND " + " AND ".join(filter_conditions)
|
||||
)
|
||||
if op.namespace_prefix:
|
||||
prefix_filter_str = f"WHERE s.prefix LIKE ? {filter_str} "
|
||||
ns_args: Sequence = (f"{_namespace_to_text(op.namespace_prefix)}%",)
|
||||
ns_condition, ns_args_tuple = _namespace_prefix_condition(
|
||||
op.namespace_prefix, column="s.prefix"
|
||||
)
|
||||
prefix_filter_str = f"WHERE {ns_condition} {filter_str} "
|
||||
ns_args: Sequence = ns_args_tuple
|
||||
else:
|
||||
ns_args = ()
|
||||
if filter_str:
|
||||
@@ -503,12 +570,15 @@ class BaseSqliteStore:
|
||||
]
|
||||
# Regular search branch (no vector search)
|
||||
else:
|
||||
base_query = """
|
||||
ns_condition, ns_args_tuple = _namespace_prefix_condition(
|
||||
op.namespace_prefix
|
||||
)
|
||||
base_query = f"""
|
||||
SELECT prefix, key, value, created_at, updated_at, expires_at, ttl_minutes, NULL as score
|
||||
FROM store
|
||||
WHERE prefix LIKE ?
|
||||
WHERE {ns_condition}
|
||||
"""
|
||||
params = [f"{_namespace_to_text(op.namespace_prefix)}%"]
|
||||
params = list(ns_args_tuple)
|
||||
|
||||
if filter_conditions:
|
||||
params.extend(filter_params)
|
||||
@@ -549,16 +619,28 @@ class BaseSqliteStore:
|
||||
|
||||
if op.match_conditions:
|
||||
for cond in op.match_conditions:
|
||||
if cond.match_type == "prefix":
|
||||
where_clauses.append("prefix LIKE ?")
|
||||
params.append(
|
||||
f"{_namespace_to_text(cond.path, handle_wildcards=True)}%"
|
||||
)
|
||||
elif cond.match_type == "suffix":
|
||||
where_clauses.append("prefix LIKE ?")
|
||||
params.append(
|
||||
f"%{_namespace_to_text(cond.path, handle_wildcards=True)}"
|
||||
)
|
||||
if cond.match_type in ("prefix", "suffix"):
|
||||
if not cond.path:
|
||||
# An empty path constrains nothing; skipping keeps it a
|
||||
# no-op rather than emitting a pattern that matches no
|
||||
# namespace at all.
|
||||
continue
|
||||
if cond.match_type == "prefix" and "*" not in cond.path:
|
||||
# Equivalent to the anchored pattern, but SQLite can
|
||||
# satisfy `=` and a trailing-wildcard GLOB from
|
||||
# store_prefix_idx. The user function is opaque to the
|
||||
# planner, so it would scan every row and call back
|
||||
# into Python for each one.
|
||||
condition, args = _namespace_prefix_condition(
|
||||
tuple(cond.path)
|
||||
)
|
||||
where_clauses.append(condition)
|
||||
params.extend(args)
|
||||
else:
|
||||
where_clauses.append(f"{NS_MATCH_FUNCTION}(prefix, ?) = 1")
|
||||
params.append(
|
||||
_namespace_match_pattern(cond.path, cond.match_type)
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Unknown match_type in list_namespaces: %s", cond.match_type
|
||||
@@ -785,6 +867,9 @@ class SqliteStore(BaseSqliteStore, BaseStore):
|
||||
super().__init__()
|
||||
self._deserializer = deserializer
|
||||
self.conn = conn
|
||||
# Registered here rather than in from_conn_string so a caller-supplied
|
||||
# connection also gets it.
|
||||
conn.create_function(NS_MATCH_FUNCTION, 2, _namespace_match, deterministic=True)
|
||||
self.lock = threading.Lock()
|
||||
self.is_setup = False
|
||||
self.index_config = index
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "langgraph-checkpoint-sqlite"
|
||||
version = "3.1.0"
|
||||
version = "3.1.2"
|
||||
description = "Library with a SQLite implementation of LangGraph checkpoint saver."
|
||||
authors = []
|
||||
requires-python = ">=3.10"
|
||||
@@ -12,7 +12,7 @@ readme = "README.md"
|
||||
license = "MIT"
|
||||
license-files = ['LICENSE']
|
||||
dependencies = [
|
||||
"langgraph-checkpoint>=4.1.0,<5.0.0",
|
||||
"langgraph-checkpoint>=4.3.0,<5.0.0",
|
||||
"aiosqlite>=0.20",
|
||||
"sqlite-vec>=0.1.6",
|
||||
]
|
||||
@@ -30,6 +30,7 @@ test = [
|
||||
"pytest-mock",
|
||||
"pytest-watcher",
|
||||
"langgraph-checkpoint",
|
||||
"langgraph-checkpoint-conformance",
|
||||
"pytest-retry>=1.7.0",
|
||||
]
|
||||
lint = [
|
||||
@@ -47,6 +48,7 @@ default-groups = ['dev']
|
||||
|
||||
[tool.uv.sources]
|
||||
langgraph-checkpoint = { path = "../checkpoint", editable = true }
|
||||
langgraph-checkpoint-conformance = { path = "../checkpoint-conformance", editable = true }
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
include = ["langgraph"]
|
||||
@@ -62,6 +64,8 @@ lint.select = [
|
||||
"UP", # pyupgrade
|
||||
"B", # flake8-bugbear
|
||||
"I", # isort
|
||||
"PLC0415", # import-outside-top-level
|
||||
"RUF100", # unused noqa directive
|
||||
"UP", # pyupgrade
|
||||
]
|
||||
lint.ignore = ["E501", "B008"]
|
||||
|
||||
@@ -9,8 +9,10 @@ from typing import cast
|
||||
import pytest
|
||||
from langgraph.store.base import (
|
||||
GetOp,
|
||||
InvalidNamespaceError,
|
||||
Item,
|
||||
ListNamespacesOp,
|
||||
MatchCondition,
|
||||
PutOp,
|
||||
SearchOp,
|
||||
)
|
||||
@@ -716,3 +718,94 @@ async def test_search_items(
|
||||
for ns in test_namespaces:
|
||||
key = f"item_{ns[-1]}"
|
||||
await store.adelete(ns, key)
|
||||
|
||||
|
||||
async def test_async_namespace_segment_boundary(store: AsyncSqliteStore) -> None:
|
||||
"""Segment-aware scoping on the async path.
|
||||
|
||||
Also covers that the namespace-match SQLite function is registered on the
|
||||
async connection -- aiosqlite's create_function is a coroutine, so it is
|
||||
registered in setup() rather than __init__.
|
||||
"""
|
||||
for namespace in [
|
||||
("foo",),
|
||||
("foo", "child"),
|
||||
("foobar",),
|
||||
("uid", "users", "alice"),
|
||||
("uid", "users", "malice"),
|
||||
("user_1",),
|
||||
("userX1",),
|
||||
]:
|
||||
await store.aput(namespace, "k", {"v": 1})
|
||||
|
||||
found = {item.namespace for item in await store.asearch(("foo",), limit=100)}
|
||||
assert found == {("foo",), ("foo", "child")}
|
||||
|
||||
found = {item.namespace for item in await store.asearch(("user_1",), limit=100)}
|
||||
assert found == {("user_1",)}
|
||||
|
||||
assert set(await store.alist_namespaces(suffix=["alice"], limit=100)) == {
|
||||
("uid", "users", "alice"),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("namespace", [("foo.bar",), ("foo", ""), ("foo", 1)])
|
||||
async def test_abatch_rejects_invalid_namespace_labels(
|
||||
store: AsyncSqliteStore, namespace: tuple
|
||||
) -> None:
|
||||
await store.aput(("foo", "bar"), "key", {"original": True})
|
||||
|
||||
for op in (
|
||||
GetOp(namespace, "key"),
|
||||
GetOp(namespace, "key", refresh_ttl=True),
|
||||
PutOp(namespace, "key", {"changed": True}),
|
||||
PutOp(namespace, "key", None),
|
||||
SearchOp(namespace),
|
||||
ListNamespacesOp((MatchCondition("prefix", namespace),)),
|
||||
ListNamespacesOp((MatchCondition("suffix", namespace),)),
|
||||
):
|
||||
with pytest.raises(InvalidNamespaceError):
|
||||
await store.abatch([op])
|
||||
|
||||
item = await store.aget(("foo", "bar"), "key")
|
||||
assert item is not None and item.value == {"original": True}
|
||||
|
||||
|
||||
async def test_invalid_namespace_only_fails_its_own_call(
|
||||
store: AsyncSqliteStore,
|
||||
) -> None:
|
||||
"""Concurrent calls share one `abatch`, which fails every op if it raises.
|
||||
|
||||
Labels are checked before an op is queued, so one caller's bad label cannot
|
||||
fail another caller's request.
|
||||
"""
|
||||
await store.aput(("foo", "bar"), "key", {"original": True})
|
||||
|
||||
valid, invalid = await asyncio.gather(
|
||||
store.aget(("foo", "bar"), "key"),
|
||||
store.aget(("foo.bar",), "key"),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
assert isinstance(valid, Item) and valid.value == {"original": True}
|
||||
assert isinstance(invalid, InvalidNamespaceError)
|
||||
|
||||
|
||||
async def test_sync_methods_reject_invalid_namespace_labels(
|
||||
store: AsyncSqliteStore,
|
||||
) -> None:
|
||||
"""The sync wrappers run off the event loop thread and must validate too."""
|
||||
await store.aput(("foo", "bar"), "key", {"original": True})
|
||||
|
||||
for call in (
|
||||
lambda: store.get(("foo.bar",), "key"),
|
||||
lambda: store.search(("foo.bar",)),
|
||||
lambda: store.delete(("foo.bar",), "key"),
|
||||
lambda: store.list_namespaces(prefix=("foo.bar",)),
|
||||
lambda: store.batch([GetOp(("foo.bar",), "key")]),
|
||||
):
|
||||
with pytest.raises(InvalidNamespaceError):
|
||||
await asyncio.to_thread(call)
|
||||
|
||||
item = await store.aget(("foo", "bar"), "key")
|
||||
assert item is not None and item.value == {"original": True}
|
||||
|
||||
@@ -3,21 +3,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from langgraph.checkpoint.conformance import validate
|
||||
from langgraph.checkpoint.conformance.initializer import checkpointer_test
|
||||
|
||||
pytest.importorskip(
|
||||
"langgraph.checkpoint.conformance",
|
||||
reason="langgraph-checkpoint-conformance not installed",
|
||||
)
|
||||
pytest.importorskip("aiosqlite", reason="aiosqlite not installed")
|
||||
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delta_channel_conformance():
|
||||
from langgraph.checkpoint.conformance import validate
|
||||
from langgraph.checkpoint.conformance.initializer import checkpointer_test
|
||||
|
||||
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
|
||||
|
||||
@checkpointer_test(name="AsyncSqliteSaver")
|
||||
async def sqlite_saver():
|
||||
async with AsyncSqliteSaver.from_conn_string(":memory:") as saver:
|
||||
|
||||
@@ -29,13 +29,13 @@ pytest.importorskip("langgraph.channels.delta", reason="langgraph core not insta
|
||||
pytest.importorskip("langgraph.channels.binop", reason="langgraph core not installed")
|
||||
pytest.importorskip("langgraph.graph", reason="langgraph core not installed")
|
||||
|
||||
from langgraph.channels.binop import BinaryOperatorAggregate # type: ignore[import-untyped] # noqa: E402,I001
|
||||
from langgraph.channels.delta import DeltaChannel # type: ignore[import-untyped] # noqa: E402
|
||||
from langgraph.graph import END, START, StateGraph # type: ignore[import-untyped] # noqa: E402
|
||||
from typing_extensions import TypedDict # noqa: E402
|
||||
from langgraph.channels.binop import BinaryOperatorAggregate # type: ignore[import-untyped] # noqa: I001
|
||||
from langgraph.channels.delta import DeltaChannel # type: ignore[import-untyped]
|
||||
from langgraph.graph import END, START, StateGraph # type: ignore[import-untyped]
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from langgraph.checkpoint.sqlite import SqliteSaver # noqa: E402
|
||||
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver # noqa: E402
|
||||
from langgraph.checkpoint.sqlite import SqliteSaver
|
||||
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from langgraph.checkpoint.base import (
|
||||
BaseCheckpointSaver,
|
||||
Checkpoint,
|
||||
DeltaChannelHistory,
|
||||
empty_checkpoint,
|
||||
)
|
||||
|
||||
from langgraph.checkpoint.sqlite import SqliteSaver
|
||||
from langgraph.checkpoint.sqlite._delta import DELTA_STAGE1_SQL
|
||||
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
|
||||
|
||||
CHANNEL = "ch"
|
||||
CONFIG: dict[str, Any] = {"configurable": {"thread_id": "t", "checkpoint_ns": ""}}
|
||||
EXPECTED: DeltaChannelHistory = {
|
||||
"writes": [("task", CHANNEL, "write-root")],
|
||||
"seed": "seed",
|
||||
}
|
||||
|
||||
|
||||
def _checkpoint(checkpoint_id: str, values: dict[str, Any]) -> Checkpoint:
|
||||
value = empty_checkpoint()
|
||||
value["id"] = checkpoint_id
|
||||
value["channel_values"] = values
|
||||
return value
|
||||
|
||||
|
||||
PARENT_ID_ORDERS = [
|
||||
pytest.param("z-older", "a-newer", id="parent_id_sorts_above_child"),
|
||||
pytest.param("a-older", "z-newer", id="parent_id_sorts_below_child"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("root_id", "child_id"), PARENT_ID_ORDERS)
|
||||
def test_sync_walk_reaches_parent_whatever_the_id_order(
|
||||
root_id: str, child_id: str
|
||||
) -> None:
|
||||
with SqliteSaver.from_conn_string(":memory:") as saver:
|
||||
root = saver.put(CONFIG, _checkpoint(root_id, {CHANNEL: "seed"}), {}, {})
|
||||
saver.put_writes(root, [(CHANNEL, "write-root")], "task")
|
||||
child = saver.put(root, _checkpoint(child_id, {}), {}, {})
|
||||
|
||||
got = saver.get_delta_channel_history(config=child, channels=[CHANNEL])
|
||||
reference = BaseCheckpointSaver.get_delta_channel_history(
|
||||
saver, config=child, channels=[CHANNEL]
|
||||
)
|
||||
assert got[CHANNEL] == EXPECTED
|
||||
assert got[CHANNEL] == reference[CHANNEL], "fast path disagrees with base"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("root_id", "child_id"), PARENT_ID_ORDERS)
|
||||
async def test_async_walk_reaches_parent_whatever_the_id_order(
|
||||
root_id: str, child_id: str
|
||||
) -> None:
|
||||
async with AsyncSqliteSaver.from_conn_string(":memory:") as saver:
|
||||
root = await saver.aput(CONFIG, _checkpoint(root_id, {CHANNEL: "seed"}), {}, {})
|
||||
await saver.aput_writes(root, [(CHANNEL, "write-root")], "task")
|
||||
child = await saver.aput(root, _checkpoint(child_id, {}), {}, {})
|
||||
|
||||
got = await saver.aget_delta_channel_history(config=child, channels=[CHANNEL])
|
||||
assert got[CHANNEL] == EXPECTED
|
||||
|
||||
|
||||
EMPTY_CHECKPOINT_ID: dict[str, Any] = {
|
||||
"configurable": {**CONFIG["configurable"], "checkpoint_id": ""}
|
||||
}
|
||||
|
||||
|
||||
def test_sync_empty_checkpoint_id_reads_from_the_latest_checkpoint() -> None:
|
||||
with SqliteSaver.from_conn_string(":memory:") as saver:
|
||||
root = saver.put(CONFIG, _checkpoint("a-older", {CHANNEL: "seed"}), {}, {})
|
||||
saver.put_writes(root, [(CHANNEL, "write-root")], "task")
|
||||
saver.put(root, _checkpoint("z-newer", {}), {}, {})
|
||||
|
||||
got = saver.get_delta_channel_history(
|
||||
config=EMPTY_CHECKPOINT_ID, channels=[CHANNEL]
|
||||
)
|
||||
|
||||
assert got[CHANNEL] == EXPECTED
|
||||
|
||||
|
||||
async def test_async_empty_checkpoint_id_reads_from_the_latest_checkpoint() -> None:
|
||||
async with AsyncSqliteSaver.from_conn_string(":memory:") as saver:
|
||||
root = await saver.aput(
|
||||
CONFIG, _checkpoint("a-older", {CHANNEL: "seed"}), {}, {}
|
||||
)
|
||||
await saver.aput_writes(root, [(CHANNEL, "write-root")], "task")
|
||||
await saver.aput(root, _checkpoint("z-newer", {}), {}, {})
|
||||
|
||||
got = await saver.aget_delta_channel_history(
|
||||
config=EMPTY_CHECKPOINT_ID, channels=[CHANNEL]
|
||||
)
|
||||
|
||||
assert got[CHANNEL] == EXPECTED
|
||||
|
||||
|
||||
def test_walk_reaches_root_of_long_chain_with_descending_ids() -> None:
|
||||
steps = 40
|
||||
with SqliteSaver.from_conn_string(":memory:") as saver:
|
||||
parent = saver.put(
|
||||
CONFIG, _checkpoint(f"id-{steps:03d}", {CHANNEL: "seed"}), {}, {}
|
||||
)
|
||||
saver.put_writes(parent, [(CHANNEL, "write-root")], "task")
|
||||
for step in range(steps - 1, 0, -1):
|
||||
parent = saver.put(parent, _checkpoint(f"id-{step:03d}", {}), {}, {})
|
||||
|
||||
got = saver.get_delta_channel_history(config=parent, channels=[CHANNEL])
|
||||
assert got[CHANNEL] == EXPECTED
|
||||
|
||||
|
||||
def test_walk_terminates_when_put_makes_the_parent_chain_cycle() -> None:
|
||||
with SqliteSaver.from_conn_string(":memory:") as saver:
|
||||
a = saver.put(CONFIG, _checkpoint("cid-a", {}), {}, {})
|
||||
b = saver.put(a, _checkpoint("cid-b", {}), {}, {})
|
||||
repoint_a_under_b = _checkpoint("cid-a", {})
|
||||
saver.put(b, repoint_a_under_b, {}, {})
|
||||
|
||||
got = saver.get_delta_channel_history(config=b, channels=[CHANNEL])
|
||||
assert got[CHANNEL] == {"writes": []}
|
||||
|
||||
|
||||
def test_walk_step_looks_up_the_parent_by_primary_key() -> None:
|
||||
with SqliteSaver.from_conn_string(":memory:") as saver:
|
||||
saver.setup()
|
||||
plan = [
|
||||
row[3]
|
||||
for row in saver.conn.execute(
|
||||
f"EXPLAIN QUERY PLAN {DELTA_STAGE1_SQL}", ("t", "", "id", "t", "")
|
||||
)
|
||||
]
|
||||
assert any(
|
||||
step.startswith("SEARCH c ") and "checkpoint_id=?" in step for step in plan
|
||||
), f"recursive step should look up the parent by key, got {plan}"
|
||||
@@ -32,13 +32,13 @@ from langchain_core.runnables import RunnableConfig
|
||||
pytest.importorskip("langgraph.channels.delta", reason="langgraph core not installed")
|
||||
pytest.importorskip("langgraph.graph", reason="langgraph core not installed")
|
||||
|
||||
from langgraph.channels.delta import DeltaChannel # type: ignore[import-untyped] # noqa: E402,I001
|
||||
from langgraph.checkpoint.serde.types import _DeltaSnapshot # noqa: E402
|
||||
from langgraph.graph import END, START, StateGraph # type: ignore[import-untyped] # noqa: E402
|
||||
from typing_extensions import TypedDict # noqa: E402
|
||||
from langgraph.channels.delta import DeltaChannel # type: ignore[import-untyped] # noqa: I001
|
||||
from langgraph.checkpoint.serde.types import _DeltaSnapshot
|
||||
from langgraph.graph import END, START, StateGraph # type: ignore[import-untyped]
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from langgraph.checkpoint.sqlite import SqliteSaver # noqa: E402
|
||||
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver # noqa: E402
|
||||
from langgraph.checkpoint.sqlite import SqliteSaver
|
||||
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import math
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
from collections import Counter, defaultdict
|
||||
from collections.abc import Generator, Iterable
|
||||
from contextlib import contextmanager
|
||||
from typing import Any, Literal, cast
|
||||
@@ -10,6 +14,7 @@ import pytest
|
||||
from langchain_core.embeddings import Embeddings
|
||||
from langgraph.store.base import (
|
||||
GetOp,
|
||||
InvalidNamespaceError,
|
||||
Item,
|
||||
ListNamespacesOp,
|
||||
MatchCondition,
|
||||
@@ -18,7 +23,13 @@ from langgraph.store.base import (
|
||||
)
|
||||
|
||||
from langgraph.store.sqlite import SqliteStore
|
||||
from langgraph.store.sqlite.base import SqliteIndexConfig
|
||||
from langgraph.store.sqlite.base import (
|
||||
NS_MATCH_FUNCTION,
|
||||
BaseSqliteStore,
|
||||
SqliteIndexConfig,
|
||||
_escape_glob_literal,
|
||||
_namespace_match_pattern,
|
||||
)
|
||||
|
||||
|
||||
# Local embeddings implementation for testing vector search
|
||||
@@ -27,10 +38,6 @@ class CharacterEmbeddings(Embeddings):
|
||||
|
||||
def __init__(self, dims: int = 50, seed: int = 42):
|
||||
"""Initialize with embedding dimensions and random seed."""
|
||||
import math
|
||||
import random
|
||||
from collections import defaultdict
|
||||
|
||||
self._rng = random.Random(seed)
|
||||
self.dims = dims
|
||||
# Create projection vector for each character lazily
|
||||
@@ -42,9 +49,6 @@ class CharacterEmbeddings(Embeddings):
|
||||
|
||||
def _embed_one(self, text: str) -> list[float]:
|
||||
"""Embed a single text."""
|
||||
import math
|
||||
from collections import Counter
|
||||
|
||||
counts = Counter(text)
|
||||
total = sum(counts.values())
|
||||
|
||||
@@ -332,8 +336,6 @@ class TestSqliteStore:
|
||||
|
||||
# Test update
|
||||
# Small delay to ensure the updated timestamp is different
|
||||
import time
|
||||
|
||||
time.sleep(0.01)
|
||||
|
||||
updated_value = {"title": "Updated Document", "content": "Hello, Updated!"}
|
||||
@@ -1229,3 +1231,256 @@ def test_non_ascii(
|
||||
assert result3[0].key == "3"
|
||||
assert result4[0].key == "4"
|
||||
assert result5[0].key == "5"
|
||||
|
||||
|
||||
def test_escape_glob_literal() -> None:
|
||||
assert _escape_glob_literal("users.alice") == "users.alice"
|
||||
# "_" and "%" are LIKE wildcards but literal in GLOB, so they are left alone.
|
||||
assert _escape_glob_literal("user_1") == "user_1"
|
||||
assert _escape_glob_literal("100%") == "100%"
|
||||
assert _escape_glob_literal("a*b") == "a[*]b"
|
||||
assert _escape_glob_literal("a?b") == "a[?]b"
|
||||
assert _escape_glob_literal("a[b") == "a[[]b"
|
||||
|
||||
|
||||
def test_namespace_match_pattern() -> None:
|
||||
assert _namespace_match_pattern(("foo",), "prefix") == r"^foo(\.|\Z)"
|
||||
assert (
|
||||
_namespace_match_pattern(("uid", "*", "alice"), "prefix")
|
||||
== r"^uid\.[^.]+\.alice(\.|\Z)"
|
||||
)
|
||||
assert _namespace_match_pattern(("alice",), "suffix") == r"(^|\.)alice\Z"
|
||||
|
||||
|
||||
def test_search_namespace_segment_boundary(store: SqliteStore) -> None:
|
||||
"""Prefix scoping must stop at namespace segment boundaries.
|
||||
|
||||
Namespaces are stored dot-joined, so matching the raw text also returns
|
||||
siblings sharing leading characters.
|
||||
"""
|
||||
for namespace in [
|
||||
("foo",),
|
||||
("foo", "child"),
|
||||
("foo", "child", "deep"),
|
||||
("foobar",),
|
||||
("foobar", "baz"),
|
||||
("foo2",),
|
||||
]:
|
||||
store.put(namespace, "k", {"v": 1})
|
||||
|
||||
def _namespaces(prefix: tuple[str, ...]) -> set[tuple[str, ...]]:
|
||||
return {item.namespace for item in store.search(prefix, limit=100)}
|
||||
|
||||
assert _namespaces(("foo",)) == {
|
||||
("foo",),
|
||||
("foo", "child"),
|
||||
("foo", "child", "deep"),
|
||||
}
|
||||
# The sibling scope is independent, not merely narrower.
|
||||
assert _namespaces(("foobar",)) == {("foobar",), ("foobar", "baz")}
|
||||
assert _namespaces(("foo2",)) == {("foo2",)}
|
||||
assert _namespaces(("fo",)) == set()
|
||||
|
||||
|
||||
def test_search_namespace_wildcard_chars_are_literal(store: SqliteStore) -> None:
|
||||
"""LIKE and GLOB metacharacters in labels must be matched literally."""
|
||||
for namespace in [
|
||||
("user_1",),
|
||||
("user_1", "child"),
|
||||
("userX1",),
|
||||
("a%b",),
|
||||
("axxb",),
|
||||
("star*",),
|
||||
("starX",),
|
||||
]:
|
||||
store.put(namespace, "k", {"v": 1})
|
||||
|
||||
def _namespaces(prefix: tuple[str, ...]) -> set[tuple[str, ...]]:
|
||||
return {item.namespace for item in store.search(prefix, limit=100)}
|
||||
|
||||
# Also asserts each namespace still matches itself, which catches escaping
|
||||
# the equality arm by mistake.
|
||||
assert _namespaces(("user_1",)) == {("user_1",), ("user_1", "child")}
|
||||
assert _namespaces(("a%b",)) == {("a%b",)}
|
||||
assert _namespaces(("star*",)) == {("star*",)}
|
||||
|
||||
|
||||
def test_search_namespace_is_case_sensitive(store: SqliteStore) -> None:
|
||||
"""Search must agree with get/put, which compare namespaces with `=`.
|
||||
|
||||
SQLite's LIKE is case-insensitive for ASCII, so matching with it conflated
|
||||
namespaces that every other operation treats as distinct.
|
||||
"""
|
||||
store.put(("Foo",), "k", {"v": "upper"})
|
||||
store.put(("foo",), "k", {"v": "lower"})
|
||||
|
||||
assert {item.namespace for item in store.search(("foo",), limit=100)} == {("foo",)}
|
||||
assert {item.namespace for item in store.search(("Foo",), limit=100)} == {("Foo",)}
|
||||
|
||||
|
||||
def test_list_namespaces_segment_boundary(store: SqliteStore) -> None:
|
||||
for namespace in [
|
||||
("foo",),
|
||||
("foo", "child"),
|
||||
("foobar",),
|
||||
("foobar", "baz"),
|
||||
("uid", "users", "alice"),
|
||||
("uid", "users", "malice"),
|
||||
("uid", "a", "b", "alice"),
|
||||
]:
|
||||
store.put(namespace, "k", {"v": 1})
|
||||
|
||||
assert set(store.list_namespaces(prefix=["foo"], limit=100)) == {
|
||||
("foo",),
|
||||
("foo", "child"),
|
||||
}
|
||||
# Suffix must align to a segment: "malice" does not end with the "alice"
|
||||
# segment.
|
||||
assert set(store.list_namespaces(suffix=["alice"], limit=100)) == {
|
||||
("uid", "users", "alice"),
|
||||
("uid", "a", "b", "alice"),
|
||||
}
|
||||
# "*" spans exactly one segment.
|
||||
assert set(store.list_namespaces(prefix=["uid", "*", "alice"], limit=100)) == {
|
||||
("uid", "users", "alice"),
|
||||
}
|
||||
# Prefix matching stays open-ended across depth.
|
||||
assert set(store.list_namespaces(prefix=["uid"], limit=100)) == {
|
||||
("uid", "users", "alice"),
|
||||
("uid", "users", "malice"),
|
||||
("uid", "a", "b", "alice"),
|
||||
}
|
||||
|
||||
|
||||
def test_search_empty_prefix_is_unconstrained(store: SqliteStore) -> None:
|
||||
"""An empty prefix constrains nothing and must return every namespace."""
|
||||
for namespace in [("a",), ("b", "c"), ("d", "e", "f")]:
|
||||
store.put(namespace, "k", {"v": 1})
|
||||
|
||||
assert {item.namespace for item in store.search((), limit=100)} == {
|
||||
("a",),
|
||||
("b", "c"),
|
||||
("d", "e", "f"),
|
||||
}
|
||||
|
||||
|
||||
def test_namespace_labels_with_trailing_newline(store: SqliteStore) -> None:
|
||||
"""Labels may contain newlines, and must not match a differently-named label.
|
||||
|
||||
Python's `$` also matches just before a trailing newline, so the patterns use
|
||||
`\\Z` to anchor at the true end of the string.
|
||||
"""
|
||||
store.put(("users", "alice"), "k", {"v": 1})
|
||||
store.put(("users", "alice\n"), "k", {"v": 2})
|
||||
|
||||
assert set(store.list_namespaces(suffix=["alice"], limit=100)) == {
|
||||
("users", "alice"),
|
||||
}
|
||||
assert set(store.list_namespaces(prefix=["users", "alice"], limit=100)) == {
|
||||
("users", "alice"),
|
||||
}
|
||||
|
||||
|
||||
def test_list_namespaces_prefix_uses_indexable_condition() -> None:
|
||||
"""Plain prefixes must use the indexable condition, not the match function.
|
||||
|
||||
A user function is opaque to the query planner, so it scans every row and
|
||||
calls back into Python for each one. Only suffix and wildcard paths, which
|
||||
no SQLite operator can express, need it.
|
||||
"""
|
||||
store = BaseSqliteStore()
|
||||
|
||||
def where(match_type: str, path: tuple[str, ...]) -> str:
|
||||
op = ListNamespacesOp(
|
||||
match_conditions=(MatchCondition(match_type=match_type, path=path),),
|
||||
max_depth=None,
|
||||
limit=10,
|
||||
offset=0,
|
||||
)
|
||||
query, _ = store._get_batch_list_namespaces_queries([(0, op)])[0]
|
||||
return " ".join(query.split())
|
||||
|
||||
assert "GLOB" in where("prefix", ("uid", "users"))
|
||||
assert NS_MATCH_FUNCTION not in where("prefix", ("uid", "users"))
|
||||
# A label that merely contains "*" is not the wildcard.
|
||||
assert "GLOB" in where("prefix", ("star*",))
|
||||
# Wildcard and suffix cannot be expressed by GLOB, so they keep the function.
|
||||
assert NS_MATCH_FUNCTION in where("prefix", ("uid", "*", "alice"))
|
||||
assert NS_MATCH_FUNCTION in where("suffix", ("alice",))
|
||||
|
||||
|
||||
def test_list_namespaces_metacharacter_labels(store: SqliteStore) -> None:
|
||||
"""Metacharacters in labels are literal on both matching paths.
|
||||
|
||||
Plain prefixes take the `= OR GLOB` condition and wildcard/suffix paths take
|
||||
the regex function, so escaping has to hold in two different syntaxes.
|
||||
"""
|
||||
pairs = [
|
||||
("star*", "starX"),
|
||||
("q?m", "qXm"),
|
||||
("br[ack]et", "brXacXket"),
|
||||
("user_1", "userX1"),
|
||||
("a%b", "axxb"),
|
||||
("plus+", "plusX"),
|
||||
]
|
||||
for label, decoy in pairs:
|
||||
store.put((label,), "k", {"v": 1})
|
||||
store.put((decoy,), "k", {"v": 1})
|
||||
store.put((label, "child"), "k", {"v": 1})
|
||||
|
||||
for label, decoy in pairs:
|
||||
found = set(store.list_namespaces(prefix=[label], limit=100))
|
||||
assert found == {(label,), (label, "child")}
|
||||
# The decoy differs only where the metacharacter would have matched.
|
||||
assert (decoy,) not in found
|
||||
assert set(store.list_namespaces(prefix=[label, "child"], limit=100)) == {
|
||||
(label, "child"),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("namespace", [("foo.bar",), ("foo", ""), ("foo", 1)])
|
||||
@pytest.mark.parametrize(
|
||||
"kind", ["get", "put", "delete", "search", "list_prefix", "list_suffix"]
|
||||
)
|
||||
def test_batch_rejects_invalid_namespace_labels(
|
||||
store: SqliteStore, namespace: tuple, kind: str
|
||||
) -> None:
|
||||
"""Ops passed straight to `batch` must not reach another namespace.
|
||||
|
||||
Namespaces are stored dot-joined, so `("foo.bar",)` flattens to the same
|
||||
text as `("foo", "bar")`. `BaseStore` methods validate labels themselves,
|
||||
but `batch` takes ops as given.
|
||||
"""
|
||||
op = {
|
||||
"get": GetOp(namespace, "key"),
|
||||
"put": PutOp(namespace, "key", {"changed": True}),
|
||||
"delete": PutOp(namespace, "key", None),
|
||||
"search": SearchOp(namespace),
|
||||
"list_prefix": ListNamespacesOp((MatchCondition("prefix", namespace),)),
|
||||
"list_suffix": ListNamespacesOp((MatchCondition("suffix", namespace),)),
|
||||
}[kind]
|
||||
store.put(("foo", "bar"), "key", {"original": True})
|
||||
|
||||
with pytest.raises(InvalidNamespaceError):
|
||||
store.batch([PutOp(("valid",), "key", {}), op])
|
||||
|
||||
item = store.get(("foo", "bar"), "key")
|
||||
assert item is not None and item.value == {"original": True}
|
||||
# The whole batch is rejected before any SQL runs.
|
||||
assert store.get(("valid",), "key") is None
|
||||
|
||||
|
||||
def test_batch_allows_empty_search_prefix_and_listing_wildcards(
|
||||
store: SqliteStore,
|
||||
) -> None:
|
||||
store.put(("foo", "bar"), "key", {"v": 1})
|
||||
|
||||
found, listed = store.batch(
|
||||
[
|
||||
SearchOp(()),
|
||||
ListNamespacesOp((MatchCondition("prefix", ("foo", "*")),)),
|
||||
]
|
||||
)
|
||||
|
||||
assert [item.namespace for item in found] == [("foo", "bar")]
|
||||
assert listed == [("foo", "bar")]
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import sqlite3
|
||||
from collections.abc import Iterator
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
|
||||
import aiosqlite
|
||||
import pytest
|
||||
from langgraph.checkpoint.base import empty_checkpoint
|
||||
|
||||
from langgraph.checkpoint.sqlite import SqliteSaver
|
||||
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
|
||||
|
||||
WRITES_BEFORE_TASK_PATH = """
|
||||
CREATE TABLE writes (
|
||||
thread_id TEXT NOT NULL,
|
||||
checkpoint_ns TEXT NOT NULL DEFAULT '',
|
||||
checkpoint_id TEXT NOT NULL,
|
||||
task_id TEXT NOT NULL,
|
||||
idx INTEGER NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
type TEXT,
|
||||
value BLOB,
|
||||
PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id, task_id, idx)
|
||||
);
|
||||
INSERT INTO writes VALUES ('t', '', 'c', 'old-task', 0, 'ch', 'null', X'');
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def legacy_db(tmp_path: Path) -> Path:
|
||||
db = tmp_path / "legacy.sqlite"
|
||||
with sqlite3.connect(db) as conn:
|
||||
conn.executescript(WRITES_BEFORE_TASK_PATH)
|
||||
return db
|
||||
|
||||
|
||||
def test_setup_migrates_legacy_writes_table_repeatably(legacy_db: Path) -> None:
|
||||
for _ in range(2):
|
||||
with SqliteSaver.from_conn_string(str(legacy_db)) as saver:
|
||||
saver.setup()
|
||||
rows = saver.conn.execute(
|
||||
"SELECT task_id, task_path FROM writes"
|
||||
).fetchall()
|
||||
assert rows == [("old-task", "")]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fresh", [True, False], ids=["fresh", "legacy"])
|
||||
def test_put_writes_persists_task_path(
|
||||
tmp_path: Path, legacy_db: Path, fresh: bool
|
||||
) -> None:
|
||||
db = tmp_path / "fresh.sqlite" if fresh else legacy_db
|
||||
with SqliteSaver.from_conn_string(str(db)) as saver:
|
||||
config = saver.put(
|
||||
{"configurable": {"thread_id": "t", "checkpoint_ns": ""}},
|
||||
empty_checkpoint(),
|
||||
{},
|
||||
{},
|
||||
)
|
||||
saver.put_writes(config, [("ch", "v")], "task-1", "~__pregel_pull, node")
|
||||
stored = saver.conn.execute(
|
||||
"SELECT task_path FROM writes WHERE task_id = 'task-1'"
|
||||
).fetchall()
|
||||
assert stored == [("~__pregel_pull, node",)]
|
||||
|
||||
|
||||
async def test_async_setup_migrates_legacy_writes_table_repeatably(
|
||||
legacy_db: Path,
|
||||
) -> None:
|
||||
for _ in range(2):
|
||||
async with AsyncSqliteSaver.from_conn_string(str(legacy_db)) as saver:
|
||||
await saver.setup()
|
||||
config = await saver.aput(
|
||||
{"configurable": {"thread_id": "t", "checkpoint_ns": ""}},
|
||||
empty_checkpoint(),
|
||||
{},
|
||||
{},
|
||||
)
|
||||
await saver.aput_writes(
|
||||
config, [("ch", "v")], "task-1", "~__pregel_pull, node"
|
||||
)
|
||||
|
||||
async with aiosqlite.connect(legacy_db) as conn:
|
||||
async with conn.execute(
|
||||
"SELECT DISTINCT task_id, task_path FROM writes ORDER BY task_id"
|
||||
) as cur:
|
||||
assert await cur.fetchall() == [
|
||||
("old-task", ""),
|
||||
("task-1", "~__pregel_pull, node"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def busy_db(tmp_path: Path) -> Iterator[Path]:
|
||||
db = tmp_path / "busy.sqlite"
|
||||
with SqliteSaver.from_conn_string(str(db)) as saver:
|
||||
saver.setup()
|
||||
with closing(sqlite3.connect(db, isolation_level=None)) as writer:
|
||||
writer.execute("BEGIN IMMEDIATE")
|
||||
yield db
|
||||
writer.execute("ROLLBACK")
|
||||
|
||||
|
||||
def test_setup_does_not_wait_on_another_writer(busy_db: Path) -> None:
|
||||
with closing(sqlite3.connect(busy_db, timeout=0)) as conn:
|
||||
SqliteSaver(conn).setup()
|
||||
|
||||
|
||||
async def test_async_setup_does_not_wait_on_another_writer(busy_db: Path) -> None:
|
||||
async with aiosqlite.connect(busy_db, timeout=0) as conn:
|
||||
await AsyncSqliteSaver(conn).setup()
|
||||
|
||||
|
||||
def _legacy_database_with_history(db: Path) -> dict:
|
||||
root = empty_checkpoint()
|
||||
root["channel_values"] = {"ch": "seed"}
|
||||
root["channel_versions"] = {"ch": 1}
|
||||
with SqliteSaver.from_conn_string(str(db)) as saver:
|
||||
root_config = saver.put(
|
||||
{"configurable": {"thread_id": "t", "checkpoint_ns": ""}},
|
||||
root,
|
||||
{},
|
||||
{"ch": 1},
|
||||
)
|
||||
saver.put_writes(root_config, [("ch", "write")], "task", "~__pregel_pull, n")
|
||||
child = saver.put(root_config, empty_checkpoint(), {}, {})
|
||||
saver.conn.execute("ALTER TABLE writes DROP COLUMN task_path")
|
||||
saver.conn.commit()
|
||||
return child
|
||||
|
||||
|
||||
def test_read_only_legacy_database_still_reads_delta_history(tmp_path: Path) -> None:
|
||||
db = tmp_path / "legacy.sqlite"
|
||||
child = _legacy_database_with_history(db)
|
||||
|
||||
saver = SqliteSaver(sqlite3.connect(f"file:{db}?mode=ro", uri=True))
|
||||
got = saver.get_delta_channel_history(config=child, channels=["ch"])
|
||||
|
||||
assert got["ch"] == {"seed": "seed", "writes": [("task", "ch", "write")]}
|
||||
|
||||
|
||||
async def test_async_read_only_legacy_database_still_reads_delta_history(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
db = tmp_path / "legacy.sqlite"
|
||||
child = _legacy_database_with_history(db)
|
||||
|
||||
async with aiosqlite.connect(f"file:{db}?mode=ro", uri=True) as conn:
|
||||
saver = AsyncSqliteSaver(conn)
|
||||
got = await saver.aget_delta_channel_history(config=child, channels=["ch"])
|
||||
|
||||
assert got["ch"] == {"seed": "seed", "writes": [("task", "ch", "write")]}
|
||||
Generated
+93
-62
@@ -26,16 +26,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.12.1"
|
||||
version = "4.14.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
||||
{ name = "idna" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -147,11 +147,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "codespell"
|
||||
version = "2.4.2"
|
||||
version = "2.4.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2d/9d/1d0903dff693160f893ca6abcabad545088e7a2ee0a6deae7c24e958be69/codespell-2.4.2.tar.gz", hash = "sha256:3c33be9ae34543807f088aeb4832dfad8cb2dae38da61cac0a7045dd376cfdf3", size = 352058, upload-time = "2026-03-05T18:10:42.936Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/80/19/45e941380f69c042b43423513d201e6592346f992394347f5e7174c31407/codespell-2.4.3.tar.gz", hash = "sha256:cbe085e331227b37bb86ef8bddd08dc768c704ee9a07ca869852c093fa2793e2", size = 352773, upload-time = "2026-07-15T11:51:54.159Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/42/a1/52fa05533e95fe45bcc09bcf8a503874b1c08f221a4e35608017e0938f55/codespell-2.4.2-py3-none-any.whl", hash = "sha256:97e0c1060cf46bd1d5db89a936c98db8c2b804e1fdd4b5c645e82a1ec6b1f886", size = 353715, upload-time = "2026-03-05T18:10:41.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/bf/bdb951d34eb169140b546f44be9ec4525d1acefb9eb5572071f5492b19fc/codespell-2.4.3-py3-none-any.whl", hash = "sha256:af2505b335e8573dbd2d384d1c4ef498f4006f4ba2d6fceca01e55b91f52628a", size = 340736, upload-time = "2026-07-15T11:51:52.925Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -168,7 +168,7 @@ name = "exceptiongroup"
|
||||
version = "1.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
|
||||
wheels = [
|
||||
@@ -253,7 +253,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langchain-core"
|
||||
version = "1.4.0"
|
||||
version = "1.5.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jsonpatch" },
|
||||
@@ -266,26 +266,26 @@ dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "uuid-utils" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/59/de/679a53472c25860837e32c0442c962fa86e95317a36460e2c9d5c91b17c2/langchain_core-1.4.0.tar.gz", hash = "sha256:1dc341eed802ed9c117c0df3923c991e5e9e226571e5725c194eeb5bd93d1a7f", size = 920260, upload-time = "2026-05-11T18:42:35.919Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6e/58/3ad53096eee1e07728e8219ded30ae308b0f2b8b7b26b8ebb6371917c0f5/langchain_core-1.5.2.tar.gz", hash = "sha256:2d13ab35b42eec63d4669a483776b8cdd778ee764107149369fb369d84c08c41", size = 972322, upload-time = "2026-07-28T16:38:37.977Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/1a/86c38c27b81913a1c6c12448cab55defb5a1097c7dc9a4cea83f55477a2d/langchain_core-1.4.0-py3-none-any.whl", hash = "sha256:23cbbdb46e38ddd1dd5247e6167e96013eae74bea4c5949c550809970a9e565c", size = 548120, upload-time = "2026-05-11T18:42:33.992Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/e4/024e402a65f5ee2eb6ae9667b5ffc5f08776cb4e712f5a11ee1997d56083/langchain_core-1.5.2-py3-none-any.whl", hash = "sha256:a687dd7c3b22c6c1294e1c1eeb61fb6f3a308e6015a1d75b576b3836ad5b5aed", size = 561643, upload-time = "2026-07-28T16:38:36.38Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langchain-protocol"
|
||||
version = "0.0.15"
|
||||
version = "0.0.18"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4f/24/9777489d6fbbee64af0c8f96d4f840239c408cf694f3394672807dafc490/langchain_protocol-0.0.15.tar.gz", hash = "sha256:9ab2d11ee73944754f10e037e717098d3a6796f0e58afa9cadda6154e7655ade", size = 5862, upload-time = "2026-05-01T22:30:04.748Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d2/59/b5959aea96faa9146e2e49a7a22882b3528c62efafe9a6a95beab30c2305/langchain_protocol-0.0.18.tar.gz", hash = "sha256:ec3e11782f1ed0c9db38e5a9ed01b0e7a0d3fba406faa8aef6594b73c56a63e6", size = 6150, upload-time = "2026-06-18T17:08:26.959Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/7a/9c97a7b9cbe4c5dc6a44cdb1545450c28f0c8ce89b9c1f0ee7fbad896263/langchain_protocol-0.0.15-py3-none-any.whl", hash = "sha256:461eb794358f83d5e42635a5797799ffec7b4702314e34edf73ac21e75d3ef79", size = 6982, upload-time = "2026-05-01T22:30:03.877Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/2e/d82db9eec13ad0f72e7aaad5c4bc730ab111934fdc83c85523206eb9b0a0/langchain_protocol-0.0.18-py3-none-any.whl", hash = "sha256:70b53a86fbf9cedc863555effe44da192ab02d556ddbf2cf95b8873adcf41b5a", size = 7221, upload-time = "2026-06-18T17:08:25.996Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-checkpoint"
|
||||
version = "4.1.1"
|
||||
version = "4.3.0"
|
||||
source = { editable = "../checkpoint" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -331,9 +331,36 @@ test = [
|
||||
{ name = "redis" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-checkpoint-conformance"
|
||||
version = "0.0.2"
|
||||
source = { editable = "../checkpoint-conformance" }
|
||||
dependencies = [
|
||||
{ name = "langgraph-checkpoint" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [{ name = "langgraph-checkpoint", editable = "../checkpoint" }]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "ruff" },
|
||||
{ name = "ty" },
|
||||
]
|
||||
lint = [
|
||||
{ name = "ruff" },
|
||||
{ name = "ty" },
|
||||
]
|
||||
test = [
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-checkpoint-sqlite"
|
||||
version = "3.1.0"
|
||||
version = "3.1.2"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiosqlite" },
|
||||
@@ -345,6 +372,7 @@ dependencies = [
|
||||
dev = [
|
||||
{ name = "codespell" },
|
||||
{ name = "langgraph-checkpoint" },
|
||||
{ name = "langgraph-checkpoint-conformance" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "pytest-mock" },
|
||||
@@ -360,6 +388,7 @@ lint = [
|
||||
]
|
||||
test = [
|
||||
{ name = "langgraph-checkpoint" },
|
||||
{ name = "langgraph-checkpoint-conformance" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "pytest-mock" },
|
||||
@@ -378,6 +407,7 @@ requires-dist = [
|
||||
dev = [
|
||||
{ name = "codespell" },
|
||||
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
|
||||
{ name = "langgraph-checkpoint-conformance", editable = "../checkpoint-conformance" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "pytest-mock" },
|
||||
@@ -393,6 +423,7 @@ lint = [
|
||||
]
|
||||
test = [
|
||||
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
|
||||
{ name = "langgraph-checkpoint-conformance", editable = "../checkpoint-conformance" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "pytest-mock" },
|
||||
@@ -720,7 +751,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.3"
|
||||
version = "9.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
@@ -731,9 +762,9 @@ dependencies = [
|
||||
{ name = "pygments" },
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -752,14 +783,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pytest-mock"
|
||||
version = "3.15.1"
|
||||
version = "3.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7a/7f/6ed29931d5c8cd396e7c0a55412e6cc88020373365c8685985dea53d26d7/pytest_mock-3.16.0.tar.gz", hash = "sha256:5a8395528b8f498205f3718f575228d0edaed7425fff638f87d1a6c3e0383636", size = 35362, upload-time = "2026-09-27T14:57:55.46Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/5b/b83a9bf1a3b4ec222f9fa083147ff6816245223da0ab92370e7e056f113f/pytest_mock-3.16.0-py3-none-any.whl", hash = "sha256:007cfeb257801d88d9c0b2a7b5a15a15e73b71968dfd72e7bf8c4a2f8393aec8", size = 10016, upload-time = "2026-09-27T14:57:54.283Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -880,27 +911,27 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.15"
|
||||
version = "0.16.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/84/6f/a76f7d96e5c962f5b69cee865e49c15c1116897c01990faa8a57edb62e7f/ruff-0.15.15.tar.gz", hash = "sha256:b8dff018130b46d8e5bf0f926ef6b60cf871d6d5ae45fc9334e09632daa741d6", size = 4706985, upload-time = "2026-05-28T14:16:57.784Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/bf/c935ca98e73fe8ce65b87ef08a280c0c1e85295d569228c15e87d8fdfaf1/ruff-0.16.9.tar.gz", hash = "sha256:12b625c6cfba78d285d9f48eda5f053374f1e53cb10ef17342a383750db99161", size = 4948764, upload-time = "2026-09-24T20:37:49.416Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/9d/3a45c05b8ab04b4705989de70a79008e27c8003296a0feaee9edc18dd7e9/ruff-0.15.15-py3-none-linux_armv6l.whl", hash = "sha256:cf93e5388f412e1b108b1f8b34a6e036b70fe8aff89393befad96fe48670311b", size = 10710652, upload-time = "2026-05-28T14:16:06.701Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/66/da974431624bf3b49f6ee1f9543c02d929ff1cba78b0d5a79c38cf21f744/ruff-0.15.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ac5a646d1f6a7dadd5d50842dae2c1f9862ac887ef5d1b1375e02def791fde6e", size = 11096615, upload-time = "2026-05-28T14:16:23.313Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/09/7443452e5d290230a712103f2fdceeef7184f3ec99a2bd01c8be78aaceb5/ruff-0.15.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:77d955a431430c66f72dd94e379ad38a16daea3d25094872ac4edf9e797be530", size = 10436683, upload-time = "2026-05-28T14:16:40.974Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/01/d330c26a57fa4f3943a14424904027428315b700fe4d14a84bb123a649e5/ruff-0.15.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7614ee79c69788cf6cedd568069ade9cecc22a1ad20494efe8d0c9ebb4b622d4", size = 10769064, upload-time = "2026-05-28T14:16:28.905Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/85/cc8770f8bdff541b1da8392d1634141fe4a0e3f4ee596605959b7906c27f/ruff-0.15.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3cdb1679e06a1f6b47bc384714ae96f6e2fb65ca441eb78c43d2ca554176ce1f", size = 10511987, upload-time = "2026-05-28T14:16:43.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/29/8c190c1472b63013583ba391f3342036e02010544c1270455ed8e519bdf3/ruff-0.15.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2728b93d7b23a603ea2c0ac6eb73d760bd38ec9de35f35fb41e18f7a3fee7622", size = 11275100, upload-time = "2026-05-28T14:16:55.244Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/6b/7e145ce2cc8e63d6834eca03d83a0e18d121def5c69f91b4cf4011ed4879/ruff-0.15.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be582fcc0db438902c7792b08d6ddf6c9b9e21addaa10092c2c741cfb09e5a45", size = 12176903, upload-time = "2026-05-28T14:16:14.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/a3/d5974637f68e451f7fadf015cf3101d1cd7d8ba5027cffe0b9e3826ebe6b/ruff-0.15.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7aa77465b8ecaf1a27bea098d696f7fed5e1eccbd10b321b682d6de586ae5627", size = 11404550, upload-time = "2026-05-28T14:16:20.138Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/1c/e6e5e568f22be4fb05d6244234aba384c06b451252453b821e1a529263cf/ruff-0.15.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48decfa11d740de4889de623be1463308346312f2409a56e24aa280c86162dc4", size = 11382027, upload-time = "2026-05-28T14:16:46.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/01/170921b49fcd2e8858825593f91cf7146c3e40a5c3e6df763e4bb0484dde/ruff-0.15.15-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a5015088452ca0081387063649ec67f06d3d1d6b8b936a1f836b5e9657ecd48c", size = 11366041, upload-time = "2026-05-28T14:16:26.247Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/54/a7bad711d7de93254e15e06a4c375b89a03d18de45d3e5dcc86a4472fb1a/ruff-0.15.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f5294aab6356c81600fcdea3a62bb1b924dfd5e91767c12318d3f68f86af57cd", size = 10741795, upload-time = "2026-05-28T14:16:17.11Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/31/38c075963668f8b41c6914ee0f6f318727fbe30ab9145cb29e6df464c5fa/ruff-0.15.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:db5bd4d802415cca656dc1616070b725952d6ae95eb5d4831e49fbd94a38f75f", size = 10511117, upload-time = "2026-05-28T14:16:31.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/96/6ff689e1f7e375d1d97075eca022f74c2bab59554a432fe4d2e6f091986a/ruff-0.15.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:587a6278ed42059191c1a466e490bd7930fb50bd2e255398bc29616c895a61cb", size = 10994867, upload-time = "2026-05-28T14:16:35.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/c2/5dce0ab9f92a8d534fa62b9bf9caca3eddb8c1a81b616f5e195ada4f0d6e/ruff-0.15.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:df0c1c084f5f4be9812f61518a45c440d3c30d69ce4bf6c5270e66d38338f02a", size = 11482101, upload-time = "2026-05-28T14:16:49.598Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/c0/1003b60edd697c649faf61f1a34094b1abb38fb3d1181e3f895781250a08/ruff-0.15.15-py3-none-win32.whl", hash = "sha256:29428ea79694afbe756d45fd59b36f22b6b020dc0443cf7de0173046236964b9", size = 10716774, upload-time = "2026-05-28T14:16:52.337Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/a8/1269eddd6945a06c23f055ef7848886e37cf9d6a8bebb386a3115f01470c/ruff-0.15.15-py3-none-win_amd64.whl", hash = "sha256:8df0323902e15e24bc4bf246da830573d3cf3352bd0b9a164eab335d111ff4a4", size = 11868463, upload-time = "2026-05-28T14:16:11.333Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/b2/920464c907b191e37469d477a1aa8bc048b8f36c4c1610dfa4ab87b39e18/ruff-0.15.15-py3-none-win_arm64.whl", hash = "sha256:3c8ceca6792f38196b8f589bc92eccd03eef286602da92e5dc05cc42ef6441b7", size = 11138498, upload-time = "2026-05-28T14:16:38.425Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/26/df51322b52ee1ada7eff2d071ea09d11d5c2d1dcc9f02594f5785c4d1635/ruff-0.16.9-py3-none-linux_armv6l.whl", hash = "sha256:95e6f022090368ab3b824c36276839c53b2adf1a3f4c09fefc33dfc400f6da96", size = 10082922, upload-time = "2026-09-24T20:37:13.045Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/27/7bf51f5a7aa375e9f339280a303aab44ca75dc1525f1cdc5991761685b0f/ruff-0.16.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a5f27be168556594a86d2f415db0cf43f5291917849318f873c7e2791f7a8c67", size = 10236360, upload-time = "2026-09-24T20:37:16.049Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/63/09659283f92f02dff45809da194a70da2f688d87c55d8875c4fae3536072/ruff-0.16.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1632eb1d6197f33bd00b1acbc5b71009e89a8895c158e2d2b03a834fac964ab6", size = 9892940, upload-time = "2026-09-24T20:37:17.957Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/58/98de1b72ec172f5f8f1731236fe21585b3998bf7dfe9fcc44ae9ba626012/ruff-0.16.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3f951b14d865d5952c89d40a5ca07e87abe24fa5453299878411e127748fb1c", size = 10032114, upload-time = "2026-09-24T20:37:19.942Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/7d/f1e17c54ab59d4bad1dce8ee3e22a7a1d0ef4745240decacdcf3832b5bb2/ruff-0.16.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:447fc07e1573afff7cb02803462b12b6c8ece7cf10e2cd78565fa6d7a1c0bf8d", size = 9910227, upload-time = "2026-09-24T20:37:21.872Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/19/436f647a65075bbd3bab2668b3bdaa5120559b294694018cdcefabbbf30b/ruff-0.16.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a3e039a6a40ed976c491722b60e0ae4a4aa1a86057f540ee7a37a5d19ae9120", size = 10547484, upload-time = "2026-09-24T20:37:24.229Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/59/38430a6bc2f6d8095447ac39625cf8b6e9344a47e6c26225c8ba1bff3ffb/ruff-0.16.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4684dded7db60aa57cb118fa158630f5feade4af5782903b6053484bdf9bd129", size = 11412367, upload-time = "2026-09-24T20:37:26.307Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/bd/bbb6d7fc7f208c8b8c50dd5dc8206e4cfdb1e7a8fb852606a3adf370c880/ruff-0.16.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d29c934357e45642fda2f34c0b1f4025b4a6c01e15e4bf0016879d60078a142c", size = 10869787, upload-time = "2026-09-24T20:37:28.35Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/b8/9c543074918061abbefc3bd139bee22de35abedb00dde0f2d27288838962/ruff-0.16.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a21713e629d3e5bdb2f5c2def1cc7f04f47fa8e1a7eb0571b4a28e1da64bc728", size = 10406494, upload-time = "2026-09-24T20:37:30.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/7a/5a8851bd146e7ccf8fd4b003f6c75c11f8fbdb0e60673b45097986b6bf41/ruff-0.16.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7baa24ef5fc8e77aa93879e1d3f43754a01ae488e869f1ae30cf431afd4d2452", size = 10590083, upload-time = "2026-09-24T20:37:32.439Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/f0/4c3467188f23f806960b46fa76575a7cd0514c9ba90562650b71efc96980/ruff-0.16.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a41aac6230aadfaa133bdfa1614488531ffa3e0837567ae04c0da2058a9c0f9e", size = 10119151, upload-time = "2026-09-24T20:37:34.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/34/5a4def5adea572ce6aea0bb64f21f928ee317b80e0db747d7979b01d7261/ruff-0.16.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c2529fb5896d49115b0e9aa8f887490b34bbe76baf879ec2264ac59406869ce7", size = 9911544, upload-time = "2026-09-24T20:37:36.796Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/5d/d15ebea7499eef9373318c0ee6ca127832927c6529731f6d48e18dce7ca9/ruff-0.16.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:41e3870277694177429b56406d65dfbdb2c2802c52b715edaf6a0b829c69d4ee", size = 10269884, upload-time = "2026-09-24T20:37:38.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/56/c5d3cd119ded7a3c7aba0e961b69cb3df701c91662c98ad694467d060ce1/ruff-0.16.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8adbe4e58af167f767d7b2ba5e83c42e878350796cf78c2f5e14ab9903a92588", size = 10749366, upload-time = "2026-09-24T20:37:41.042Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/fe/734ec7527029ac757ecf821f143c9f3fcf69149c21f53a044a899b430f5a/ruff-0.16.9-py3-none-win32.whl", hash = "sha256:0e1dbc2073624dee6618d41d0098690a7244654af746704b64759e12b6b6b385", size = 10152355, upload-time = "2026-09-24T20:37:43.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/21/26e4643629b3ebb44f0a06f9c9a53058d63d989415f63a9a3c28e2ee7f22/ruff-0.16.9-py3-none-win_amd64.whl", hash = "sha256:6bd40fec8cd4c8a3d4dd589bd8ad4e6320c13c29234159bfd959a40d529d597b", size = 10592965, upload-time = "2026-09-24T20:37:44.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/60/5fb1a39dbb5ae314d5f59bc7348a63c1d5c20f3cd83914c4b5cb0be31d2d/ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284", size = 10458649, upload-time = "2026-09-24T20:37:46.882Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -980,27 +1011,27 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ty"
|
||||
version = "0.0.43"
|
||||
version = "0.0.84"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0d/37/4ec04de0659b93be37d956dfceca13b1ecab9c959f28d8a1d5e514603f36/ty-0.0.43.tar.gz", hash = "sha256:ea4cff50548f2a1877e848d3abe9e293cde8ab94757a7eb93fc0d4013f98be8e", size = 5798429, upload-time = "2026-06-04T00:52:10.013Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e1/f6/34f8869c45ea87fe6a931ffa824b1fe4428167173543d92988d631add355/ty-0.0.84.tar.gz", hash = "sha256:0cefdb0cd5d399418dbe83c349ba605047b7dff815ae6f460c80e8522b40be67", size = 7409537, upload-time = "2026-09-24T13:16:24.367Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/db/74/1916026a78f20019a2f03adbd6fb4430ddb7ce1e52c2e17a90856a6d192e/ty-0.0.43-py3-none-linux_armv6l.whl", hash = "sha256:3bf70f5446480562bf6c9f639df4b5cb60716b8f8d1a6b8e5811d5c7eccd8bf2", size = 11598153, upload-time = "2026-06-04T00:52:20.646Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/af/58bb0089d2635216c8fa6612dd486a3f986d0ab1c46a41527ab95e57f0e3/ty-0.0.43-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7184741f8b15425a1bc64b950ad005cb353573288ac0e8a04f5481ceb3832596", size = 11357811, upload-time = "2026-06-04T00:52:24.683Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/9c/32c6b14f3feddf87b59c7a50709e2b3da408258f2f583f05575f77bc8f7b/ty-0.0.43-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8c306379ca9a35f6ae5270fe9bda7af4b46d91822725a2586d78c8b9b5493b62", size = 10772024, upload-time = "2026-06-04T00:52:14.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/fa/98aa4a74bd00cd5efc424923cd1daffbf1e40a0338041cafb203379d746f/ty-0.0.43-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d624b884c9c1fd244ad2a5f026364e7162a22b3f537025941ada2e363e676414", size = 11291034, upload-time = "2026-06-04T00:52:37.249Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/db/4de086c38ce96dcada2bd451f43171d2c237f96d8ed19a1ea8fe51bb8ef4/ty-0.0.43-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:281fc4c00fbc196045141faa085055bddc58846b04a2800204701415a1b9c6aa", size = 11364724, upload-time = "2026-06-04T00:52:33.138Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/d3/e3cd8e3233a6fd8362a49aa025b79e9f40151a2a86d811ace154c6eb7445/ty-0.0.43-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f57d6cc28de89024b48d1788e4758c05299d5749d4a51c02e71ac655ec23d9a5", size = 11890555, upload-time = "2026-06-04T00:52:22.711Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/7b/6f46d444e8241606bbde098df3dca93f2ec0b834a42055db85ee7d33646f/ty-0.0.43-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0a1d6ad6c5e7792c7eac0a01e550f2c2004462e01a64a91ea1636aba6fef6e71", size = 12450968, upload-time = "2026-06-04T00:52:28.94Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/e1/79fbe51f2e4b9d8347f2013cd7ed0b63f3b499038c02dc0357e9b28a3a47/ty-0.0.43-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:66d474395d7635fb618bdbb58b4e3360259a2056d0a5621b82754b9da2cd8a04", size = 12064187, upload-time = "2026-06-04T00:52:12.039Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/3f/c758a3a8df5b90d331f2b60c8f16021ee64d75e78f99d67cc4efc9bf5f4b/ty-0.0.43-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2663a0003a8b60fb98db7f6f6e673df80b21d0fe3a9868a26fb06b4e049b6fc4", size = 11943208, upload-time = "2026-06-04T00:52:31.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/5f/f516442749cf1b45ca6720a5d41df2738a486ed9ace774c03d515db89084/ty-0.0.43-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:d5a6c352d374d889189d5ec82b54b26a5885f769f7b7787f7f875500dcb8673e", size = 12143572, upload-time = "2026-06-04T00:52:18.457Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/bf/0d83c7f43bf4c10f3678bfe7d938e51c445298c7b923f155c5204730c2df/ty-0.0.43-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e7dbbeedfad3ca250d74fcc355fa9ab6b38d2a17f22d6304f615716939dbbb27", size = 11279355, upload-time = "2026-06-04T00:52:26.726Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/de/a6c978bef6d9e949f79f4782d9e4ee4df0893713e73b055d84c1a5116b9a/ty-0.0.43-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:24b18a0273ee46154996cfcfa27438f851f440c925587ec200df6f98dffe67d3", size = 11408412, upload-time = "2026-06-04T00:52:35.282Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/b1/d13857c23867f0f76b92e38e5841c64ca5e76dc5d4bf27f52cb81d8ab685/ty-0.0.43-py3-none-musllinux_1_2_i686.whl", hash = "sha256:2ef681951520d692b7e9c0b5e56aacf4f98ccae47cf6ffccaf2c7b6b33dc226e", size = 11541709, upload-time = "2026-06-04T00:52:16.451Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/f1/cd6afc6f6a687e238bf5e12189f7920e81a0bdef6c3dba4c784ef140f7d9/ty-0.0.43-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:2af105de7437143aa4676b28016b5bee661aaaa4eff52be5867fb25119641ceb", size = 12041266, upload-time = "2026-06-04T00:52:43.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/ba/51ca7c3335da2b8d0a3e477fa4986be9f4a53b05bfab862967d8d2e6ca60/ty-0.0.43-py3-none-win32.whl", hash = "sha256:e4773115b0d6486ee30f1657fc8bdffe7e3a3f5300ab77ef2495da6e83e4694f", size = 10858724, upload-time = "2026-06-04T00:52:07.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/29/5d80453e5f7c520145fa058851da87230dbd7ca761a7675447a9fe504e0b/ty-0.0.43-py3-none-win_amd64.whl", hash = "sha256:48d3545094a4ae6395492c7e6ac90550fce969e0ed2815fbf8c5da9756676b7d", size = 11976157, upload-time = "2026-06-04T00:52:41.438Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/ed/befe5a543e5b95e754ed38ee95239e44efda9bc5f578db4ac1bc8dd758d6/ty-0.0.43-py3-none-win_arm64.whl", hash = "sha256:740ca33d7f75f655a4e7d475bc42dfb825c13219bb073fad30fcc04d35790c74", size = 11308680, upload-time = "2026-06-04T00:52:39.233Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/0c/600648778e1c79133ce6b6a5c4c2531ae16b90c9b30f5041b38b574f61b8/ty-0.0.84-py3-none-linux_armv6l.whl", hash = "sha256:868438e2b9abfc835dd5b5b07ed693db95d8348ac40e3228252511d63ae87695", size = 13935333, upload-time = "2026-09-24T13:15:31.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/3b/27f06f49945c36170765fad82076898039ae3873338e9865f876dd62f12c/ty-0.0.84-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b00f39b521f0b559cde0f6abf8dd47213b513b04f33d58efeb9b98486a16bb17", size = 13511328, upload-time = "2026-09-24T13:15:34.446Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/b6/b12dd130e7f30141f0645614f454c925991cd389b1cc6c8284fd6bf32326/ty-0.0.84-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8f78c2915567f2ee62ce0c24d86091bc76f81577677755e242d09951db588b9a", size = 13445445, upload-time = "2026-09-24T13:15:37.476Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/96/f012de4b3c1d9a4773326621380d768b4d48c47e620dc012775c5e01c7d7/ty-0.0.84-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:61155cad0921e890d86d1d911a40b3a0e924ef4bdc3b4f3865a6c89c7e3012dc", size = 13471235, upload-time = "2026-09-24T13:15:40.64Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/32/dca630f5f5353fa7bf67eebccf1549df99b16656d21071d0df6677c826a5/ty-0.0.84-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:417c3bf14947ed16b0b92976ab333c27915590354e96d4f581709068d3811355", size = 13680704, upload-time = "2026-09-24T13:15:43.817Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/8d/33702c8f62f05ad45b33644fb914d483de6a0a7bc8e51884e9b8773e6d69/ty-0.0.84-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e06ef117759f654f5379efe9ae79bda8a186016c016f1023d05c34f8e15c1546", size = 14518923, upload-time = "2026-09-24T13:15:46.802Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/a1/da3246e6ef2ae0e842bca4058b25c54bee55ab058ffff4ee54e72517ec01/ty-0.0.84-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a05d1d53db8b64410ab38c6e32c5898510584869d1d5dd9c0605db81c9aef7df", size = 15027731, upload-time = "2026-09-24T13:15:49.755Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/17/b0099098a560aeb4f9fde6c912f9a81622c3e44e6555e1fbe322b368ec31/ty-0.0.84-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b92400494ab855cfb1204ac1101e3f53b2d09a8e21d171758b2cf9acdab1809e", size = 14748260, upload-time = "2026-09-24T13:15:52.713Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/d5/32cd67aedb271f006e716ad093806c96c5d7e2af86be8255469dbd3d76c8/ty-0.0.84-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af17eb391ae3027fed9be1780bea555c2b8a25ba68e184c627b4981b2c9eaab7", size = 14205858, upload-time = "2026-09-24T13:15:55.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/c4/638ac62ca2e8eca1b92e3e16273085744fada728b993500819b5c258ca15/ty-0.0.84-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:44cf06af0e7ec78ea6d8ff33a3450fc19627756a4fba681f7410d4e3ef63047a", size = 14573196, upload-time = "2026-09-24T13:15:59.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/f6/9ee81c5d8921e5fd5f10889ae563c95cd3272b31df2ada528919ca1cef6b/ty-0.0.84-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c6e56b443ff9ba462e34bfbc1e582004c2719ed35f8f93a433744b0ba0afb2d9", size = 13480760, upload-time = "2026-09-24T13:16:02.313Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/83/fbe5d1177667ae16d84c3242846b143809c9cc09568b333e5a9b4d5908f6/ty-0.0.84-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8b6562fcc48820c1030e2a4509ef2c14ef93043ca4860280074314ac3a825b95", size = 13696346, upload-time = "2026-09-24T13:16:05.391Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/9a/1b4ac9ddc34cc0ab4624bc219825d66af52f1ad87f4778ad08ad03fa06d5/ty-0.0.84-py3-none-musllinux_1_2_i686.whl", hash = "sha256:dee7421451efcbd70f03e95a8f2c8d9e5ec3d35edcd27b4095ee00ee062abfc4", size = 13959410, upload-time = "2026-09-24T13:16:08.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/04/70e289437a67dfd686056ab4fd91880261a8233033a6a95d29393741fb97/ty-0.0.84-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:31e96307cf621babe969307dd657783d672e2d8bdbd06493710da635bf63fa07", size = 14350294, upload-time = "2026-09-24T13:16:11.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/f4/ae7dbd118425c850981d23efb850f0d9d7f6b8d2b852bde2767434f668b4/ty-0.0.84-py3-none-win32.whl", hash = "sha256:4bdc85f91cbaae35825f2b17bb4ac5de0563367875cb0f42b2bf07a1bf7ff2ba", size = 13176439, upload-time = "2026-09-24T13:16:15.049Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/ec/994d87252f065dda5a500bc8afc726d18f6582d81d6f4ff1feb8a267c800/ty-0.0.84-py3-none-win_amd64.whl", hash = "sha256:71cc9aa7a7c89d4e12c6a814e9c0c3310ad5bee44efedbd6f5a42d91e9ac0f0d", size = 13962879, upload-time = "2026-09-24T13:16:18.191Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/1c/e6e91852b72566b1ab9aca504712857dfb89324c12092a5bd61d42d34427/ty-0.0.84-py3-none-win_arm64.whl", hash = "sha256:6c9d3718c4a0d318bc9c92af9b2aa85c3294e7dff54cf1d69ad79568f56516da", size = 13656453, upload-time = "2026-09-24T13:16:21.51Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1026,11 +1057,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.7.0"
|
||||
version = "2.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+11
-18
@@ -39,7 +39,12 @@ You must pass these when invoking the graph as part of the configurable part of
|
||||
|
||||
```python
|
||||
{"configurable": {"thread_id": "1"}} # valid config
|
||||
{"configurable": {"thread_id": "1", "checkpoint_id": "0c62ca34-ac19-445d-bbb0-5b4984975b2a"}} # also valid config
|
||||
{
|
||||
"configurable": {
|
||||
"thread_id": "1",
|
||||
"checkpoint_id": "0c62ca34-ac19-445d-bbb0-5b4984975b2a",
|
||||
}
|
||||
} # also valid config
|
||||
```
|
||||
|
||||
### Serde
|
||||
@@ -79,24 +84,12 @@ checkpoint = {
|
||||
"v": 4,
|
||||
"ts": "2024-07-31T20:14:19.804150+00:00",
|
||||
"id": "1ef4f797-8335-6428-8001-8a1503f9b875",
|
||||
"channel_values": {
|
||||
"my_key": "meow",
|
||||
"node": "node"
|
||||
},
|
||||
"channel_versions": {
|
||||
"__start__": 2,
|
||||
"my_key": 3,
|
||||
"start:node": 3,
|
||||
"node": 3
|
||||
},
|
||||
"channel_values": {"my_key": "meow", "node": "node"},
|
||||
"channel_versions": {"__start__": 2, "my_key": 3, "start:node": 3, "node": 3},
|
||||
"versions_seen": {
|
||||
"__input__": {},
|
||||
"__start__": {
|
||||
"__start__": 1
|
||||
},
|
||||
"node": {
|
||||
"start:node": 2
|
||||
}
|
||||
"__input__": {},
|
||||
"__start__": {"__start__": 1},
|
||||
"node": {"start:node": 2},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from typing import (
|
||||
NamedTuple,
|
||||
TypedDict,
|
||||
TypeVar,
|
||||
cast,
|
||||
)
|
||||
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
@@ -34,37 +35,6 @@ PendingWrite = tuple[str, str, Any]
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _parse_supersteps_since_last_snapshot_by_channel(
|
||||
metadata: Mapping[str, Any],
|
||||
channels: Sequence[str],
|
||||
) -> dict[str, int]:
|
||||
"""Per-channel supersteps-since-last-snapshot, parsed from metadata.
|
||||
|
||||
Reads `metadata.counters_since_delta_snapshot[ch]`, a `(updates,
|
||||
supersteps)` pair, and returns `{ch: supersteps}` for channels whose
|
||||
supersteps count is positive. Channels with no counter entry (just
|
||||
snapshotted, or never written) are omitted — their seed, if any, is the
|
||||
target checkpoint itself and needs no ancestor walk.
|
||||
|
||||
Used to drive the ancestor-walk depth in `get_delta_channel_history`:
|
||||
a channel's seed snapshot sits exactly `supersteps` hops back along the
|
||||
parent chain.
|
||||
"""
|
||||
counters = metadata.get("counters_since_delta_snapshot") or {}
|
||||
result: dict[str, int] = {}
|
||||
for ch in channels:
|
||||
entry = counters.get(ch)
|
||||
if not isinstance(entry, (list, tuple)) or len(entry) < 2:
|
||||
continue
|
||||
try:
|
||||
supersteps = int(entry[1])
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if supersteps > 0:
|
||||
result[ch] = supersteps
|
||||
return result
|
||||
|
||||
|
||||
# Marked as total=False to allow for future expansion.
|
||||
class CheckpointMetadata(TypedDict, total=False):
|
||||
"""Metadata associated with a checkpoint."""
|
||||
@@ -192,6 +162,14 @@ class DeltaChannelHistory(TypedDict):
|
||||
Always present; possibly empty. Already filtered to one channel.
|
||||
Writes stored at the target checkpoint itself are pending for the
|
||||
next super-step and are excluded.
|
||||
|
||||
Within a single checkpoint, writes are ordered by `writes_sort_key`,
|
||||
`(task_path, task_id, idx)`, which is the order live execution applies
|
||||
a super-step's task writes in. `task_id` is a hash of the path, so
|
||||
ordering by it permutes parallel tasks writing one channel, and
|
||||
reducers need not be order-invariant. Writes stored without a
|
||||
`task_path` (graph input, `update_state` and `Command` updates, rows
|
||||
predating the column) sort first, by `task_id`.
|
||||
* `seed` — the stored value at the nearest ancestor whose
|
||||
`channel_values[ch]` is populated. Omitted if the walk reached the
|
||||
root without finding any stored value (consumer treats absence as
|
||||
@@ -204,6 +182,20 @@ class DeltaChannelHistory(TypedDict):
|
||||
seed: NotRequired[Any]
|
||||
|
||||
|
||||
def writes_sort_key(
|
||||
task_path: str, task_id: str = "", idx: int = 0
|
||||
) -> tuple[str, str, int]:
|
||||
"""Sort key for the writes of one super-step.
|
||||
|
||||
Live execution applies a super-step's tasks in this order, so a saver
|
||||
that replays stored writes, as `get_delta_channel_history` does, must
|
||||
sort them by it too, or an order-sensitive reducer rebuilds a different
|
||||
value than the run produced. `task_path` is the string passed to
|
||||
`put_writes`.
|
||||
"""
|
||||
return (task_path, task_id, idx)
|
||||
|
||||
|
||||
class BaseCheckpointSaver(Generic[V]):
|
||||
"""Base class for creating a graph checkpointer.
|
||||
|
||||
@@ -274,7 +266,8 @@ class BaseCheckpointSaver(Generic[V]):
|
||||
config: Configuration specifying which checkpoint to retrieve.
|
||||
|
||||
Returns:
|
||||
The requested checkpoint tuple, or `None` if not found.
|
||||
The requested checkpoint tuple, or `None` if not found. Its
|
||||
`pending_writes` must be in `writes_sort_key` order.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: Implement this method in your custom checkpoint saver.
|
||||
@@ -464,7 +457,8 @@ class BaseCheckpointSaver(Generic[V]):
|
||||
config: Configuration specifying which checkpoint to retrieve.
|
||||
|
||||
Returns:
|
||||
The requested checkpoint tuple, or `None` if not found.
|
||||
The requested checkpoint tuple, or `None` if not found. Its
|
||||
`pending_writes` must be in `writes_sort_key` order.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: Implement this method in your custom checkpoint saver.
|
||||
@@ -641,6 +635,10 @@ class BaseCheckpointSaver(Generic[V]):
|
||||
`PostgresSaver`) override for performance; the return contract is
|
||||
fixed here.
|
||||
|
||||
`PendingWrite` carries no `task_path`, so this default replays each
|
||||
checkpoint's writes in `get_tuple`'s `pending_writes` order, which
|
||||
`get_tuple` must return in `writes_sort_key` order.
|
||||
|
||||
Args:
|
||||
config: Configuration identifying the target checkpoint.
|
||||
channels: Channel names to walk for. Empty → empty mapping.
|
||||
@@ -650,30 +648,34 @@ class BaseCheckpointSaver(Generic[V]):
|
||||
"""
|
||||
if not channels:
|
||||
return {}
|
||||
channels = list(channels)
|
||||
collected_by_ch: dict[str, list[PendingWrite]] = {c: [] for c in channels}
|
||||
seed_by_ch: dict[str, Any] = {}
|
||||
remaining: set[str] = set(channels)
|
||||
target_tuple = self.get_tuple(config)
|
||||
if target_tuple is None:
|
||||
return {ch: {"writes": []} for ch in channels}
|
||||
supersteps_by_ch = _parse_supersteps_since_last_snapshot_by_channel(
|
||||
target_tuple.metadata or {}, channels
|
||||
cursor_config: RunnableConfig | None = (
|
||||
target_tuple.parent_config if target_tuple else None
|
||||
)
|
||||
max_supersteps = max(supersteps_by_ch.values(), default=0)
|
||||
|
||||
chain: list[CheckpointTuple] = []
|
||||
has_reached_root = False
|
||||
cursor_config: RunnableConfig | None = target_tuple.parent_config
|
||||
while cursor_config is not None and len(chain) < max_supersteps:
|
||||
while cursor_config is not None and remaining:
|
||||
tup = self.get_tuple(cursor_config)
|
||||
if tup is None:
|
||||
break
|
||||
chain.append(tup)
|
||||
if tup.parent_config is None:
|
||||
has_reached_root = True
|
||||
break
|
||||
if tup.pending_writes:
|
||||
for write in reversed(tup.pending_writes):
|
||||
ch = write[1]
|
||||
if ch in remaining:
|
||||
collected_by_ch[ch].append(write)
|
||||
for ch in list(remaining):
|
||||
if ch in tup.checkpoint["channel_values"]:
|
||||
seed_by_ch[ch] = tup.checkpoint["channel_values"][ch]
|
||||
remaining.discard(ch)
|
||||
cursor_config = tup.parent_config
|
||||
return self._assemble_default_delta_history(
|
||||
channels, supersteps_by_ch, chain, has_reached_root, config
|
||||
)
|
||||
result: dict[str, DeltaChannelHistory] = {}
|
||||
for ch in channels:
|
||||
entry: DeltaChannelHistory = {"writes": list(reversed(collected_by_ch[ch]))}
|
||||
if ch in seed_by_ch:
|
||||
entry["seed"] = seed_by_ch[ch]
|
||||
result[ch] = entry
|
||||
return result
|
||||
|
||||
async def aget_delta_channel_history(
|
||||
self, *, config: RunnableConfig, channels: Sequence[str]
|
||||
@@ -687,106 +689,32 @@ class BaseCheckpointSaver(Generic[V]):
|
||||
"""
|
||||
if not channels:
|
||||
return {}
|
||||
channels = list(channels)
|
||||
collected_by_ch: dict[str, list[PendingWrite]] = {c: [] for c in channels}
|
||||
seed_by_ch: dict[str, Any] = {}
|
||||
remaining: set[str] = set(channels)
|
||||
target_tuple = await self.aget_tuple(config)
|
||||
if target_tuple is None:
|
||||
return {ch: {"writes": []} for ch in channels}
|
||||
supersteps_by_ch = _parse_supersteps_since_last_snapshot_by_channel(
|
||||
target_tuple.metadata or {}, channels
|
||||
cursor_config: RunnableConfig | None = (
|
||||
target_tuple.parent_config if target_tuple else None
|
||||
)
|
||||
max_supersteps = max(supersteps_by_ch.values(), default=0)
|
||||
|
||||
chain: list[CheckpointTuple] = []
|
||||
has_reached_root = False
|
||||
cursor_config: RunnableConfig | None = target_tuple.parent_config
|
||||
while cursor_config is not None and len(chain) < max_supersteps:
|
||||
while cursor_config is not None and remaining:
|
||||
tup = await self.aget_tuple(cursor_config)
|
||||
if tup is None:
|
||||
break
|
||||
chain.append(tup)
|
||||
if tup.parent_config is None:
|
||||
has_reached_root = True
|
||||
break
|
||||
if tup.pending_writes:
|
||||
for write in reversed(tup.pending_writes):
|
||||
ch = write[1]
|
||||
if ch in remaining:
|
||||
collected_by_ch[ch].append(write)
|
||||
for ch in list(remaining):
|
||||
if ch in tup.checkpoint["channel_values"]:
|
||||
seed_by_ch[ch] = tup.checkpoint["channel_values"][ch]
|
||||
remaining.discard(ch)
|
||||
cursor_config = tup.parent_config
|
||||
return self._assemble_default_delta_history(
|
||||
channels, supersteps_by_ch, chain, has_reached_root, config
|
||||
)
|
||||
|
||||
def _assemble_default_delta_history(
|
||||
self,
|
||||
channels: Sequence[str],
|
||||
supersteps_by_ch: Mapping[str, int],
|
||||
chain: Sequence[CheckpointTuple],
|
||||
has_reached_root: bool,
|
||||
config: RunnableConfig,
|
||||
) -> dict[str, DeltaChannelHistory]:
|
||||
"""Slice the walked parent chain into per-channel histories.
|
||||
|
||||
For each channel the seed snapshot sits `supersteps` hops back along
|
||||
`chain` (newest first): the seed checkpoint is `chain[supersteps - 1]`
|
||||
and its `channel_values[ch]` is the seed value. The seed checkpoint's
|
||||
own writes are replayed on top of a `_DeltaSnapshot` seed but skipped
|
||||
for a migrated plain-value seed (the legacy non-delta blob already
|
||||
incorporates them).
|
||||
|
||||
When the chain is shorter than `supersteps` but the root was reached,
|
||||
the persisted chain is "compressed" relative to the logical superstep
|
||||
count (`durability="exit"`, or a thread that never snapshotted). The
|
||||
seed candidate is then the oldest checkpoint (`chain[-1]`); if its
|
||||
`channel_values[ch]` is absent the channel has no seed and replays
|
||||
the full chain on an empty baseline.
|
||||
"""
|
||||
# Imported lazily to avoid a hard checkpoint→serde-types coupling at
|
||||
# module import; only the delta surface needs the runtime check.
|
||||
from langgraph.checkpoint.serde.types import _DeltaSnapshot
|
||||
|
||||
thread_id = config["configurable"].get("thread_id")
|
||||
result: dict[str, DeltaChannelHistory] = {}
|
||||
for ch in channels:
|
||||
entry: DeltaChannelHistory = {"writes": []}
|
||||
bound = supersteps_by_ch.get(ch, 0)
|
||||
if bound <= 0:
|
||||
result[ch] = entry
|
||||
continue
|
||||
if len(chain) >= bound:
|
||||
seed_depth = bound
|
||||
elif has_reached_root:
|
||||
seed_depth = len(chain)
|
||||
else:
|
||||
logger.warning(
|
||||
"cannot find seed snapshot for delta channel "
|
||||
"(thread_id=%s, channel=%s)",
|
||||
thread_id,
|
||||
ch,
|
||||
)
|
||||
result[ch] = entry
|
||||
continue
|
||||
if seed_depth <= 0:
|
||||
result[ch] = entry
|
||||
continue
|
||||
chain_slice: Sequence[CheckpointTuple] = chain[:seed_depth]
|
||||
seed_tuple: CheckpointTuple | None = chain[seed_depth - 1]
|
||||
|
||||
skip_seed_checkpoint_writes = False
|
||||
channel_values = seed_tuple.checkpoint["channel_values"]
|
||||
if ch in channel_values:
|
||||
seed_value = channel_values[ch]
|
||||
entry["seed"] = seed_value
|
||||
skip_seed_checkpoint_writes = not isinstance(seed_value, _DeltaSnapshot)
|
||||
else:
|
||||
# No stored value at the oldest checkpoint → empty baseline,
|
||||
# so the chain's own writes are all replayed from empty.
|
||||
seed_tuple = None
|
||||
|
||||
collected: list[PendingWrite] = []
|
||||
# Chain is newest→oldest; replay oldest→newest.
|
||||
for tup in reversed(list(chain_slice)):
|
||||
if skip_seed_checkpoint_writes and tup is seed_tuple:
|
||||
continue
|
||||
for write in tup.pending_writes or []:
|
||||
if write[1] == ch:
|
||||
collected.append(write)
|
||||
entry["writes"] = collected
|
||||
entry: DeltaChannelHistory = {"writes": list(reversed(collected_by_ch[ch]))}
|
||||
if ch in seed_by_ch:
|
||||
entry["seed"] = seed_by_ch[ch]
|
||||
result[ch] = entry
|
||||
return result
|
||||
|
||||
@@ -882,7 +810,7 @@ def get_serializable_checkpoint_metadata(
|
||||
"""Get checkpoint metadata in a backwards-compatible manner."""
|
||||
checkpoint_metadata = get_checkpoint_metadata(config, metadata)
|
||||
if "writes" in checkpoint_metadata:
|
||||
checkpoint_metadata.pop("writes")
|
||||
cast(dict[str, Any], checkpoint_metadata).pop("writes")
|
||||
return checkpoint_metadata
|
||||
|
||||
|
||||
|
||||
@@ -23,9 +23,9 @@ from langgraph.checkpoint.base import (
|
||||
DeltaChannelHistory,
|
||||
PendingWrite,
|
||||
SerializerProtocol,
|
||||
_parse_supersteps_since_last_snapshot_by_channel,
|
||||
get_checkpoint_id,
|
||||
get_checkpoint_metadata,
|
||||
writes_sort_key,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -140,124 +140,94 @@ class InMemorySaver(
|
||||
result[k] = self.serde.loads_typed(vv)
|
||||
return result
|
||||
|
||||
def _ordered_writes(
|
||||
self, thread_id: str, checkpoint_ns: str, checkpoint_id: str
|
||||
) -> list[tuple[str, str, tuple[str, bytes], str]]:
|
||||
stored = self.writes.get((thread_id, checkpoint_ns, checkpoint_id), {})
|
||||
return [
|
||||
stored[k]
|
||||
for k in sorted(stored, key=lambda k: writes_sort_key(stored[k][3], *k))
|
||||
]
|
||||
|
||||
def get_delta_channel_history(
|
||||
self, *, config: RunnableConfig, channels: Sequence[str]
|
||||
) -> Mapping[str, DeltaChannelHistory]:
|
||||
"""Override: walk the parent chain ONCE for all requested channels.
|
||||
|
||||
Walk depth is driven by the target checkpoint's
|
||||
`counters_since_delta_snapshot[ch]` supersteps counter: each
|
||||
channel's seed snapshot sits exactly `supersteps` hops back along
|
||||
the parent chain. This locates seeds reliably even when they aren't
|
||||
a `_DeltaSnapshot` sentinel — e.g. a legacy plain-value blob from a
|
||||
thread migrated off a non-delta channel.
|
||||
Each channel terminates independently at the nearest ancestor
|
||||
whose stored blob is non-empty. Other channels keep walking until
|
||||
they find their own terminator or hit the root.
|
||||
|
||||
Pre-delta plain-value seeds subsume their own checkpoint's pending
|
||||
writes (the value already includes them), so those are skipped;
|
||||
`_DeltaSnapshot` seeds do not (the snapshot is the value AT that
|
||||
ancestor, prior to its own pending writes), so they are replayed.
|
||||
When the chain reaches the root short of `supersteps`, the snapshot
|
||||
was never persisted (implicit empty baseline) — replay the full
|
||||
chain with no seed.
|
||||
A blob is the value AT its ancestor, prior to the writes stored
|
||||
under that same ancestor (those writes produce its child, which
|
||||
is on the path to the target). This holds for `_DeltaSnapshot`
|
||||
blobs and for pre-delta plain values alike, so the seed
|
||||
ancestor's own writes are always collected. Writes at ancestors
|
||||
older than the seed are subsumed by the seed value and are never
|
||||
reached — the walk terminates there.
|
||||
"""
|
||||
if not channels:
|
||||
return {}
|
||||
# Imported lazily to avoid a hard checkpoint→serde-types coupling at
|
||||
# module import; only this override needs the runtime check.
|
||||
from langgraph.checkpoint.serde.types import _DeltaSnapshot
|
||||
|
||||
channels = list(channels)
|
||||
thread_id = config["configurable"]["thread_id"]
|
||||
checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
|
||||
ns_storage = self.storage.get(thread_id, {}).get(checkpoint_ns, {})
|
||||
checkpoint_id = get_checkpoint_id(config) or max(ns_storage, default="")
|
||||
|
||||
# Resolve the target checkpoint id + its metadata (for supersteps).
|
||||
checkpoint_id = get_checkpoint_id(config)
|
||||
if checkpoint_id is None:
|
||||
checkpoint_id = next(reversed(ns_storage), None)
|
||||
target_entry = ns_storage.get(checkpoint_id) if checkpoint_id else None
|
||||
if target_entry is None:
|
||||
return {ch: {"writes": []} for ch in channels}
|
||||
metadata = self.serde.loads_typed(target_entry[1])
|
||||
supersteps_by_ch = _parse_supersteps_since_last_snapshot_by_channel(
|
||||
metadata or {}, channels
|
||||
)
|
||||
max_supersteps = max(supersteps_by_ch.values(), default=0)
|
||||
|
||||
# Walk the parent chain (newest first) up to the deepest supersteps.
|
||||
chain: list[str] = []
|
||||
has_reached_root = False
|
||||
current: str | None = target_entry[2]
|
||||
while current is not None and len(chain) < max_supersteps:
|
||||
target_entry = ns_storage.get(checkpoint_id)
|
||||
current: str | None = target_entry[2] if target_entry is not None else None
|
||||
while current is not None:
|
||||
entry = ns_storage.get(current)
|
||||
if entry is None:
|
||||
break
|
||||
chain.append(current)
|
||||
parent = entry[2]
|
||||
if parent is None:
|
||||
has_reached_root = True
|
||||
break
|
||||
_, _, parent = entry
|
||||
current = parent
|
||||
|
||||
collected_by_ch: dict[str, list[PendingWrite]] = {c: [] for c in channels}
|
||||
seed_by_ch: dict[str, Any] = {}
|
||||
remaining: set[str] = set(channels)
|
||||
|
||||
for cp_id in chain:
|
||||
if not remaining:
|
||||
break
|
||||
entry = ns_storage.get(cp_id)
|
||||
ckpt = self.serde.loads_typed(entry[0]) if entry is not None else None
|
||||
|
||||
terminated_here: set[str] = set()
|
||||
blob_value_by_ch: dict[str, Any] = {}
|
||||
if ckpt is not None:
|
||||
versions = ckpt.get("channel_versions", {})
|
||||
for ch in remaining:
|
||||
ver = versions.get(ch)
|
||||
if ver is None:
|
||||
continue
|
||||
blob_entry = self.blobs.get((thread_id, checkpoint_ns, ch, ver))
|
||||
if blob_entry is None or blob_entry[0] == "empty":
|
||||
continue
|
||||
blob_value_by_ch[ch] = self.serde.loads_typed(blob_entry)
|
||||
terminated_here.add(ch)
|
||||
|
||||
for tid, ch, serialized, _ in reversed(
|
||||
self._ordered_writes(thread_id, checkpoint_ns, cp_id)
|
||||
):
|
||||
if ch not in remaining:
|
||||
continue
|
||||
collected_by_ch[ch].append(
|
||||
(tid, ch, self.serde.loads_typed(serialized))
|
||||
)
|
||||
|
||||
for ch in terminated_here:
|
||||
seed_by_ch[ch] = blob_value_by_ch[ch]
|
||||
remaining.discard(ch)
|
||||
|
||||
result: dict[str, DeltaChannelHistory] = {}
|
||||
for ch in channels:
|
||||
entry_h: DeltaChannelHistory = {"writes": []}
|
||||
bound = supersteps_by_ch.get(ch, 0)
|
||||
if bound <= 0:
|
||||
result[ch] = entry_h
|
||||
continue
|
||||
if len(chain) >= bound:
|
||||
seed_depth = bound
|
||||
elif has_reached_root:
|
||||
seed_depth = len(chain)
|
||||
else:
|
||||
logger.warning(
|
||||
"cannot find seed snapshot for delta channel "
|
||||
"(thread_id=%s, channel=%s)",
|
||||
thread_id,
|
||||
ch,
|
||||
)
|
||||
result[ch] = entry_h
|
||||
continue
|
||||
if seed_depth <= 0:
|
||||
result[ch] = entry_h
|
||||
continue
|
||||
chain_slice: list[str] = chain[:seed_depth]
|
||||
seed_cpid: str | None = chain[seed_depth - 1]
|
||||
|
||||
skip_seed_checkpoint_writes = False
|
||||
seed_entry = ns_storage.get(seed_cpid)
|
||||
if seed_entry is not None:
|
||||
ckpt = self.serde.loads_typed(seed_entry[0])
|
||||
ver = (ckpt.get("channel_versions") or {}).get(ch)
|
||||
blob_entry = (
|
||||
self.blobs.get((thread_id, checkpoint_ns, ch, ver))
|
||||
if ver is not None
|
||||
else None
|
||||
)
|
||||
if blob_entry is not None and blob_entry[0] != "empty":
|
||||
seed_value = self.serde.loads_typed(blob_entry)
|
||||
entry_h["seed"] = seed_value
|
||||
skip_seed_checkpoint_writes = not isinstance(
|
||||
seed_value, _DeltaSnapshot
|
||||
)
|
||||
else:
|
||||
# No stored value at the oldest checkpoint → empty
|
||||
# baseline; the chain's own writes replay from empty.
|
||||
seed_cpid = None
|
||||
|
||||
collected: list[PendingWrite] = []
|
||||
# Chain is newest→oldest; replay oldest→newest.
|
||||
for cp_id in reversed(chain_slice):
|
||||
if skip_seed_checkpoint_writes and cp_id == seed_cpid:
|
||||
continue
|
||||
step_writes = self.writes.get((thread_id, checkpoint_ns, cp_id), {})
|
||||
for (_task_id, _idx), (tid, w_ch, serialized, _) in sorted(
|
||||
step_writes.items()
|
||||
):
|
||||
if w_ch == ch:
|
||||
collected.append((tid, ch, self.serde.loads_typed(serialized)))
|
||||
entry_h["writes"] = collected
|
||||
entry_h: DeltaChannelHistory = {
|
||||
"writes": list(reversed(collected_by_ch[ch]))
|
||||
}
|
||||
if ch in seed_by_ch:
|
||||
entry_h["seed"] = seed_by_ch[ch]
|
||||
result[ch] = entry_h
|
||||
return result
|
||||
|
||||
@@ -285,7 +255,7 @@ class InMemorySaver(
|
||||
if checkpoint_id := get_checkpoint_id(config):
|
||||
if saved := self.storage[thread_id][checkpoint_ns].get(checkpoint_id):
|
||||
checkpoint, metadata, parent_checkpoint_id = saved
|
||||
writes = self.writes[(thread_id, checkpoint_ns, checkpoint_id)].values()
|
||||
writes = self._ordered_writes(thread_id, checkpoint_ns, checkpoint_id)
|
||||
checkpoint_: Checkpoint = self.serde.loads_typed(checkpoint)
|
||||
return CheckpointTuple(
|
||||
config=config,
|
||||
@@ -315,7 +285,7 @@ class InMemorySaver(
|
||||
if checkpoints := self.storage[thread_id][checkpoint_ns]:
|
||||
checkpoint_id = max(checkpoints.keys())
|
||||
checkpoint, metadata, parent_checkpoint_id = checkpoints[checkpoint_id]
|
||||
writes = self.writes[(thread_id, checkpoint_ns, checkpoint_id)].values()
|
||||
writes = self._ordered_writes(thread_id, checkpoint_ns, checkpoint_id)
|
||||
checkpoint_ = self.serde.loads_typed(checkpoint)
|
||||
return CheckpointTuple(
|
||||
config={
|
||||
@@ -418,9 +388,9 @@ class InMemorySaver(
|
||||
elif limit is not None:
|
||||
limit -= 1
|
||||
|
||||
writes = self.writes[
|
||||
(thread_id, checkpoint_ns, checkpoint_id)
|
||||
].values()
|
||||
writes = self._ordered_writes(
|
||||
thread_id, checkpoint_ns, checkpoint_id
|
||||
)
|
||||
|
||||
checkpoint_: Checkpoint = self.serde.loads_typed(checkpoint)
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ logger = logging.getLogger(__name__)
|
||||
_MAX_WARNED_TYPES = 1000
|
||||
_warned_unregistered_types: set[tuple[str, str]] = set()
|
||||
_warned_blocked_types: set[tuple[str, str]] = set()
|
||||
_warned_unreconstructable_types: set[tuple[str, str]] = set()
|
||||
|
||||
|
||||
def _is_safe_json_type(id_list: list[str]) -> bool:
|
||||
@@ -79,6 +80,27 @@ def _warn_once(
|
||||
logger.warning(msg, *args)
|
||||
|
||||
|
||||
def _reconstruction_fallback(tup: Any, exc: Exception) -> Any:
|
||||
"""Return the serialized payload of an object that could not be rebuilt.
|
||||
|
||||
Returning `None` here would silently erase the value from restored state.
|
||||
"""
|
||||
try:
|
||||
module, name, payload = tup[0], tup[1], tup[2]
|
||||
except Exception:
|
||||
return None
|
||||
_warn_once(
|
||||
_warned_unreconstructable_types,
|
||||
(str(module), str(name)),
|
||||
"Could not reconstruct %s.%s from checkpoint (%s); "
|
||||
"returning its serialized data instead.",
|
||||
module,
|
||||
name,
|
||||
type(exc).__name__,
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
class JsonPlusSerializer(SerializerProtocol):
|
||||
"""Serializer that uses ormsgpack, with optional fallbacks.
|
||||
|
||||
@@ -638,6 +660,7 @@ def _create_msgpack_ext_hook(
|
||||
)
|
||||
)
|
||||
elif code == EXT_CONSTRUCTOR_SINGLE_ARG:
|
||||
tup = None
|
||||
try:
|
||||
tup = ormsgpack.unpackb(
|
||||
data, ext_hook=ext_hook, option=ormsgpack.OPT_NON_STR_KEYS
|
||||
@@ -649,9 +672,10 @@ def _create_msgpack_ext_hook(
|
||||
return tup[2]
|
||||
# module, name, arg
|
||||
return getattr(importlib.import_module(tup[0]), tup[1])(tup[2])
|
||||
except Exception:
|
||||
return None
|
||||
except Exception as exc:
|
||||
return _reconstruction_fallback(tup, exc)
|
||||
elif code == EXT_CONSTRUCTOR_POS_ARGS:
|
||||
tup = None
|
||||
try:
|
||||
tup = ormsgpack.unpackb(
|
||||
data, ext_hook=ext_hook, option=ormsgpack.OPT_NON_STR_KEYS
|
||||
@@ -662,9 +686,10 @@ def _create_msgpack_ext_hook(
|
||||
return _send_from_args(tup[2])
|
||||
# module, name, args
|
||||
return getattr(importlib.import_module(tup[0]), tup[1])(*tup[2])
|
||||
except Exception:
|
||||
return None
|
||||
except Exception as exc:
|
||||
return _reconstruction_fallback(tup, exc)
|
||||
elif code == EXT_CONSTRUCTOR_KW_ARGS:
|
||||
tup = None
|
||||
try:
|
||||
tup = ormsgpack.unpackb(
|
||||
data, ext_hook=ext_hook, option=ormsgpack.OPT_NON_STR_KEYS
|
||||
@@ -673,9 +698,10 @@ def _create_msgpack_ext_hook(
|
||||
return tup[2]
|
||||
# module, name, kwargs
|
||||
return getattr(importlib.import_module(tup[0]), tup[1])(**tup[2])
|
||||
except Exception:
|
||||
return None
|
||||
except Exception as exc:
|
||||
return _reconstruction_fallback(tup, exc)
|
||||
elif code == EXT_METHOD_SINGLE_ARG:
|
||||
tup = None
|
||||
try:
|
||||
tup = ormsgpack.unpackb(
|
||||
data, ext_hook=ext_hook, option=ormsgpack.OPT_NON_STR_KEYS
|
||||
@@ -686,8 +712,8 @@ def _create_msgpack_ext_hook(
|
||||
return getattr(
|
||||
getattr(importlib.import_module(tup[0]), tup[1]), tup[3]
|
||||
)(tup[2])
|
||||
except Exception:
|
||||
return None
|
||||
except Exception as exc:
|
||||
return _reconstruction_fallback(tup, exc)
|
||||
elif code == EXT_PYDANTIC_V1:
|
||||
try:
|
||||
tup = ormsgpack.unpackb(
|
||||
|
||||
@@ -12,7 +12,7 @@ Core types:
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Iterable
|
||||
from collections.abc import Iterable, Mapping
|
||||
from datetime import datetime
|
||||
from typing import (
|
||||
Any,
|
||||
@@ -473,10 +473,10 @@ class PutOp(NamedTuple):
|
||||
the full path would effectively be `"documents/user123/report1"`
|
||||
"""
|
||||
|
||||
value: dict[str, Any] | None
|
||||
value: Mapping[str, Any] | None
|
||||
"""The data to store, or `None` to mark the item for deletion.
|
||||
|
||||
The value must be a dictionary with string keys and JSON-serializable values.
|
||||
The value must be a mapping with string keys and JSON-serializable values.
|
||||
Setting this to `None` signals that the item should be deleted.
|
||||
|
||||
Example:
|
||||
@@ -552,6 +552,14 @@ class TTLConfig(TypedDict, total=False):
|
||||
This can be overridden per-operation by explicitly setting `refresh_ttl`.
|
||||
Defaults to `True` if not configured.
|
||||
"""
|
||||
omit_expired: bool
|
||||
"""Whether to omit expired items from read operations.
|
||||
|
||||
If `True`, and if the store supports this option, expired items will not be
|
||||
returned by `GET` or `SEARCH`, or included in namespace listings, even before
|
||||
a TTL sweep deletes them.
|
||||
Defaults to `False` if not configured.
|
||||
"""
|
||||
default_ttl: float | None
|
||||
"""Default TTL (time-to-live) in minutes for new items.
|
||||
|
||||
@@ -763,7 +771,12 @@ class BaseStore(ABC):
|
||||
|
||||
Returns:
|
||||
The retrieved item or `None` if not found.
|
||||
|
||||
Raises:
|
||||
InvalidNamespaceError: If a namespace label is empty, is not a string,
|
||||
or contains a period (`.`).
|
||||
"""
|
||||
_validate_namespace_labels(namespace)
|
||||
return self.batch(
|
||||
[GetOp(namespace, str(key), _ensure_refresh(self.ttl_config, refresh_ttl))]
|
||||
)[0]
|
||||
@@ -793,6 +806,10 @@ class BaseStore(ABC):
|
||||
Returns:
|
||||
List of items matching the search criteria.
|
||||
|
||||
Raises:
|
||||
InvalidNamespaceError: If a `namespace_prefix` label is empty, is not a
|
||||
string, or contains a period (`.`).
|
||||
|
||||
???+ example "Examples"
|
||||
|
||||
Basic filtering:
|
||||
@@ -832,6 +849,7 @@ class BaseStore(ABC):
|
||||
Natural language search support depends on your store implementation
|
||||
and requires proper embedding configuration.
|
||||
"""
|
||||
_validate_namespace_labels(namespace_prefix)
|
||||
return self.batch(
|
||||
[
|
||||
SearchOp(
|
||||
@@ -849,7 +867,7 @@ class BaseStore(ABC):
|
||||
self,
|
||||
namespace: tuple[str, ...],
|
||||
key: str,
|
||||
value: dict[str, Any],
|
||||
value: Mapping[str, Any],
|
||||
index: Literal[False] | list[str] | None = None,
|
||||
*,
|
||||
ttl: float | None | NotProvided = NOT_PROVIDED,
|
||||
@@ -861,7 +879,7 @@ class BaseStore(ABC):
|
||||
Example: `("documents", "user123")`
|
||||
key: Unique identifier within the namespace. Together with namespace forms
|
||||
the complete path to the item.
|
||||
value: Dictionary containing the item's data. Must contain string keys
|
||||
value: Mapping containing the item's data. Must contain string keys
|
||||
and JSON-serializable values.
|
||||
index: Controls how the item's fields are indexed for search:
|
||||
|
||||
@@ -879,6 +897,11 @@ class BaseStore(ABC):
|
||||
By default, the expiration timer refreshes on both read operations (get/search)
|
||||
and write operations (put/update), whenever the item is included in the operation.
|
||||
|
||||
Raises:
|
||||
InvalidNamespaceError: If the namespace is empty, its root label is
|
||||
`"langgraph"`, or a label is empty, is not a string, or contains a
|
||||
period (`.`).
|
||||
|
||||
Note:
|
||||
Indexing support depends on your store implementation.
|
||||
If you do not initialize the store with indexing capabilities,
|
||||
@@ -932,7 +955,12 @@ class BaseStore(ABC):
|
||||
Args:
|
||||
namespace: Hierarchical path for the item.
|
||||
key: Unique identifier within the namespace.
|
||||
|
||||
Raises:
|
||||
InvalidNamespaceError: If a namespace label is empty, is not a string,
|
||||
or contains a period (`.`).
|
||||
"""
|
||||
_validate_namespace_labels(namespace)
|
||||
self.batch([PutOp(namespace, str(key), None, ttl=None)])
|
||||
|
||||
def list_namespaces(
|
||||
@@ -961,6 +989,10 @@ class BaseStore(ABC):
|
||||
A list of namespace tuples that match the criteria. Each tuple represents a
|
||||
full namespace path up to `max_depth`.
|
||||
|
||||
Raises:
|
||||
InvalidNamespaceError: If a `prefix` or `suffix` label is empty, is not a
|
||||
string, or contains a period (`.`).
|
||||
|
||||
???+ example "Examples":
|
||||
|
||||
Setting `max_depth=3`. Given the namespaces:
|
||||
@@ -976,6 +1008,8 @@ class BaseStore(ABC):
|
||||
# [("a", "b", "c"), ("a", "b", "d"), ("a", "b", "f")]
|
||||
```
|
||||
"""
|
||||
_validate_namespace_labels(prefix or ())
|
||||
_validate_namespace_labels(suffix or ())
|
||||
match_conditions = []
|
||||
if prefix:
|
||||
match_conditions.append(MatchCondition(match_type="prefix", path=prefix))
|
||||
@@ -1005,7 +1039,12 @@ class BaseStore(ABC):
|
||||
|
||||
Returns:
|
||||
The retrieved item or `None` if not found.
|
||||
|
||||
Raises:
|
||||
InvalidNamespaceError: If a namespace label is empty, is not a string,
|
||||
or contains a period (`.`).
|
||||
"""
|
||||
_validate_namespace_labels(namespace)
|
||||
return (
|
||||
await self.abatch(
|
||||
[
|
||||
@@ -1044,6 +1083,10 @@ class BaseStore(ABC):
|
||||
Returns:
|
||||
List of items matching the search criteria.
|
||||
|
||||
Raises:
|
||||
InvalidNamespaceError: If a `namespace_prefix` label is empty, is not a
|
||||
string, or contains a period (`.`).
|
||||
|
||||
???+ example "Examples"
|
||||
|
||||
Basic filtering:
|
||||
@@ -1083,6 +1126,7 @@ class BaseStore(ABC):
|
||||
Natural language search support depends on your store implementation
|
||||
and requires proper embedding configuration.
|
||||
"""
|
||||
_validate_namespace_labels(namespace_prefix)
|
||||
return (
|
||||
await self.abatch(
|
||||
[
|
||||
@@ -1102,7 +1146,7 @@ class BaseStore(ABC):
|
||||
self,
|
||||
namespace: tuple[str, ...],
|
||||
key: str,
|
||||
value: dict[str, Any],
|
||||
value: Mapping[str, Any],
|
||||
index: Literal[False] | list[str] | None = None,
|
||||
*,
|
||||
ttl: float | None | NotProvided = NOT_PROVIDED,
|
||||
@@ -1114,7 +1158,7 @@ class BaseStore(ABC):
|
||||
Example: `("documents", "user123")`
|
||||
key: Unique identifier within the namespace. Together with namespace forms
|
||||
the complete path to the item.
|
||||
value: Dictionary containing the item's data. Must contain string keys
|
||||
value: Mapping containing the item's data. Must contain string keys
|
||||
and JSON-serializable values.
|
||||
index: Controls how the item's fields are indexed for search:
|
||||
|
||||
@@ -1132,6 +1176,11 @@ class BaseStore(ABC):
|
||||
By default, the expiration timer refreshes on both read operations (get/search)
|
||||
and write operations (put/update), whenever the item is included in the operation.
|
||||
|
||||
Raises:
|
||||
InvalidNamespaceError: If the namespace is empty, its root label is
|
||||
`"langgraph"`, or a label is empty, is not a string, or contains a
|
||||
period (`.`).
|
||||
|
||||
Note:
|
||||
Indexing support depends on your store implementation.
|
||||
If you do not initialize the store with indexing capabilities,
|
||||
@@ -1193,7 +1242,12 @@ class BaseStore(ABC):
|
||||
Args:
|
||||
namespace: Hierarchical path for the item.
|
||||
key: Unique identifier within the namespace.
|
||||
|
||||
Raises:
|
||||
InvalidNamespaceError: If a namespace label is empty, is not a string,
|
||||
or contains a period (`.`).
|
||||
"""
|
||||
_validate_namespace_labels(namespace)
|
||||
await self.abatch([PutOp(namespace, str(key), None)])
|
||||
|
||||
async def alist_namespaces(
|
||||
@@ -1222,6 +1276,10 @@ class BaseStore(ABC):
|
||||
A list of namespace tuples that match the criteria. Each tuple represents a
|
||||
full namespace path up to `max_depth`.
|
||||
|
||||
Raises:
|
||||
InvalidNamespaceError: If a `prefix` or `suffix` label is empty, is not a
|
||||
string, or contains a period (`.`).
|
||||
|
||||
???+ example "Examples"
|
||||
|
||||
Setting `max_depth=3` with existing namespaces:
|
||||
@@ -1237,6 +1295,8 @@ class BaseStore(ABC):
|
||||
# Returns: [("a", "b", "c"), ("a", "b", "d"), ("a", "b", "f")]
|
||||
```
|
||||
"""
|
||||
_validate_namespace_labels(prefix or ())
|
||||
_validate_namespace_labels(suffix or ())
|
||||
match_conditions = []
|
||||
if prefix:
|
||||
match_conditions.append(MatchCondition(match_type="prefix", path=prefix))
|
||||
@@ -1255,6 +1315,14 @@ class BaseStore(ABC):
|
||||
def _validate_namespace(namespace: tuple[str, ...]) -> None:
|
||||
if not namespace:
|
||||
raise InvalidNamespaceError("Namespace cannot be empty.")
|
||||
_validate_namespace_labels(namespace)
|
||||
if namespace[0] == "langgraph":
|
||||
raise InvalidNamespaceError(
|
||||
f'Root label for namespace cannot be "langgraph". Got: {namespace}'
|
||||
)
|
||||
|
||||
|
||||
def _validate_namespace_labels(namespace: tuple[str, ...]) -> None:
|
||||
for label in namespace:
|
||||
if not isinstance(label, str):
|
||||
raise InvalidNamespaceError(
|
||||
@@ -1269,10 +1337,27 @@ def _validate_namespace(namespace: tuple[str, ...]) -> None:
|
||||
raise InvalidNamespaceError(
|
||||
f"Namespace labels cannot be empty strings. Got {label} in {namespace}"
|
||||
)
|
||||
if namespace[0] == "langgraph":
|
||||
raise InvalidNamespaceError(
|
||||
f'Root label for namespace cannot be "langgraph". Got: {namespace}'
|
||||
)
|
||||
|
||||
|
||||
def validate_op_namespace(op: Op) -> None:
|
||||
"""Validate the namespace labels an op carries before a store executes it.
|
||||
|
||||
`BaseStore` methods check labels before batching, but ops passed directly to
|
||||
`batch`/`abatch` skip those methods. Stores that serialize namespaces as
|
||||
delimited text should call this for every op they execute, so a label such
|
||||
as `"foo.bar"` cannot address the namespace `("foo", "bar")`.
|
||||
|
||||
Raises:
|
||||
InvalidNamespaceError: If a label is empty, is not a string, or contains
|
||||
a period (`.`).
|
||||
"""
|
||||
if isinstance(op, (GetOp, PutOp)):
|
||||
_validate_namespace_labels(op.namespace)
|
||||
elif isinstance(op, SearchOp):
|
||||
_validate_namespace_labels(op.namespace_prefix)
|
||||
elif isinstance(op, ListNamespacesOp):
|
||||
for condition in op.match_conditions or ():
|
||||
_validate_namespace_labels(condition.path)
|
||||
|
||||
|
||||
def _ensure_refresh(
|
||||
@@ -1311,4 +1396,5 @@ __all__ = [
|
||||
"ensure_embeddings",
|
||||
"tokenize_path",
|
||||
"get_text_at_path",
|
||||
"validate_op_namespace",
|
||||
]
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import functools
|
||||
import weakref
|
||||
from collections.abc import Callable, Iterable
|
||||
from collections.abc import Callable, Iterable, Mapping
|
||||
from typing import Any, Literal, TypeVar
|
||||
|
||||
from langgraph.store.base import (
|
||||
@@ -25,6 +25,7 @@ from langgraph.store.base import (
|
||||
_ensure_refresh,
|
||||
_ensure_ttl,
|
||||
_validate_namespace,
|
||||
_validate_namespace_labels,
|
||||
)
|
||||
|
||||
F = TypeVar("F", bound=Callable)
|
||||
@@ -86,6 +87,7 @@ class AsyncBatchedBaseStore(BaseStore):
|
||||
*,
|
||||
refresh_ttl: bool | None = None,
|
||||
) -> Item | None:
|
||||
_validate_namespace_labels(namespace)
|
||||
self._ensure_task()
|
||||
fut = self._loop.create_future()
|
||||
self._aqueue.put_nowait(
|
||||
@@ -111,6 +113,7 @@ class AsyncBatchedBaseStore(BaseStore):
|
||||
offset: int = 0,
|
||||
refresh_ttl: bool | None = None,
|
||||
) -> list[SearchItem]:
|
||||
_validate_namespace_labels(namespace_prefix)
|
||||
self._ensure_task()
|
||||
fut = self._loop.create_future()
|
||||
self._aqueue.put_nowait(
|
||||
@@ -132,7 +135,7 @@ class AsyncBatchedBaseStore(BaseStore):
|
||||
self,
|
||||
namespace: tuple[str, ...],
|
||||
key: str,
|
||||
value: dict[str, Any],
|
||||
value: Mapping[str, Any],
|
||||
index: Literal[False] | list[str] | None = None,
|
||||
*,
|
||||
ttl: float | None | NotProvided = NOT_PROVIDED,
|
||||
@@ -155,6 +158,7 @@ class AsyncBatchedBaseStore(BaseStore):
|
||||
namespace: tuple[str, ...],
|
||||
key: str,
|
||||
) -> None:
|
||||
_validate_namespace_labels(namespace)
|
||||
self._ensure_task()
|
||||
fut = self._loop.create_future()
|
||||
self._aqueue.put_nowait((fut, PutOp(namespace, key, None)))
|
||||
@@ -169,6 +173,8 @@ class AsyncBatchedBaseStore(BaseStore):
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> list[tuple[str, ...]]:
|
||||
_validate_namespace_labels(prefix or ())
|
||||
_validate_namespace_labels(suffix or ())
|
||||
self._ensure_task()
|
||||
fut = self._loop.create_future()
|
||||
match_conditions = []
|
||||
@@ -231,7 +237,7 @@ class AsyncBatchedBaseStore(BaseStore):
|
||||
self,
|
||||
namespace: tuple[str, ...],
|
||||
key: str,
|
||||
value: dict[str, Any],
|
||||
value: Mapping[str, Any],
|
||||
index: Literal[False] | list[str] | None = None,
|
||||
*,
|
||||
ttl: float | None | NotProvided = NOT_PROVIDED,
|
||||
|
||||
@@ -11,7 +11,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import functools
|
||||
import json
|
||||
from collections.abc import Awaitable, Callable, Sequence
|
||||
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
||||
from typing import Any
|
||||
|
||||
from langchain_core.embeddings import Embeddings
|
||||
@@ -244,6 +244,9 @@ def get_text_at_path(obj: Any, path: str | list[str]) -> list[str]:
|
||||
- Multi-field selection: "{field1,field2}"
|
||||
- Nested paths in multi-field: "{field1,nested.field2}"
|
||||
"""
|
||||
if isinstance(obj, Mapping) and not isinstance(obj, dict):
|
||||
obj = dict(obj)
|
||||
|
||||
if not path or path == "$":
|
||||
return [json.dumps(obj, sort_keys=True, ensure_ascii=False)]
|
||||
|
||||
|
||||
@@ -408,7 +408,7 @@ class InMemoryStore(BaseStore):
|
||||
self._vectors[namespace].pop(key, None)
|
||||
else:
|
||||
self._data[namespace][key] = Item(
|
||||
value=op.value,
|
||||
value=dict(op.value),
|
||||
key=key,
|
||||
namespace=namespace,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "langgraph-checkpoint"
|
||||
version = "4.1.1"
|
||||
version = "4.3.0"
|
||||
description = "Library with base interfaces for LangGraph checkpoint savers."
|
||||
authors = []
|
||||
requires-python = ">=3.10"
|
||||
@@ -59,9 +59,15 @@ lint.select = [
|
||||
"UP", # pyupgrade
|
||||
"B", # flake8-bugbear
|
||||
"I", # isort
|
||||
"PLC0415", # import-outside-top-level
|
||||
"RUF100", # unused noqa directive
|
||||
"UP", # pyupgrade
|
||||
]
|
||||
lint.ignore = ["E501", "B008"]
|
||||
# PLC0415 (import-outside-top-level) is enforced in tests only. Library code
|
||||
# still has deferred imports that have not been reviewed, so it stays exempt
|
||||
# for now.
|
||||
lint.per-file-ignores = { "langgraph/**" = ["PLC0415"] }
|
||||
target-version = "py310"
|
||||
|
||||
[tool.ty.rules]
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
"""Run delta-channel conformance capabilities against InMemorySaver."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
conformance = pytest.importorskip(
|
||||
"langgraph.checkpoint.conformance",
|
||||
reason="langgraph-checkpoint-conformance not installed",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delta_channel_conformance():
|
||||
from langgraph.checkpoint.conformance import validate
|
||||
from langgraph.checkpoint.conformance.initializer import checkpointer_test
|
||||
|
||||
from langgraph.checkpoint.memory import InMemorySaver
|
||||
|
||||
@checkpointer_test(name="InMemorySaver")
|
||||
async def mem_saver():
|
||||
yield InMemorySaver()
|
||||
|
||||
report = await validate(
|
||||
mem_saver,
|
||||
capabilities={
|
||||
"delta_channel_history",
|
||||
},
|
||||
)
|
||||
for cap, result in report.results.items():
|
||||
if result.passed is False:
|
||||
details = "\n".join(result.failures or [])
|
||||
pytest.fail(f"Capability {cap} failed:\n{details}")
|
||||
@@ -307,8 +307,6 @@ class TestWithMsgpackAllowlistEncrypted:
|
||||
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
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import dataclasses
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import pickle
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
import uuid
|
||||
from collections import deque
|
||||
from datetime import date, datetime, time, timezone
|
||||
@@ -18,7 +22,7 @@ import ormsgpack
|
||||
import pandas as pd
|
||||
import pytest
|
||||
from langchain_core.documents.base import Document
|
||||
from langchain_core.messages import HumanMessage
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
from pydantic import BaseModel, SecretStr
|
||||
from pydantic.v1 import BaseModel as BaseModelV1
|
||||
from pydantic.v1 import SecretStr as SecretStrV1
|
||||
@@ -30,12 +34,16 @@ from langgraph.checkpoint.serde.event_hooks import (
|
||||
register_serde_event_listener,
|
||||
)
|
||||
from langgraph.checkpoint.serde.jsonplus import (
|
||||
EXT_CONSTRUCTOR_KW_ARGS,
|
||||
EXT_CONSTRUCTOR_POS_ARGS,
|
||||
EXT_CONSTRUCTOR_SINGLE_ARG,
|
||||
EXT_METHOD_SINGLE_ARG,
|
||||
InvalidModuleError,
|
||||
JsonPlusSerializer,
|
||||
_msgpack_enc,
|
||||
_msgpack_ext_hook_to_json,
|
||||
_warned_blocked_types,
|
||||
_warned_unreconstructable_types,
|
||||
_warned_unregistered_types,
|
||||
)
|
||||
from langgraph.store.base import Item
|
||||
@@ -341,7 +349,6 @@ def test_lc2_json_safe_type_revives_without_allowlist() -> None:
|
||||
constructor dicts. Resuming those threads must reconstruct proper BaseMessage objects
|
||||
rather than returning raw dicts that cause MESSAGE_COERCION_FAILURE in add_messages.
|
||||
"""
|
||||
from langchain_core.messages import AIMessage
|
||||
|
||||
serde = JsonPlusSerializer() # default: _allowed_json_modules=None
|
||||
|
||||
@@ -410,7 +417,6 @@ def test_lc2_json_method_field_is_ignored() -> None:
|
||||
to that method: the result is whatever ``AIMessage(*args, **kwargs)`` would
|
||||
produce, which proves the default constructor ran instead of ``parse_raw``.
|
||||
"""
|
||||
from langchain_core.messages import AIMessage
|
||||
|
||||
serde = JsonPlusSerializer()
|
||||
load = {
|
||||
@@ -436,7 +442,6 @@ def test_lc2_json_method_field_is_ignored_for_allowlisted_types() -> None:
|
||||
method dispatch as a side effect. Revival is restricted to the default
|
||||
constructor regardless of how the class reached the revival path.
|
||||
"""
|
||||
from langchain_core.messages import AIMessage
|
||||
|
||||
serde = JsonPlusSerializer(
|
||||
allowed_json_modules=[("langchain_core.messages.ai", "AIMessage")]
|
||||
@@ -455,7 +460,6 @@ def test_lc2_json_method_field_is_ignored_for_allowlisted_types() -> None:
|
||||
|
||||
def test_lc2_json_safe_type_init_still_works() -> None:
|
||||
"""SAFE-type lc=2 revival without a `method` field still constructs the class."""
|
||||
from langchain_core.messages import AIMessage
|
||||
|
||||
serde = JsonPlusSerializer()
|
||||
load = {
|
||||
@@ -479,7 +483,6 @@ def test_lc2_json_legacy_pydantic_method_list_falls_back_to_default() -> None:
|
||||
this shape continue to revive correctly as long as the default constructor
|
||||
accepts the serialized kwargs.
|
||||
"""
|
||||
from langchain_core.messages import AIMessage
|
||||
|
||||
serde = JsonPlusSerializer()
|
||||
load = {
|
||||
@@ -551,9 +554,6 @@ def test_lc2_json_safe_type_pickle_payload_does_not_execute() -> None:
|
||||
With method dispatch removed from `_revive_lc2`, the gadget bytes are never
|
||||
passed to `parse_raw` and therefore never reach `pickle.loads`.
|
||||
"""
|
||||
import os
|
||||
import pickle
|
||||
import tempfile
|
||||
|
||||
marker = tempfile.NamedTemporaryFile(
|
||||
prefix="lc2_block_proof_", suffix=".out", delete=False
|
||||
@@ -826,6 +826,7 @@ def _reset_warned_types() -> None:
|
||||
# a fresh slate and assertions about warning emission are stable.
|
||||
_warned_unregistered_types.clear()
|
||||
_warned_blocked_types.clear()
|
||||
_warned_unreconstructable_types.clear()
|
||||
|
||||
|
||||
def test_msgpack_pydantic_warns_by_default(caplog: pytest.LogCaptureFixture) -> None:
|
||||
@@ -1235,3 +1236,58 @@ def test_msgpack_nested_pydantic_serializes_as_dict(
|
||||
# No blocking should occur - inner is serialized as dict, not ext
|
||||
assert "blocked" not in caplog.text.lower()
|
||||
assert result == obj
|
||||
|
||||
|
||||
def test_msgpack_dataclass_from_removed_module_restores_payload(
|
||||
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
@dataclasses.dataclass
|
||||
class SavedObject:
|
||||
value: int
|
||||
|
||||
SavedObject.__module__ = "removed_module"
|
||||
module = types.ModuleType("removed_module")
|
||||
module.SavedObject = SavedObject
|
||||
monkeypatch.setitem(sys.modules, "removed_module", module)
|
||||
serde = JsonPlusSerializer(
|
||||
allowed_msgpack_modules=[("removed_module", "SavedObject")]
|
||||
)
|
||||
dumped = serde.dumps_typed({"state": SavedObject(123)})
|
||||
assert serde.loads_typed(dumped) == {"state": SavedObject(123)}
|
||||
|
||||
monkeypatch.delitem(sys.modules, "removed_module")
|
||||
caplog.set_level(logging.WARNING, logger="langgraph.checkpoint.serde.jsonplus")
|
||||
|
||||
assert serde.loads_typed(dumped) == {"state": {"value": 123}}
|
||||
assert (
|
||||
"could not reconstruct removed_module.savedobject from checkpoint "
|
||||
"(modulenotfounderror)" in caplog.text.lower()
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("code", "tup", "expected"),
|
||||
[
|
||||
(EXT_CONSTRUCTOR_SINGLE_ARG, ("missing_module", "Thing", "x"), "x"),
|
||||
(EXT_CONSTRUCTOR_POS_ARGS, ("missing_module", "Thing", [1, 2]), [1, 2]),
|
||||
(
|
||||
EXT_CONSTRUCTOR_KW_ARGS,
|
||||
("missing_module", "Thing", {"value": 123}),
|
||||
{"value": 123},
|
||||
),
|
||||
(
|
||||
EXT_METHOD_SINGLE_ARG,
|
||||
("datetime", "datetime", "not-a-date", "fromisoformat"),
|
||||
"not-a-date",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_msgpack_failed_reconstruction_returns_payload(
|
||||
code: int, tup: tuple, expected: object
|
||||
) -> None:
|
||||
serde = JsonPlusSerializer(allowed_msgpack_modules=True)
|
||||
payload = ormsgpack.packb(
|
||||
ormsgpack.Ext(code, _msgpack_enc(tup)), option=ormsgpack.OPT_NON_STR_KEYS
|
||||
)
|
||||
|
||||
assert serde.loads_typed(("msgpack", payload)) == expected
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
@@ -351,12 +352,9 @@ class TestInMemorySaverDeltaChannel:
|
||||
cp1["id"] = "cp1"
|
||||
cp2 = empty_checkpoint()
|
||||
cp2["id"] = "cp2"
|
||||
# Target (cp2) carries the supersteps counter; no snapshot was ever
|
||||
# taken, so the walk runs back to the root → no seed, empty baseline.
|
||||
cp2_md = {"counters_since_delta_snapshot": {channel: [1, 2]}}
|
||||
saver.storage[thread_id][ns] = {
|
||||
"cp1": (serde.dumps_typed(cp1), serde.dumps_typed({}), None),
|
||||
"cp2": (serde.dumps_typed(cp2), serde.dumps_typed(cp2_md), "cp1"),
|
||||
"cp2": (serde.dumps_typed(cp2), serde.dumps_typed({}), "cp1"),
|
||||
}
|
||||
# Writes stored at cp1 produced the cp1 snapshot; part of history.
|
||||
saver.writes[(thread_id, ns, "cp1")][("task1", 0)] = (
|
||||
@@ -422,6 +420,19 @@ class TestInMemorySaverDeltaChannel:
|
||||
assert "seed" not in result
|
||||
assert result["writes"] == []
|
||||
|
||||
def test_get_channel_writes_without_checkpoint_id_reads_the_latest(self) -> None:
|
||||
saver = InMemorySaver()
|
||||
thread: RunnableConfig = {
|
||||
"configurable": {"thread_id": "t1", "checkpoint_ns": ""}
|
||||
}
|
||||
parent = saver.put(thread, empty_checkpoint(), {}, {})
|
||||
saver.put_writes(parent, [("messages", "hi")], "task1")
|
||||
saver.put(parent, empty_checkpoint(), {}, {})
|
||||
|
||||
result = saver.get_delta_channel_history(config=thread, channels=["messages"])
|
||||
|
||||
assert result == {"messages": {"writes": [("task1", "messages", "hi")]}}
|
||||
|
||||
|
||||
class TestBaseFallbackGetChannelWrites:
|
||||
"""Exercises the `BaseCheckpointSaver.get_delta_channel_history` default
|
||||
@@ -459,15 +470,10 @@ class TestBaseFallbackGetChannelWrites:
|
||||
cp1["id"] = "00000000000000000000000000000002.0000000000000000"
|
||||
cp2 = empty_checkpoint()
|
||||
cp2["id"] = "00000000000000000000000000000003.0000000000000000"
|
||||
# The target (cp2) carries `counters_since_delta_snapshot` — the
|
||||
# supersteps-since-last-snapshot driving the ancestor-walk depth. No
|
||||
# snapshot was ever taken, so the count runs back past the oldest
|
||||
# persisted checkpoint (root) → implicit empty baseline, no seed.
|
||||
cp2_md = {"counters_since_delta_snapshot": {channel: [2, 3]}}
|
||||
saver.storage[thread_id][ns] = {
|
||||
cp0["id"]: (serde.dumps_typed(cp0), serde.dumps_typed({}), None),
|
||||
cp1["id"]: (serde.dumps_typed(cp1), serde.dumps_typed({}), cp0["id"]),
|
||||
cp2["id"]: (serde.dumps_typed(cp2), serde.dumps_typed(cp2_md), cp1["id"]),
|
||||
cp2["id"]: (serde.dumps_typed(cp2), serde.dumps_typed({}), cp1["id"]),
|
||||
}
|
||||
# Writes under cp0 produced cp1's state; writes under cp1 produced cp2's.
|
||||
saver.writes[(thread_id, ns, cp0["id"])][("task1", 0)] = (
|
||||
@@ -531,7 +537,6 @@ class TestBaseFallbackGetChannelWrites:
|
||||
`threading.local()` guard would let whichever task set it first
|
||||
short-circuit the other to `writes=[]`.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
saver, thread_id, ns = self._build_saver_with_chain()
|
||||
|
||||
@@ -585,9 +590,19 @@ class TestPreDeltaBlobTerminator:
|
||||
"""
|
||||
|
||||
def _build_mixed_thread(self) -> tuple[InMemorySaver, str, str, str, str]:
|
||||
"""Three-checkpoint chain: cp1 (pre-delta, blob=[A]), cp2 (delta,
|
||||
write=B), cp3 (delta, write=C). Reconstructing at cp3 must yield
|
||||
seed=[A] + writes=[B, C].
|
||||
"""Four-checkpoint chain spanning the migration boundary:
|
||||
|
||||
* `cp0` — pre-delta ancestor OLDER than the seed. Its write
|
||||
(`OLDER-WRITE`) is already folded into `cp1`'s stored value, so the
|
||||
walk must terminate at `cp1` and never reach it.
|
||||
* `cp1` — pre-delta, blob `["A"]`. That value is the state ENTERING
|
||||
`cp1`; the write stored under `cp1` (`PRE-DELTA-WRITE`) is what
|
||||
produced `cp2` and is NOT subsumed by the blob.
|
||||
* `cp2` — delta-era, no stored value, write `B`.
|
||||
* `cp3` — target, delta-era, write `PENDING-AT-TARGET`.
|
||||
|
||||
Reconstructing at `cp3` must yield seed `["A"]` plus writes
|
||||
`["PRE-DELTA-WRITE", "B"]`.
|
||||
|
||||
Returns `(saver, thread_id, ns, channel, cp3_id)`.
|
||||
"""
|
||||
@@ -595,16 +610,21 @@ class TestPreDeltaBlobTerminator:
|
||||
serde = JsonPlusSerializer()
|
||||
thread_id, ns, channel = "t1", "", "messages"
|
||||
|
||||
v0 = "00000000000000000000000000000000.0"
|
||||
v1 = "00000000000000000000000000000001.0"
|
||||
v2 = "00000000000000000000000000000002.0"
|
||||
v3 = "00000000000000000000000000000003.0"
|
||||
|
||||
# Pre-delta: cp1 stored a real blob for the channel.
|
||||
# Pre-delta: cp0 and cp1 stored real blobs for the channel.
|
||||
saver.blobs[(thread_id, ns, channel, v0)] = serde.dumps_typed([])
|
||||
saver.blobs[(thread_id, ns, channel, v1)] = serde.dumps_typed(["A"])
|
||||
# Delta-era: cp2 and cp3 store "empty"; real writes in checkpoint_writes.
|
||||
saver.blobs[(thread_id, ns, channel, v2)] = ("empty", b"")
|
||||
saver.blobs[(thread_id, ns, channel, v3)] = ("empty", b"")
|
||||
|
||||
cp0 = empty_checkpoint()
|
||||
cp0["id"] = "cp0"
|
||||
cp0["channel_versions"][channel] = v0
|
||||
cp1 = empty_checkpoint()
|
||||
cp1["id"] = "cp1"
|
||||
cp1["channel_versions"][channel] = v1
|
||||
@@ -615,21 +635,25 @@ class TestPreDeltaBlobTerminator:
|
||||
cp3["id"] = "cp3"
|
||||
cp3["channel_versions"][channel] = v3
|
||||
|
||||
# cp1 is the pre-delta (migration) seed; the channel became a
|
||||
# DeltaChannel at cp2, so its supersteps counter starts there: cp2 -> 1,
|
||||
# cp3 -> 2. The target (cp3) walks 2 hops back to the cp1 seed blob.
|
||||
cp3_md = {"counters_since_delta_snapshot": {channel: [2, 2]}}
|
||||
saver.storage[thread_id][ns] = {
|
||||
"cp1": (serde.dumps_typed(cp1), serde.dumps_typed({}), None),
|
||||
"cp0": (serde.dumps_typed(cp0), serde.dumps_typed({}), None),
|
||||
"cp1": (serde.dumps_typed(cp1), serde.dumps_typed({}), "cp0"),
|
||||
"cp2": (serde.dumps_typed(cp2), serde.dumps_typed({}), "cp1"),
|
||||
"cp3": (serde.dumps_typed(cp3), serde.dumps_typed(cp3_md), "cp2"),
|
||||
"cp3": (serde.dumps_typed(cp3), serde.dumps_typed({}), "cp2"),
|
||||
}
|
||||
# Write under cp1 would be from the pre-delta era and MUST be ignored
|
||||
# (the blob already captures it). We add one and assert it is not
|
||||
# folded into the reconstructed result.
|
||||
saver.writes[(thread_id, ns, "cp1")][("task0", 0)] = (
|
||||
# Write under cp0 is older than the seed — cp1's blob already folded
|
||||
# it in, and the terminator must stop before reaching it.
|
||||
saver.writes[(thread_id, ns, "cp0")][("task0", 0)] = (
|
||||
"task0",
|
||||
channel,
|
||||
serde.dumps_typed("OLDER-WRITE"),
|
||||
"",
|
||||
)
|
||||
# Write under cp1 postdates cp1's blob (it is what produced cp2, which
|
||||
# stores no value of its own) and MUST be replayed.
|
||||
saver.writes[(thread_id, ns, "cp1")][("task1", 0)] = (
|
||||
"task1",
|
||||
channel,
|
||||
serde.dumps_typed("PRE-DELTA-WRITE"),
|
||||
"",
|
||||
)
|
||||
@@ -663,15 +687,19 @@ class TestPreDeltaBlobTerminator:
|
||||
|
||||
# Seed came from the pre-delta blob at cp1.
|
||||
assert result["seed"] == ["A"]
|
||||
# Delta-era writes from cp2 replay through the reducer on top of seed.
|
||||
# cp3 is the target — its own write is pending for the NEXT step and
|
||||
# must be excluded.
|
||||
# The seed ancestor's own write and the delta-era write from cp2 both
|
||||
# replay through the reducer on top of the seed, oldest first. cp3 is
|
||||
# the target — its own write is pending for the NEXT step and must be
|
||||
# excluded.
|
||||
values = [v for _, _, v in result["writes"]]
|
||||
assert values == ["B"]
|
||||
assert values == ["PRE-DELTA-WRITE", "B"]
|
||||
|
||||
def test_pre_delta_blob_terminates_walk_before_older_writes(self) -> None:
|
||||
"""Writes stored at the pre-delta ancestor itself must not be replayed
|
||||
(the blob subsumes them)."""
|
||||
def test_seed_bounds_walk_without_dropping_its_own_writes(self) -> None:
|
||||
"""The seed terminator bounds the walk: writes at ancestors OLDER than
|
||||
the seed are already folded into the seed value and must not be
|
||||
replayed. The seed ancestor's own write is not one of them — it
|
||||
postdates the stored value and produced the next checkpoint.
|
||||
"""
|
||||
saver, thread_id, ns, channel, target = self._build_mixed_thread()
|
||||
config: RunnableConfig = {
|
||||
"configurable": {
|
||||
@@ -686,7 +714,9 @@ class TestPreDeltaBlobTerminator:
|
||||
]
|
||||
|
||||
values = [v for _, _, v in result["writes"]]
|
||||
# The pre-delta write under cp1 must not appear (the blob subsumes it).
|
||||
assert "PRE-DELTA-WRITE" not in values
|
||||
# Older than the seed — subsumed by cp1's blob, so the walk stops first.
|
||||
assert "OLDER-WRITE" not in values
|
||||
# Stored AT the seed ancestor — not subsumed, so it must be replayed.
|
||||
assert "PRE-DELTA-WRITE" in values
|
||||
# And the pending write at the target is never folded in.
|
||||
assert "PENDING-AT-TARGET" not in values
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import asyncio
|
||||
import json
|
||||
from collections.abc import Iterable
|
||||
from collections import UserDict
|
||||
from collections.abc import Iterable, Mapping
|
||||
from datetime import datetime
|
||||
from types import MappingProxyType
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
@@ -11,10 +13,14 @@ from langgraph.store.base import (
|
||||
GetOp,
|
||||
InvalidNamespaceError,
|
||||
Item,
|
||||
ListNamespacesOp,
|
||||
MatchCondition,
|
||||
Op,
|
||||
PutOp,
|
||||
Result,
|
||||
SearchOp,
|
||||
get_text_at_path,
|
||||
validate_op_namespace,
|
||||
)
|
||||
from langgraph.store.base.batch import AsyncBatchedBaseStore
|
||||
from langgraph.store.memory import InMemoryStore
|
||||
@@ -137,6 +143,18 @@ def test_get_text_at_path() -> None:
|
||||
assert get_text_at_path(nested_data, "nested[{invalid}]") == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mapping",
|
||||
[
|
||||
UserDict({"text": "searchable"}),
|
||||
MappingProxyType({"text": "searchable"}),
|
||||
],
|
||||
)
|
||||
def test_get_text_at_path_with_non_dict_mapping(mapping: Mapping[str, str]) -> None:
|
||||
assert get_text_at_path(mapping, "$") == ['{"text": "searchable"}']
|
||||
assert get_text_at_path(mapping, "text") == ["searchable"]
|
||||
|
||||
|
||||
async def test_async_batch_store(mocker: MockerFixture) -> None:
|
||||
abatch = mocker.stub()
|
||||
|
||||
@@ -514,6 +532,127 @@ async def test_cannot_put_empty_namespace() -> None:
|
||||
assert (await async_store.aget(("valid", "namespace"), "key")) is None
|
||||
|
||||
|
||||
INVALID_NAMESPACES = [("foo.bar",), ("foo", ""), (123,)]
|
||||
NAMESPACE_METHODS = ["get", "delete", "search", "prefix", "suffix"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("namespace", INVALID_NAMESPACES)
|
||||
@pytest.mark.parametrize("method", NAMESPACE_METHODS)
|
||||
def test_rejects_invalid_namespace_labels(
|
||||
mocker: MockerFixture, namespace: tuple, method: str
|
||||
) -> None:
|
||||
store = InMemoryStore()
|
||||
batch = mocker.spy(InMemoryStore, "batch")
|
||||
call = {
|
||||
"get": lambda: store.get(namespace, "key"),
|
||||
"delete": lambda: store.delete(namespace, "key"),
|
||||
"search": lambda: store.search(namespace),
|
||||
"prefix": lambda: store.list_namespaces(prefix=namespace),
|
||||
"suffix": lambda: store.list_namespaces(suffix=namespace),
|
||||
}[method]
|
||||
|
||||
with pytest.raises(InvalidNamespaceError):
|
||||
call()
|
||||
|
||||
batch.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("batched", [False, True])
|
||||
@pytest.mark.parametrize("namespace", INVALID_NAMESPACES)
|
||||
@pytest.mark.parametrize("method", NAMESPACE_METHODS)
|
||||
async def test_async_rejects_invalid_namespace_labels(
|
||||
mocker: MockerFixture, batched: bool, namespace: tuple, method: str
|
||||
) -> None:
|
||||
# The batched store must reject before queueing: a failure inside the
|
||||
# shared `abatch` would fail every op queued alongside this one.
|
||||
store = MockAsyncBatchedStore() if batched else InMemoryStore()
|
||||
# `MockAsyncBatchedStore` dispatches through `InMemoryStore.batch`.
|
||||
batch = mocker.spy(InMemoryStore, "batch")
|
||||
abatch = mocker.spy(InMemoryStore, "abatch")
|
||||
call = {
|
||||
"get": lambda: store.aget(namespace, "key"),
|
||||
"delete": lambda: store.adelete(namespace, "key"),
|
||||
"search": lambda: store.asearch(namespace),
|
||||
"prefix": lambda: store.alist_namespaces(prefix=namespace),
|
||||
"suffix": lambda: store.alist_namespaces(suffix=namespace),
|
||||
}[method]
|
||||
|
||||
with pytest.raises(InvalidNamespaceError):
|
||||
await call()
|
||||
|
||||
batch.assert_not_called()
|
||||
abatch.assert_not_called()
|
||||
|
||||
|
||||
def test_search_and_listing_keep_empty_prefixes_and_wildcards() -> None:
|
||||
store = InMemoryStore()
|
||||
store.put(("tenant", "a_%"), "key", {"v": 1})
|
||||
store.put(("tenant", "b", "child"), "key", {"v": 1})
|
||||
|
||||
assert len(store.search(())) == 2
|
||||
assert [item.namespace for item in store.search(("tenant", "a_%"))] == [
|
||||
("tenant", "a_%")
|
||||
]
|
||||
assert sorted(store.list_namespaces(prefix=("tenant", "*"), suffix=("*",))) == [
|
||||
("tenant", "a_%"),
|
||||
("tenant", "b", "child"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("batched", [False, True])
|
||||
async def test_async_search_and_listing_keep_empty_prefixes_and_wildcards(
|
||||
batched: bool,
|
||||
) -> None:
|
||||
store = MockAsyncBatchedStore() if batched else InMemoryStore()
|
||||
await store.aput(("tenant", "a_%"), "key", {"v": 1})
|
||||
await store.aput(("tenant", "b", "child"), "key", {"v": 1})
|
||||
|
||||
assert len(await store.asearch(())) == 2
|
||||
assert [item.namespace for item in await store.asearch(("tenant", "a_%"))] == [
|
||||
("tenant", "a_%")
|
||||
]
|
||||
assert sorted(
|
||||
await store.alist_namespaces(prefix=("tenant", "*"), suffix=("*",))
|
||||
) == [("tenant", "a_%"), ("tenant", "b", "child")]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("namespace", INVALID_NAMESPACES)
|
||||
@pytest.mark.parametrize(
|
||||
"kind", ["get", "put", "delete", "search", "list_prefix", "list_suffix"]
|
||||
)
|
||||
def test_validate_op_namespace_rejects_invalid_labels(
|
||||
namespace: tuple, kind: str
|
||||
) -> None:
|
||||
op = {
|
||||
"get": GetOp(namespace, "key"),
|
||||
"put": PutOp(namespace, "key", {"v": 1}),
|
||||
"delete": PutOp(namespace, "key", None),
|
||||
"search": SearchOp(namespace),
|
||||
"list_prefix": ListNamespacesOp((MatchCondition("prefix", namespace),)),
|
||||
"list_suffix": ListNamespacesOp((MatchCondition("suffix", namespace),)),
|
||||
}[kind]
|
||||
|
||||
with pytest.raises(InvalidNamespaceError):
|
||||
validate_op_namespace(op)
|
||||
|
||||
|
||||
def test_validate_op_namespace_allows_empty_prefix_and_wildcards() -> None:
|
||||
for op in (
|
||||
SearchOp(()),
|
||||
ListNamespacesOp(),
|
||||
ListNamespacesOp(
|
||||
(
|
||||
MatchCondition("prefix", ("tenant", "*")),
|
||||
MatchCondition("suffix", ("*",)),
|
||||
)
|
||||
),
|
||||
GetOp(("tenant", "a_%"), "key"),
|
||||
# Write-only rules belong to `put`, not to op validation.
|
||||
PutOp(("langgraph", "x"), "key", {"v": 1}),
|
||||
):
|
||||
validate_op_namespace(op)
|
||||
|
||||
|
||||
async def test_async_batch_store_deduplication(mocker: MockerFixture) -> None:
|
||||
abatch = mocker.spy(InMemoryStore, "batch")
|
||||
store = MockAsyncBatchedStore()
|
||||
|
||||
Generated
+65
-64
@@ -19,16 +19,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.12.1"
|
||||
version = "4.14.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
||||
{ name = "idna" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -149,11 +149,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "codespell"
|
||||
version = "2.4.2"
|
||||
version = "2.4.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2d/9d/1d0903dff693160f893ca6abcabad545088e7a2ee0a6deae7c24e958be69/codespell-2.4.2.tar.gz", hash = "sha256:3c33be9ae34543807f088aeb4832dfad8cb2dae38da61cac0a7045dd376cfdf3", size = 352058, upload-time = "2026-03-05T18:10:42.936Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/80/19/45e941380f69c042b43423513d201e6592346f992394347f5e7174c31407/codespell-2.4.3.tar.gz", hash = "sha256:cbe085e331227b37bb86ef8bddd08dc768c704ee9a07ca869852c093fa2793e2", size = 352773, upload-time = "2026-07-15T11:51:54.159Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/42/a1/52fa05533e95fe45bcc09bcf8a503874b1c08f221a4e35608017e0938f55/codespell-2.4.2-py3-none-any.whl", hash = "sha256:97e0c1060cf46bd1d5db89a936c98db8c2b804e1fdd4b5c645e82a1ec6b1f886", size = 353715, upload-time = "2026-03-05T18:10:41.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/bf/bdb951d34eb169140b546f44be9ec4525d1acefb9eb5572071f5492b19fc/codespell-2.4.3-py3-none-any.whl", hash = "sha256:af2505b335e8573dbd2d384d1c4ef498f4006f4ba2d6fceca01e55b91f52628a", size = 340736, upload-time = "2026-07-15T11:51:52.925Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -183,7 +183,7 @@ name = "exceptiongroup"
|
||||
version = "1.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
|
||||
wheels = [
|
||||
@@ -268,9 +268,10 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langchain-core"
|
||||
version = "1.4.0"
|
||||
version = "1.6.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
{ name = "jsonpatch" },
|
||||
{ name = "langchain-protocol" },
|
||||
{ name = "langsmith" },
|
||||
@@ -281,26 +282,26 @@ dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "uuid-utils" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/59/de/679a53472c25860837e32c0442c962fa86e95317a36460e2c9d5c91b17c2/langchain_core-1.4.0.tar.gz", hash = "sha256:1dc341eed802ed9c117c0df3923c991e5e9e226571e5725c194eeb5bd93d1a7f", size = 920260, upload-time = "2026-05-11T18:42:35.919Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ed/ed/ceecd3bfcfc77a94cf30a9f1506e907fdea447458825e01914a72d2d1a6f/langchain_core-1.6.5.tar.gz", hash = "sha256:bdb059fef65473fd444558dbb18f50901657402c43ceed9dc0fdc7b76a393c6b", size = 1007041, upload-time = "2026-09-24T18:11:06.487Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/1a/86c38c27b81913a1c6c12448cab55defb5a1097c7dc9a4cea83f55477a2d/langchain_core-1.4.0-py3-none-any.whl", hash = "sha256:23cbbdb46e38ddd1dd5247e6167e96013eae74bea4c5949c550809970a9e565c", size = 548120, upload-time = "2026-05-11T18:42:33.992Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/68/447ac7e734990b7ed9dcfc3b6dee9a4cd4d4169e296429fa71a9a47c1c7b/langchain_core-1.6.5-py3-none-any.whl", hash = "sha256:54c7b0e9314b9084fb04405bb33dc5d32986d512d92b7b8863899cd5f6267556", size = 572136, upload-time = "2026-09-24T18:11:04.837Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langchain-protocol"
|
||||
version = "0.0.15"
|
||||
version = "0.0.18"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4f/24/9777489d6fbbee64af0c8f96d4f840239c408cf694f3394672807dafc490/langchain_protocol-0.0.15.tar.gz", hash = "sha256:9ab2d11ee73944754f10e037e717098d3a6796f0e58afa9cadda6154e7655ade", size = 5862, upload-time = "2026-05-01T22:30:04.748Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d2/59/b5959aea96faa9146e2e49a7a22882b3528c62efafe9a6a95beab30c2305/langchain_protocol-0.0.18.tar.gz", hash = "sha256:ec3e11782f1ed0c9db38e5a9ed01b0e7a0d3fba406faa8aef6594b73c56a63e6", size = 6150, upload-time = "2026-06-18T17:08:26.959Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/7a/9c97a7b9cbe4c5dc6a44cdb1545450c28f0c8ce89b9c1f0ee7fbad896263/langchain_protocol-0.0.15-py3-none-any.whl", hash = "sha256:461eb794358f83d5e42635a5797799ffec7b4702314e34edf73ac21e75d3ef79", size = 6982, upload-time = "2026-05-01T22:30:03.877Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/2e/d82db9eec13ad0f72e7aaad5c4bc730ab111934fdc83c85523206eb9b0a0/langchain_protocol-0.0.18-py3-none-any.whl", hash = "sha256:70b53a86fbf9cedc863555effe44da192ab02d556ddbf2cf95b8873adcf41b5a", size = 7221, upload-time = "2026-06-18T17:08:25.996Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-checkpoint"
|
||||
version = "4.1.1"
|
||||
version = "4.3.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -982,7 +983,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.3"
|
||||
version = "9.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
@@ -993,9 +994,9 @@ dependencies = [
|
||||
{ name = "pygments" },
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1014,14 +1015,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pytest-mock"
|
||||
version = "3.15.1"
|
||||
version = "3.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7a/7f/6ed29931d5c8cd396e7c0a55412e6cc88020373365c8685985dea53d26d7/pytest_mock-3.16.0.tar.gz", hash = "sha256:5a8395528b8f498205f3718f575228d0edaed7425fff638f87d1a6c3e0383636", size = 35362, upload-time = "2026-09-27T14:57:55.46Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/5b/b83a9bf1a3b4ec222f9fa083147ff6816245223da0ab92370e7e056f113f/pytest_mock-3.16.0-py3-none-any.whl", hash = "sha256:007cfeb257801d88d9c0b2a7b5a15a15e73b71968dfd72e7bf8c4a2f8393aec8", size = 10016, upload-time = "2026-09-27T14:57:54.283Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1124,14 +1125,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "redis"
|
||||
version = "8.0.0"
|
||||
version = "8.1.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/53/ae/ed461cca5780b5fc8b9fe8ca0ed98d89508645fb9d880c24cc42c087678f/redis-8.0.0.tar.gz", hash = "sha256:a00c5355432051ac14e593b8b197fc76c887ee12d55a0984f69328a1115fdc49", size = 5101591, upload-time = "2026-05-28T12:45:13.5Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a8/99/604f0b666d4c616d891cf77ebb9db6bb21601344c051aebf1b72b9ff915f/redis-8.1.0.tar.gz", hash = "sha256:6e1a19beef9225c83efd689c7e6b7da2d5215b1f42cd13b7fc3714d0a09c7b25", size = 5254356, upload-time = "2026-07-30T08:51:00.269Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/27/e3/b519734372d305bd547534a9f32e4ce9f98552af753dce72cf3483a0ff0b/redis-8.0.0-py3-none-any.whl", hash = "sha256:c938c18338585009f0bc310f4c7e4e4b4d37639356c4ac072cedf3af570c8dc7", size = 499870, upload-time = "2026-05-28T12:45:11.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/9d/c5731f6e3608663d4d3656fd8d3aecee8b509c3082818f5a13eae925baea/redis-8.1.0-py3-none-any.whl", hash = "sha256:a4fe1aac3d3b3cc791d4b3d5931c5a956045dc951ee74d1c913ee3ac4d2ee9fb", size = 560618, upload-time = "2026-07-30T08:50:58.497Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1163,27 +1164,27 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.15"
|
||||
version = "0.16.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/84/6f/a76f7d96e5c962f5b69cee865e49c15c1116897c01990faa8a57edb62e7f/ruff-0.15.15.tar.gz", hash = "sha256:b8dff018130b46d8e5bf0f926ef6b60cf871d6d5ae45fc9334e09632daa741d6", size = 4706985, upload-time = "2026-05-28T14:16:57.784Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/bf/c935ca98e73fe8ce65b87ef08a280c0c1e85295d569228c15e87d8fdfaf1/ruff-0.16.9.tar.gz", hash = "sha256:12b625c6cfba78d285d9f48eda5f053374f1e53cb10ef17342a383750db99161", size = 4948764, upload-time = "2026-09-24T20:37:49.416Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/9d/3a45c05b8ab04b4705989de70a79008e27c8003296a0feaee9edc18dd7e9/ruff-0.15.15-py3-none-linux_armv6l.whl", hash = "sha256:cf93e5388f412e1b108b1f8b34a6e036b70fe8aff89393befad96fe48670311b", size = 10710652, upload-time = "2026-05-28T14:16:06.701Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/66/da974431624bf3b49f6ee1f9543c02d929ff1cba78b0d5a79c38cf21f744/ruff-0.15.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ac5a646d1f6a7dadd5d50842dae2c1f9862ac887ef5d1b1375e02def791fde6e", size = 11096615, upload-time = "2026-05-28T14:16:23.313Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/09/7443452e5d290230a712103f2fdceeef7184f3ec99a2bd01c8be78aaceb5/ruff-0.15.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:77d955a431430c66f72dd94e379ad38a16daea3d25094872ac4edf9e797be530", size = 10436683, upload-time = "2026-05-28T14:16:40.974Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/01/d330c26a57fa4f3943a14424904027428315b700fe4d14a84bb123a649e5/ruff-0.15.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7614ee79c69788cf6cedd568069ade9cecc22a1ad20494efe8d0c9ebb4b622d4", size = 10769064, upload-time = "2026-05-28T14:16:28.905Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/85/cc8770f8bdff541b1da8392d1634141fe4a0e3f4ee596605959b7906c27f/ruff-0.15.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3cdb1679e06a1f6b47bc384714ae96f6e2fb65ca441eb78c43d2ca554176ce1f", size = 10511987, upload-time = "2026-05-28T14:16:43.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/29/8c190c1472b63013583ba391f3342036e02010544c1270455ed8e519bdf3/ruff-0.15.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2728b93d7b23a603ea2c0ac6eb73d760bd38ec9de35f35fb41e18f7a3fee7622", size = 11275100, upload-time = "2026-05-28T14:16:55.244Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/6b/7e145ce2cc8e63d6834eca03d83a0e18d121def5c69f91b4cf4011ed4879/ruff-0.15.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be582fcc0db438902c7792b08d6ddf6c9b9e21addaa10092c2c741cfb09e5a45", size = 12176903, upload-time = "2026-05-28T14:16:14.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/a3/d5974637f68e451f7fadf015cf3101d1cd7d8ba5027cffe0b9e3826ebe6b/ruff-0.15.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7aa77465b8ecaf1a27bea098d696f7fed5e1eccbd10b321b682d6de586ae5627", size = 11404550, upload-time = "2026-05-28T14:16:20.138Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/1c/e6e5e568f22be4fb05d6244234aba384c06b451252453b821e1a529263cf/ruff-0.15.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48decfa11d740de4889de623be1463308346312f2409a56e24aa280c86162dc4", size = 11382027, upload-time = "2026-05-28T14:16:46.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/01/170921b49fcd2e8858825593f91cf7146c3e40a5c3e6df763e4bb0484dde/ruff-0.15.15-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a5015088452ca0081387063649ec67f06d3d1d6b8b936a1f836b5e9657ecd48c", size = 11366041, upload-time = "2026-05-28T14:16:26.247Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/54/a7bad711d7de93254e15e06a4c375b89a03d18de45d3e5dcc86a4472fb1a/ruff-0.15.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f5294aab6356c81600fcdea3a62bb1b924dfd5e91767c12318d3f68f86af57cd", size = 10741795, upload-time = "2026-05-28T14:16:17.11Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/31/38c075963668f8b41c6914ee0f6f318727fbe30ab9145cb29e6df464c5fa/ruff-0.15.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:db5bd4d802415cca656dc1616070b725952d6ae95eb5d4831e49fbd94a38f75f", size = 10511117, upload-time = "2026-05-28T14:16:31.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/96/6ff689e1f7e375d1d97075eca022f74c2bab59554a432fe4d2e6f091986a/ruff-0.15.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:587a6278ed42059191c1a466e490bd7930fb50bd2e255398bc29616c895a61cb", size = 10994867, upload-time = "2026-05-28T14:16:35.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/c2/5dce0ab9f92a8d534fa62b9bf9caca3eddb8c1a81b616f5e195ada4f0d6e/ruff-0.15.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:df0c1c084f5f4be9812f61518a45c440d3c30d69ce4bf6c5270e66d38338f02a", size = 11482101, upload-time = "2026-05-28T14:16:49.598Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/c0/1003b60edd697c649faf61f1a34094b1abb38fb3d1181e3f895781250a08/ruff-0.15.15-py3-none-win32.whl", hash = "sha256:29428ea79694afbe756d45fd59b36f22b6b020dc0443cf7de0173046236964b9", size = 10716774, upload-time = "2026-05-28T14:16:52.337Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/a8/1269eddd6945a06c23f055ef7848886e37cf9d6a8bebb386a3115f01470c/ruff-0.15.15-py3-none-win_amd64.whl", hash = "sha256:8df0323902e15e24bc4bf246da830573d3cf3352bd0b9a164eab335d111ff4a4", size = 11868463, upload-time = "2026-05-28T14:16:11.333Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/b2/920464c907b191e37469d477a1aa8bc048b8f36c4c1610dfa4ab87b39e18/ruff-0.15.15-py3-none-win_arm64.whl", hash = "sha256:3c8ceca6792f38196b8f589bc92eccd03eef286602da92e5dc05cc42ef6441b7", size = 11138498, upload-time = "2026-05-28T14:16:38.425Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/26/df51322b52ee1ada7eff2d071ea09d11d5c2d1dcc9f02594f5785c4d1635/ruff-0.16.9-py3-none-linux_armv6l.whl", hash = "sha256:95e6f022090368ab3b824c36276839c53b2adf1a3f4c09fefc33dfc400f6da96", size = 10082922, upload-time = "2026-09-24T20:37:13.045Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/27/7bf51f5a7aa375e9f339280a303aab44ca75dc1525f1cdc5991761685b0f/ruff-0.16.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a5f27be168556594a86d2f415db0cf43f5291917849318f873c7e2791f7a8c67", size = 10236360, upload-time = "2026-09-24T20:37:16.049Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/63/09659283f92f02dff45809da194a70da2f688d87c55d8875c4fae3536072/ruff-0.16.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1632eb1d6197f33bd00b1acbc5b71009e89a8895c158e2d2b03a834fac964ab6", size = 9892940, upload-time = "2026-09-24T20:37:17.957Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/58/98de1b72ec172f5f8f1731236fe21585b3998bf7dfe9fcc44ae9ba626012/ruff-0.16.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3f951b14d865d5952c89d40a5ca07e87abe24fa5453299878411e127748fb1c", size = 10032114, upload-time = "2026-09-24T20:37:19.942Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/7d/f1e17c54ab59d4bad1dce8ee3e22a7a1d0ef4745240decacdcf3832b5bb2/ruff-0.16.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:447fc07e1573afff7cb02803462b12b6c8ece7cf10e2cd78565fa6d7a1c0bf8d", size = 9910227, upload-time = "2026-09-24T20:37:21.872Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/19/436f647a65075bbd3bab2668b3bdaa5120559b294694018cdcefabbbf30b/ruff-0.16.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a3e039a6a40ed976c491722b60e0ae4a4aa1a86057f540ee7a37a5d19ae9120", size = 10547484, upload-time = "2026-09-24T20:37:24.229Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/59/38430a6bc2f6d8095447ac39625cf8b6e9344a47e6c26225c8ba1bff3ffb/ruff-0.16.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4684dded7db60aa57cb118fa158630f5feade4af5782903b6053484bdf9bd129", size = 11412367, upload-time = "2026-09-24T20:37:26.307Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/bd/bbb6d7fc7f208c8b8c50dd5dc8206e4cfdb1e7a8fb852606a3adf370c880/ruff-0.16.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d29c934357e45642fda2f34c0b1f4025b4a6c01e15e4bf0016879d60078a142c", size = 10869787, upload-time = "2026-09-24T20:37:28.35Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/b8/9c543074918061abbefc3bd139bee22de35abedb00dde0f2d27288838962/ruff-0.16.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a21713e629d3e5bdb2f5c2def1cc7f04f47fa8e1a7eb0571b4a28e1da64bc728", size = 10406494, upload-time = "2026-09-24T20:37:30.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/7a/5a8851bd146e7ccf8fd4b003f6c75c11f8fbdb0e60673b45097986b6bf41/ruff-0.16.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7baa24ef5fc8e77aa93879e1d3f43754a01ae488e869f1ae30cf431afd4d2452", size = 10590083, upload-time = "2026-09-24T20:37:32.439Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/f0/4c3467188f23f806960b46fa76575a7cd0514c9ba90562650b71efc96980/ruff-0.16.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a41aac6230aadfaa133bdfa1614488531ffa3e0837567ae04c0da2058a9c0f9e", size = 10119151, upload-time = "2026-09-24T20:37:34.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/34/5a4def5adea572ce6aea0bb64f21f928ee317b80e0db747d7979b01d7261/ruff-0.16.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c2529fb5896d49115b0e9aa8f887490b34bbe76baf879ec2264ac59406869ce7", size = 9911544, upload-time = "2026-09-24T20:37:36.796Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/5d/d15ebea7499eef9373318c0ee6ca127832927c6529731f6d48e18dce7ca9/ruff-0.16.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:41e3870277694177429b56406d65dfbdb2c2802c52b715edaf6a0b829c69d4ee", size = 10269884, upload-time = "2026-09-24T20:37:38.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/56/c5d3cd119ded7a3c7aba0e961b69cb3df701c91662c98ad694467d060ce1/ruff-0.16.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8adbe4e58af167f767d7b2ba5e83c42e878350796cf78c2f5e14ab9903a92588", size = 10749366, upload-time = "2026-09-24T20:37:41.042Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/fe/734ec7527029ac757ecf821f143c9f3fcf69149c21f53a044a899b430f5a/ruff-0.16.9-py3-none-win32.whl", hash = "sha256:0e1dbc2073624dee6618d41d0098690a7244654af746704b64759e12b6b6b385", size = 10152355, upload-time = "2026-09-24T20:37:43.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/21/26e4643629b3ebb44f0a06f9c9a53058d63d989415f63a9a3c28e2ee7f22/ruff-0.16.9-py3-none-win_amd64.whl", hash = "sha256:6bd40fec8cd4c8a3d4dd589bd8ad4e6320c13c29234159bfd959a40d529d597b", size = 10592965, upload-time = "2026-09-24T20:37:44.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/60/5fb1a39dbb5ae314d5f59bc7348a63c1d5c20f3cd83914c4b5cb0be31d2d/ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284", size = 10458649, upload-time = "2026-09-24T20:37:46.882Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1260,27 +1261,27 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ty"
|
||||
version = "0.0.43"
|
||||
version = "0.0.84"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0d/37/4ec04de0659b93be37d956dfceca13b1ecab9c959f28d8a1d5e514603f36/ty-0.0.43.tar.gz", hash = "sha256:ea4cff50548f2a1877e848d3abe9e293cde8ab94757a7eb93fc0d4013f98be8e", size = 5798429, upload-time = "2026-06-04T00:52:10.013Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e1/f6/34f8869c45ea87fe6a931ffa824b1fe4428167173543d92988d631add355/ty-0.0.84.tar.gz", hash = "sha256:0cefdb0cd5d399418dbe83c349ba605047b7dff815ae6f460c80e8522b40be67", size = 7409537, upload-time = "2026-09-24T13:16:24.367Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/db/74/1916026a78f20019a2f03adbd6fb4430ddb7ce1e52c2e17a90856a6d192e/ty-0.0.43-py3-none-linux_armv6l.whl", hash = "sha256:3bf70f5446480562bf6c9f639df4b5cb60716b8f8d1a6b8e5811d5c7eccd8bf2", size = 11598153, upload-time = "2026-06-04T00:52:20.646Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/af/58bb0089d2635216c8fa6612dd486a3f986d0ab1c46a41527ab95e57f0e3/ty-0.0.43-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7184741f8b15425a1bc64b950ad005cb353573288ac0e8a04f5481ceb3832596", size = 11357811, upload-time = "2026-06-04T00:52:24.683Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/9c/32c6b14f3feddf87b59c7a50709e2b3da408258f2f583f05575f77bc8f7b/ty-0.0.43-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8c306379ca9a35f6ae5270fe9bda7af4b46d91822725a2586d78c8b9b5493b62", size = 10772024, upload-time = "2026-06-04T00:52:14.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/fa/98aa4a74bd00cd5efc424923cd1daffbf1e40a0338041cafb203379d746f/ty-0.0.43-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d624b884c9c1fd244ad2a5f026364e7162a22b3f537025941ada2e363e676414", size = 11291034, upload-time = "2026-06-04T00:52:37.249Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/db/4de086c38ce96dcada2bd451f43171d2c237f96d8ed19a1ea8fe51bb8ef4/ty-0.0.43-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:281fc4c00fbc196045141faa085055bddc58846b04a2800204701415a1b9c6aa", size = 11364724, upload-time = "2026-06-04T00:52:33.138Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/d3/e3cd8e3233a6fd8362a49aa025b79e9f40151a2a86d811ace154c6eb7445/ty-0.0.43-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f57d6cc28de89024b48d1788e4758c05299d5749d4a51c02e71ac655ec23d9a5", size = 11890555, upload-time = "2026-06-04T00:52:22.711Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/7b/6f46d444e8241606bbde098df3dca93f2ec0b834a42055db85ee7d33646f/ty-0.0.43-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0a1d6ad6c5e7792c7eac0a01e550f2c2004462e01a64a91ea1636aba6fef6e71", size = 12450968, upload-time = "2026-06-04T00:52:28.94Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/e1/79fbe51f2e4b9d8347f2013cd7ed0b63f3b499038c02dc0357e9b28a3a47/ty-0.0.43-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:66d474395d7635fb618bdbb58b4e3360259a2056d0a5621b82754b9da2cd8a04", size = 12064187, upload-time = "2026-06-04T00:52:12.039Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/3f/c758a3a8df5b90d331f2b60c8f16021ee64d75e78f99d67cc4efc9bf5f4b/ty-0.0.43-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2663a0003a8b60fb98db7f6f6e673df80b21d0fe3a9868a26fb06b4e049b6fc4", size = 11943208, upload-time = "2026-06-04T00:52:31.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/5f/f516442749cf1b45ca6720a5d41df2738a486ed9ace774c03d515db89084/ty-0.0.43-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:d5a6c352d374d889189d5ec82b54b26a5885f769f7b7787f7f875500dcb8673e", size = 12143572, upload-time = "2026-06-04T00:52:18.457Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/bf/0d83c7f43bf4c10f3678bfe7d938e51c445298c7b923f155c5204730c2df/ty-0.0.43-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e7dbbeedfad3ca250d74fcc355fa9ab6b38d2a17f22d6304f615716939dbbb27", size = 11279355, upload-time = "2026-06-04T00:52:26.726Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/de/a6c978bef6d9e949f79f4782d9e4ee4df0893713e73b055d84c1a5116b9a/ty-0.0.43-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:24b18a0273ee46154996cfcfa27438f851f440c925587ec200df6f98dffe67d3", size = 11408412, upload-time = "2026-06-04T00:52:35.282Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/b1/d13857c23867f0f76b92e38e5841c64ca5e76dc5d4bf27f52cb81d8ab685/ty-0.0.43-py3-none-musllinux_1_2_i686.whl", hash = "sha256:2ef681951520d692b7e9c0b5e56aacf4f98ccae47cf6ffccaf2c7b6b33dc226e", size = 11541709, upload-time = "2026-06-04T00:52:16.451Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/f1/cd6afc6f6a687e238bf5e12189f7920e81a0bdef6c3dba4c784ef140f7d9/ty-0.0.43-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:2af105de7437143aa4676b28016b5bee661aaaa4eff52be5867fb25119641ceb", size = 12041266, upload-time = "2026-06-04T00:52:43.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/ba/51ca7c3335da2b8d0a3e477fa4986be9f4a53b05bfab862967d8d2e6ca60/ty-0.0.43-py3-none-win32.whl", hash = "sha256:e4773115b0d6486ee30f1657fc8bdffe7e3a3f5300ab77ef2495da6e83e4694f", size = 10858724, upload-time = "2026-06-04T00:52:07.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/29/5d80453e5f7c520145fa058851da87230dbd7ca761a7675447a9fe504e0b/ty-0.0.43-py3-none-win_amd64.whl", hash = "sha256:48d3545094a4ae6395492c7e6ac90550fce969e0ed2815fbf8c5da9756676b7d", size = 11976157, upload-time = "2026-06-04T00:52:41.438Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/ed/befe5a543e5b95e754ed38ee95239e44efda9bc5f578db4ac1bc8dd758d6/ty-0.0.43-py3-none-win_arm64.whl", hash = "sha256:740ca33d7f75f655a4e7d475bc42dfb825c13219bb073fad30fcc04d35790c74", size = 11308680, upload-time = "2026-06-04T00:52:39.233Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/0c/600648778e1c79133ce6b6a5c4c2531ae16b90c9b30f5041b38b574f61b8/ty-0.0.84-py3-none-linux_armv6l.whl", hash = "sha256:868438e2b9abfc835dd5b5b07ed693db95d8348ac40e3228252511d63ae87695", size = 13935333, upload-time = "2026-09-24T13:15:31.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/3b/27f06f49945c36170765fad82076898039ae3873338e9865f876dd62f12c/ty-0.0.84-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b00f39b521f0b559cde0f6abf8dd47213b513b04f33d58efeb9b98486a16bb17", size = 13511328, upload-time = "2026-09-24T13:15:34.446Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/b6/b12dd130e7f30141f0645614f454c925991cd389b1cc6c8284fd6bf32326/ty-0.0.84-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8f78c2915567f2ee62ce0c24d86091bc76f81577677755e242d09951db588b9a", size = 13445445, upload-time = "2026-09-24T13:15:37.476Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/96/f012de4b3c1d9a4773326621380d768b4d48c47e620dc012775c5e01c7d7/ty-0.0.84-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:61155cad0921e890d86d1d911a40b3a0e924ef4bdc3b4f3865a6c89c7e3012dc", size = 13471235, upload-time = "2026-09-24T13:15:40.64Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/32/dca630f5f5353fa7bf67eebccf1549df99b16656d21071d0df6677c826a5/ty-0.0.84-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:417c3bf14947ed16b0b92976ab333c27915590354e96d4f581709068d3811355", size = 13680704, upload-time = "2026-09-24T13:15:43.817Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/8d/33702c8f62f05ad45b33644fb914d483de6a0a7bc8e51884e9b8773e6d69/ty-0.0.84-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e06ef117759f654f5379efe9ae79bda8a186016c016f1023d05c34f8e15c1546", size = 14518923, upload-time = "2026-09-24T13:15:46.802Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/a1/da3246e6ef2ae0e842bca4058b25c54bee55ab058ffff4ee54e72517ec01/ty-0.0.84-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a05d1d53db8b64410ab38c6e32c5898510584869d1d5dd9c0605db81c9aef7df", size = 15027731, upload-time = "2026-09-24T13:15:49.755Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/17/b0099098a560aeb4f9fde6c912f9a81622c3e44e6555e1fbe322b368ec31/ty-0.0.84-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b92400494ab855cfb1204ac1101e3f53b2d09a8e21d171758b2cf9acdab1809e", size = 14748260, upload-time = "2026-09-24T13:15:52.713Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/d5/32cd67aedb271f006e716ad093806c96c5d7e2af86be8255469dbd3d76c8/ty-0.0.84-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af17eb391ae3027fed9be1780bea555c2b8a25ba68e184c627b4981b2c9eaab7", size = 14205858, upload-time = "2026-09-24T13:15:55.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/c4/638ac62ca2e8eca1b92e3e16273085744fada728b993500819b5c258ca15/ty-0.0.84-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:44cf06af0e7ec78ea6d8ff33a3450fc19627756a4fba681f7410d4e3ef63047a", size = 14573196, upload-time = "2026-09-24T13:15:59.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/f6/9ee81c5d8921e5fd5f10889ae563c95cd3272b31df2ada528919ca1cef6b/ty-0.0.84-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c6e56b443ff9ba462e34bfbc1e582004c2719ed35f8f93a433744b0ba0afb2d9", size = 13480760, upload-time = "2026-09-24T13:16:02.313Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/83/fbe5d1177667ae16d84c3242846b143809c9cc09568b333e5a9b4d5908f6/ty-0.0.84-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8b6562fcc48820c1030e2a4509ef2c14ef93043ca4860280074314ac3a825b95", size = 13696346, upload-time = "2026-09-24T13:16:05.391Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/9a/1b4ac9ddc34cc0ab4624bc219825d66af52f1ad87f4778ad08ad03fa06d5/ty-0.0.84-py3-none-musllinux_1_2_i686.whl", hash = "sha256:dee7421451efcbd70f03e95a8f2c8d9e5ec3d35edcd27b4095ee00ee062abfc4", size = 13959410, upload-time = "2026-09-24T13:16:08.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/04/70e289437a67dfd686056ab4fd91880261a8233033a6a95d29393741fb97/ty-0.0.84-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:31e96307cf621babe969307dd657783d672e2d8bdbd06493710da635bf63fa07", size = 14350294, upload-time = "2026-09-24T13:16:11.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/f4/ae7dbd118425c850981d23efb850f0d9d7f6b8d2b852bde2767434f668b4/ty-0.0.84-py3-none-win32.whl", hash = "sha256:4bdc85f91cbaae35825f2b17bb4ac5de0563367875cb0f42b2bf07a1bf7ff2ba", size = 13176439, upload-time = "2026-09-24T13:16:15.049Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/ec/994d87252f065dda5a500bc8afc726d18f6582d81d6f4ff1feb8a267c800/ty-0.0.84-py3-none-win_amd64.whl", hash = "sha256:71cc9aa7a7c89d4e12c6a814e9c0c3310ad5bee44efedbd6f5a42d91e9ac0f0d", size = 13962879, upload-time = "2026-09-24T13:16:18.191Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/1c/e6e91852b72566b1ab9aca504712857dfb89324c12092a5bd61d42d34427/ty-0.0.84-py3-none-win_arm64.whl", hash = "sha256:6c9d3718c4a0d318bc9c92af9b2aa85c3294e7dff54cf1d69ad79568f56516da", size = 13656453, upload-time = "2026-09-24T13:16:21.51Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1337,11 +1338,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.7.0"
|
||||
version = "2.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -103,6 +103,8 @@ The CLI uses a `langgraph.json` configuration file with these key settings:
|
||||
}
|
||||
```
|
||||
|
||||
Git dependencies should use credential-free URLs. The CLI conservatively scans direct `langgraph.json` dependencies, common Python package files, uv project and lock files, and common Node.js package and lock files for HTTP Git URLs with userinfo. This check is not exhaustive: generated Docker builds can copy other files, including nested requirement or constraint files, into image layers without scanning them. For private dependencies, provide short-lived credentials through your build environment's secret-backed Git credential helper. Do not store credentials in copied files such as `langgraph.json` or `pip_config_file`.
|
||||
|
||||
See the [full documentation](https://reference.langchain.com/python/langgraph-cli) for detailed configuration options.
|
||||
|
||||
## Development
|
||||
|
||||
@@ -5,5 +5,5 @@ description = "Test for prerelease stuff"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"langgraph==1.1.5"
|
||||
"langgraph==1.2.6"
|
||||
]
|
||||
@@ -7,7 +7,7 @@ requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"langchain-openai==1.1.14",
|
||||
"langchain-anthropic==1.4.6",
|
||||
"langgraph==1.1.5"
|
||||
"langgraph==1.2.6"
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
|
||||
@@ -236,7 +236,7 @@ def swap_roles(state: InterviewState, name: str):
|
||||
converted = []
|
||||
for message in state["messages"]:
|
||||
if isinstance(message, AIMessage) and message.name != name:
|
||||
message = HumanMessage(**message.dict(exclude={"type"}))
|
||||
message = HumanMessage(**message.model_dump(exclude={"type"}))
|
||||
converted.append(message)
|
||||
return {"messages": converted}
|
||||
|
||||
|
||||
Generated
-285
@@ -1,285 +0,0 @@
|
||||
# This file is automatically @generated by Poetry 2.0.0 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.4.0"
|
||||
description = "High level compatibility layer for multiple asynchronous event loop implementations"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "anyio-4.4.0-py3-none-any.whl", hash = "sha256:c1b2d8f46a8a812513012e1107cb0e68c17159a7a594208005a57dc776e1bdc7"},
|
||||
{file = "anyio-4.4.0.tar.gz", hash = "sha256:5aadc6a1bbb7cdb0bede386cac5e2940f5e2ff3aa20277e991cf028e0585ce94"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""}
|
||||
idna = ">=2.8"
|
||||
sniffio = ">=1.1"
|
||||
typing-extensions = {version = ">=4.1", markers = "python_version < \"3.11\""}
|
||||
|
||||
[package.extras]
|
||||
doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"]
|
||||
test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"]
|
||||
trio = ["trio (>=0.23)"]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2024.7.4"
|
||||
description = "Python package for providing Mozilla's CA Bundle."
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90"},
|
||||
{file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.1.7"
|
||||
description = "Composable command line interface toolkit"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"},
|
||||
{file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
colorama = {version = "*", markers = "platform_system == \"Windows\""}
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
description = "Cross-platform colored terminal text."
|
||||
optional = false
|
||||
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
|
||||
groups = ["main"]
|
||||
markers = "platform_system == \"Windows\""
|
||||
files = [
|
||||
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
|
||||
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "exceptiongroup"
|
||||
version = "1.2.1"
|
||||
description = "Backport of PEP 654 (exception groups)"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["main"]
|
||||
markers = "python_version < \"3.11\""
|
||||
files = [
|
||||
{file = "exceptiongroup-1.2.1-py3-none-any.whl", hash = "sha256:5258b9ed329c5bbdd31a309f53cbfb0b155341807f6ff7606a1e801a891b29ad"},
|
||||
{file = "exceptiongroup-1.2.1.tar.gz", hash = "sha256:a4785e48b045528f5bfe627b6ad554ff32def154f42372786903b7abcfe1aa16"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
test = ["pytest (>=6)"]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"},
|
||||
{file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpcore"
|
||||
version = "1.0.9"
|
||||
description = "A minimal low-level HTTP client."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"},
|
||||
{file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
certifi = "*"
|
||||
h11 = ">=0.16"
|
||||
|
||||
[package.extras]
|
||||
asyncio = ["anyio (>=4.0,<5.0)"]
|
||||
http2 = ["h2 (>=3,<5)"]
|
||||
socks = ["socksio (==1.*)"]
|
||||
trio = ["trio (>=0.22.0,<1.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "httpx"
|
||||
version = "0.28.1"
|
||||
description = "The next generation HTTP client."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"},
|
||||
{file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
anyio = "*"
|
||||
certifi = "*"
|
||||
httpcore = "==1.*"
|
||||
idna = "*"
|
||||
|
||||
[package.extras]
|
||||
brotli = ["brotli", "brotlicffi"]
|
||||
cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"]
|
||||
http2 = ["h2 (>=3,<5)"]
|
||||
socks = ["socksio (==1.*)"]
|
||||
zstd = ["zstandard (>=0.18.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "httpx-sse"
|
||||
version = "0.4.0"
|
||||
description = "Consume Server-Sent Event (SSE) messages with HTTPX."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721"},
|
||||
{file = "httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.7"
|
||||
description = "Internationalized Domain Names in Applications (IDNA)"
|
||||
optional = false
|
||||
python-versions = ">=3.5"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"},
|
||||
{file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-cli"
|
||||
version = "0.1.52"
|
||||
description = "CLI for interacting with LangGraph API"
|
||||
optional = false
|
||||
python-versions = "^3.9.0,<4.0"
|
||||
groups = ["main"]
|
||||
files = []
|
||||
develop = true
|
||||
|
||||
[package.dependencies]
|
||||
click = "^8.1.7"
|
||||
|
||||
[package.source]
|
||||
type = "directory"
|
||||
url = ".."
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-sdk"
|
||||
version = "0.1.29"
|
||||
description = "SDK for interacting with LangGraph API"
|
||||
optional = false
|
||||
python-versions = "^3.9.0,<4.0"
|
||||
groups = ["main"]
|
||||
files = []
|
||||
develop = true
|
||||
|
||||
[package.dependencies]
|
||||
httpx = ">=0.25.2"
|
||||
httpx-sse = ">=0.4.0"
|
||||
orjson = ">=3.10.1"
|
||||
|
||||
[package.source]
|
||||
type = "directory"
|
||||
url = "../../sdk-py"
|
||||
|
||||
[[package]]
|
||||
name = "orjson"
|
||||
version = "3.10.5"
|
||||
description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "orjson-3.10.5-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:545d493c1f560d5ccfc134803ceb8955a14c3fcb47bbb4b2fee0232646d0b932"},
|
||||
{file = "orjson-3.10.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4324929c2dd917598212bfd554757feca3e5e0fa60da08be11b4aa8b90013c1"},
|
||||
{file = "orjson-3.10.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8c13ca5e2ddded0ce6a927ea5a9f27cae77eee4c75547b4297252cb20c4d30e6"},
|
||||
{file = "orjson-3.10.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b6c8e30adfa52c025f042a87f450a6b9ea29649d828e0fec4858ed5e6caecf63"},
|
||||
{file = "orjson-3.10.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:338fd4f071b242f26e9ca802f443edc588fa4ab60bfa81f38beaedf42eda226c"},
|
||||
{file = "orjson-3.10.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6970ed7a3126cfed873c5d21ece1cd5d6f83ca6c9afb71bbae21a0b034588d96"},
|
||||
{file = "orjson-3.10.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:235dadefb793ad12f7fa11e98a480db1f7c6469ff9e3da5e73c7809c700d746b"},
|
||||
{file = "orjson-3.10.5-cp310-none-win32.whl", hash = "sha256:be79e2393679eda6a590638abda16d167754393f5d0850dcbca2d0c3735cebe2"},
|
||||
{file = "orjson-3.10.5-cp310-none-win_amd64.whl", hash = "sha256:c4a65310ccb5c9910c47b078ba78e2787cb3878cdded1702ac3d0da71ddc5228"},
|
||||
{file = "orjson-3.10.5-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:cdf7365063e80899ae3a697def1277c17a7df7ccfc979990a403dfe77bb54d40"},
|
||||
{file = "orjson-3.10.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b68742c469745d0e6ca5724506858f75e2f1e5b59a4315861f9e2b1df77775a"},
|
||||
{file = "orjson-3.10.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7d10cc1b594951522e35a3463da19e899abe6ca95f3c84c69e9e901e0bd93d38"},
|
||||
{file = "orjson-3.10.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcbe82b35d1ac43b0d84072408330fd3295c2896973112d495e7234f7e3da2e1"},
|
||||
{file = "orjson-3.10.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10c0eb7e0c75e1e486c7563fe231b40fdd658a035ae125c6ba651ca3b07936f5"},
|
||||
{file = "orjson-3.10.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:53ed1c879b10de56f35daf06dbc4a0d9a5db98f6ee853c2dbd3ee9d13e6f302f"},
|
||||
{file = "orjson-3.10.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:099e81a5975237fda3100f918839af95f42f981447ba8f47adb7b6a3cdb078fa"},
|
||||
{file = "orjson-3.10.5-cp311-none-win32.whl", hash = "sha256:1146bf85ea37ac421594107195db8bc77104f74bc83e8ee21a2e58596bfb2f04"},
|
||||
{file = "orjson-3.10.5-cp311-none-win_amd64.whl", hash = "sha256:36a10f43c5f3a55c2f680efe07aa93ef4a342d2960dd2b1b7ea2dd764fe4a37c"},
|
||||
{file = "orjson-3.10.5-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:68f85ecae7af14a585a563ac741b0547a3f291de81cd1e20903e79f25170458f"},
|
||||
{file = "orjson-3.10.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28afa96f496474ce60d3340fe8d9a263aa93ea01201cd2bad844c45cd21f5268"},
|
||||
{file = "orjson-3.10.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9cd684927af3e11b6e754df80b9ffafd9fb6adcaa9d3e8fdd5891be5a5cad51e"},
|
||||
{file = "orjson-3.10.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d21b9983da032505f7050795e98b5d9eee0df903258951566ecc358f6696969"},
|
||||
{file = "orjson-3.10.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ad1de7fef79736dde8c3554e75361ec351158a906d747bd901a52a5c9c8d24b"},
|
||||
{file = "orjson-3.10.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d97531cdfe9bdd76d492e69800afd97e5930cb0da6a825646667b2c6c6c0211"},
|
||||
{file = "orjson-3.10.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d69858c32f09c3e1ce44b617b3ebba1aba030e777000ebdf72b0d8e365d0b2b3"},
|
||||
{file = "orjson-3.10.5-cp312-none-win32.whl", hash = "sha256:64c9cc089f127e5875901ac05e5c25aa13cfa5dbbbd9602bda51e5c611d6e3e2"},
|
||||
{file = "orjson-3.10.5-cp312-none-win_amd64.whl", hash = "sha256:b2efbd67feff8c1f7728937c0d7f6ca8c25ec81373dc8db4ef394c1d93d13dc5"},
|
||||
{file = "orjson-3.10.5-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:03b565c3b93f5d6e001db48b747d31ea3819b89abf041ee10ac6988886d18e01"},
|
||||
{file = "orjson-3.10.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:584c902ec19ab7928fd5add1783c909094cc53f31ac7acfada817b0847975f26"},
|
||||
{file = "orjson-3.10.5-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a35455cc0b0b3a1eaf67224035f5388591ec72b9b6136d66b49a553ce9eb1e6"},
|
||||
{file = "orjson-3.10.5-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1670fe88b116c2745a3a30b0f099b699a02bb3482c2591514baf5433819e4f4d"},
|
||||
{file = "orjson-3.10.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:185c394ef45b18b9a7d8e8f333606e2e8194a50c6e3c664215aae8cf42c5385e"},
|
||||
{file = "orjson-3.10.5-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:ca0b3a94ac8d3886c9581b9f9de3ce858263865fdaa383fbc31c310b9eac07c9"},
|
||||
{file = "orjson-3.10.5-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dfc91d4720d48e2a709e9c368d5125b4b5899dced34b5400c3837dadc7d6271b"},
|
||||
{file = "orjson-3.10.5-cp38-none-win32.whl", hash = "sha256:c05f16701ab2a4ca146d0bca950af254cb7c02f3c01fca8efbbad82d23b3d9d4"},
|
||||
{file = "orjson-3.10.5-cp38-none-win_amd64.whl", hash = "sha256:8a11d459338f96a9aa7f232ba95679fc0c7cedbd1b990d736467894210205c09"},
|
||||
{file = "orjson-3.10.5-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:85c89131d7b3218db1b24c4abecea92fd6c7f9fab87441cfc342d3acc725d807"},
|
||||
{file = "orjson-3.10.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb66215277a230c456f9038d5e2d84778141643207f85336ef8d2a9da26bd7ca"},
|
||||
{file = "orjson-3.10.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:51bbcdea96cdefa4a9b4461e690c75ad4e33796530d182bdd5c38980202c134a"},
|
||||
{file = "orjson-3.10.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dbead71dbe65f959b7bd8cf91e0e11d5338033eba34c114f69078d59827ee139"},
|
||||
{file = "orjson-3.10.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5df58d206e78c40da118a8c14fc189207fffdcb1f21b3b4c9c0c18e839b5a214"},
|
||||
{file = "orjson-3.10.5-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:c4057c3b511bb8aef605616bd3f1f002a697c7e4da6adf095ca5b84c0fd43595"},
|
||||
{file = "orjson-3.10.5-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b39e006b00c57125ab974362e740c14a0c6a66ff695bff44615dcf4a70ce2b86"},
|
||||
{file = "orjson-3.10.5-cp39-none-win32.whl", hash = "sha256:eded5138cc565a9d618e111c6d5c2547bbdd951114eb822f7f6309e04db0fb47"},
|
||||
{file = "orjson-3.10.5-cp39-none-win_amd64.whl", hash = "sha256:cc28e90a7cae7fcba2493953cff61da5a52950e78dc2dacfe931a317ee3d8de7"},
|
||||
{file = "orjson-3.10.5.tar.gz", hash = "sha256:7a5baef8a4284405d96c90c7c62b755e9ef1ada84c2406c24a9ebec86b89f46d"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sniffio"
|
||||
version = "1.3.1"
|
||||
description = "Sniff out which async library your code is running under"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"},
|
||||
{file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.12.2"
|
||||
description = "Backported and Experimental Type Hints for Python 3.8+"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
markers = "python_version < \"3.11\""
|
||||
files = [
|
||||
{file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"},
|
||||
{file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"},
|
||||
]
|
||||
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = "^3.9.0,<4.0"
|
||||
content-hash = "ec5109729f30d2033a10a10e8f8d3ed94c7d96d5d31025b4815b0123664bb063"
|
||||
@@ -21,25 +21,30 @@
|
||||
"test:all": "yarn test && yarn test:int && yarn lint:langgraph"
|
||||
},
|
||||
"dependencies": {
|
||||
"@langchain/core": "^1.1.48",
|
||||
"@langchain/langgraph": "^1.3.3"
|
||||
"@langchain/core": "^1.2.9",
|
||||
"@langchain/langgraph": "^1.4.13"
|
||||
},
|
||||
"resolutions": {
|
||||
"@langchain/langgraph-checkpoint": "1.0.4",
|
||||
"jest/**/js-yaml": "3.15.2",
|
||||
"@eslint/eslintrc/js-yaml": "4.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.5",
|
||||
"@eslint/eslintrc": "^3.3.6",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tsconfig/recommended": "^1.0.13",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.60.1",
|
||||
"@typescript-eslint/parser": "^8.60.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.68.0",
|
||||
"@typescript-eslint/parser": "^8.68.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"eslint": "^10.4.1",
|
||||
"eslint": "^10.9.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-no-instanceof": "^1.0.1",
|
||||
"eslint-plugin-prettier": "^5.5.6",
|
||||
"jest": "^30.4.2",
|
||||
"prettier": "^3.8.3",
|
||||
"ts-jest": "^29.4.11",
|
||||
"typescript": "^6.0.3"
|
||||
"jest": "^30.5.0",
|
||||
"prettier": "^3.9.6",
|
||||
"ts-jest": "^29.4.12",
|
||||
"typescript": "^7.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
+1011
-804
File diff suppressed because it is too large
Load Diff
@@ -9,10 +9,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@js-monorepo-example/shared": "*",
|
||||
"@langchain/core": "^1.1.48",
|
||||
"@langchain/langgraph": "^1.3.3"
|
||||
"@langchain/core": "^1.2.13",
|
||||
"@langchain/langgraph": "^1.4.18"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^6.0.3"
|
||||
"typescript": "^7.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,6 @@
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^6.0.3"
|
||||
"typescript": "^7.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,18 +17,18 @@
|
||||
"lint": "eslint 'apps/**/*.ts' 'libs/**/*.ts'"
|
||||
},
|
||||
"devDependencies": {
|
||||
"turbo": "^2.9.16",
|
||||
"typescript": "^6.0.3",
|
||||
"turbo": "^2.11.5",
|
||||
"typescript": "^7.0.2",
|
||||
"@tsconfig/recommended": "^1.0.13",
|
||||
"@eslint/eslintrc": "^3.3.5",
|
||||
"@eslint/eslintrc": "^3.3.7",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"eslint": "^10.4.1",
|
||||
"eslint": "^10.11.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-import": "^2.27.5",
|
||||
"eslint-plugin-no-instanceof": "^1.0.1",
|
||||
"eslint-plugin-prettier": "^5.5.6",
|
||||
"@typescript-eslint/eslint-plugin": "^8.60.1",
|
||||
"@typescript-eslint/parser": "^8.60.1",
|
||||
"prettier": "^3.8.3"
|
||||
"@typescript-eslint/eslint-plugin": "^8.70.1",
|
||||
"@typescript-eslint/parser": "^8.70.1",
|
||||
"prettier": "^3.9.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,24 @@
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
"@cacheable/memory@^2.2.0":
|
||||
version "2.2.0"
|
||||
resolved "https://registry.yarnpkg.com/@cacheable/memory/-/memory-2.2.0.tgz#72aeb8b051f6d597d10ac8595b94ad8302bd2239"
|
||||
integrity sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==
|
||||
dependencies:
|
||||
"@cacheable/utils" "^2.5.0"
|
||||
"@keyv/bigmap" "^1.3.1"
|
||||
hookified "^1.15.1"
|
||||
keyv "^5.6.0"
|
||||
|
||||
"@cacheable/utils@^2.5.0":
|
||||
version "2.5.0"
|
||||
resolved "https://registry.yarnpkg.com/@cacheable/utils/-/utils-2.5.0.tgz#534c91113aa48fe43baedb169550b6ee070ef303"
|
||||
integrity sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==
|
||||
dependencies:
|
||||
hashery "^1.5.1"
|
||||
keyv "^5.6.0"
|
||||
|
||||
"@cfworker/json-schema@^4.0.2":
|
||||
version "4.1.1"
|
||||
resolved "https://registry.yarnpkg.com/@cfworker/json-schema/-/json-schema-4.1.1.tgz#4a2a3947ee9fa7b7c24be981422831b8674c3be6"
|
||||
@@ -28,10 +46,10 @@
|
||||
debug "^4.3.1"
|
||||
minimatch "^10.2.4"
|
||||
|
||||
"@eslint/config-helpers@^0.6.0":
|
||||
version "0.6.0"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.6.0.tgz#ef9a36881d39dfd5dbeac22b0da997fabfb08b03"
|
||||
integrity sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==
|
||||
"@eslint/config-helpers@^0.7.0":
|
||||
version "0.7.0"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.7.0.tgz#09ee4aa07b73f059ec2d4c74bf4b2ff02b322377"
|
||||
integrity sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==
|
||||
dependencies:
|
||||
"@eslint/core" "^1.2.1"
|
||||
|
||||
@@ -42,10 +60,10 @@
|
||||
dependencies:
|
||||
"@types/json-schema" "^7.0.15"
|
||||
|
||||
"@eslint/eslintrc@^3.3.5":
|
||||
version "3.3.5"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-3.3.5.tgz#c131793cfc1a7b96f24a83e0a8bbd4b881558c60"
|
||||
integrity sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==
|
||||
"@eslint/eslintrc@^3.3.7":
|
||||
version "3.3.7"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-3.3.7.tgz#76d3dedec4a30ea32df797bf8a0085c1311b7316"
|
||||
integrity sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==
|
||||
dependencies:
|
||||
ajv "^6.14.0"
|
||||
debug "^4.3.2"
|
||||
@@ -53,7 +71,7 @@
|
||||
globals "^14.0.0"
|
||||
ignore "^5.2.0"
|
||||
import-fresh "^3.2.1"
|
||||
js-yaml "^4.1.1"
|
||||
js-yaml "^4.3.2"
|
||||
minimatch "^3.1.5"
|
||||
strip-json-comments "^3.1.1"
|
||||
|
||||
@@ -67,10 +85,10 @@
|
||||
resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-3.0.5.tgz#88e9bf4d11d2b19c082e78ebe7ce88724a5eb091"
|
||||
integrity sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==
|
||||
|
||||
"@eslint/plugin-kit@^0.7.2":
|
||||
version "0.7.2"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz#4b0962f3f2c7ce8bc98b3ecfe34525c09d2cb729"
|
||||
integrity sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==
|
||||
"@eslint/plugin-kit@^0.7.3":
|
||||
version "0.7.3"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz#cc7268cc36405b331ef92db1bc37971f21d66fe1"
|
||||
integrity sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==
|
||||
dependencies:
|
||||
"@eslint/core" "^1.2.1"
|
||||
levn "^0.4.1"
|
||||
@@ -103,10 +121,23 @@
|
||||
resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-9.0.0.tgz#4d0a3f127058043bf2e7ee169eaf30ed901302f3"
|
||||
integrity sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==
|
||||
|
||||
"@langchain/core@^1.1.48":
|
||||
version "1.1.48"
|
||||
resolved "https://registry.yarnpkg.com/@langchain/core/-/core-1.1.48.tgz#dfe7f6a64b0e3e79e5357ed4b402c021ee315a7f"
|
||||
integrity sha512-fQU6Guyb1pwc2fEplmA8FPbKfOMAofjnyJzExevro0FxEiuGHE18Ov/ZHmT9trWCDTZRI9eW1VIc6aChxV8pAQ==
|
||||
"@keyv/bigmap@^1.3.1":
|
||||
version "1.3.1"
|
||||
resolved "https://registry.yarnpkg.com/@keyv/bigmap/-/bigmap-1.3.1.tgz#fc82fa83947e7ff68c6798d08907db842771ef2c"
|
||||
integrity sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==
|
||||
dependencies:
|
||||
hashery "^1.4.0"
|
||||
hookified "^1.15.0"
|
||||
|
||||
"@keyv/serialize@^1.1.1":
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/@keyv/serialize/-/serialize-1.1.1.tgz#0c01dd3a3483882af7cf3878d4e71d505c81fc4a"
|
||||
integrity sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==
|
||||
|
||||
"@langchain/core@^1.2.13":
|
||||
version "1.2.13"
|
||||
resolved "https://registry.yarnpkg.com/@langchain/core/-/core-1.2.13.tgz#4ea4eb0308a25af350f75de2fbf34aecb819177e"
|
||||
integrity sha512-ADGTxZ84n3civruUX1LlTOdua/PDGoni2U6TmKTX7hvRU2Jlepu8vLJI4Wnwj45KUKhUCrW94Il12Q2bxE3e8A==
|
||||
dependencies:
|
||||
"@cfworker/json-schema" "^4.0.2"
|
||||
"@standard-schema/spec" "^1.1.0"
|
||||
@@ -116,39 +147,35 @@
|
||||
p-queue "^6.6.2"
|
||||
zod "^3.25.76 || ^4"
|
||||
|
||||
"@langchain/langgraph-checkpoint@^1.0.4":
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-1.0.4.tgz#893aa7255b2fe810fdeb50281f6750a3b0a6fef7"
|
||||
integrity sha512-1y5MgZ0gXXrtmoy56e3kaBChI3GwFPIKl27xkrHwN+VE/3iUsyr9gO3Jtp7kdKAe6diZGbcas5bdC/r0yUwTZA==
|
||||
dependencies:
|
||||
uuid "^14.0.0"
|
||||
"@langchain/langgraph-checkpoint@^1.1.5":
|
||||
version "1.1.5"
|
||||
resolved "https://registry.yarnpkg.com/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-1.1.5.tgz#c793177f9afcce31a1e9922f317f88fec1254282"
|
||||
integrity sha512-BwDwl5VeTOh6CVuiIPgsUgfK51vTJDMSbFcSCUfjJWsl8/DPdK/mbv+ejxJstkSk/BlSPMP4JfXWcN6jD2ea2Q==
|
||||
|
||||
"@langchain/langgraph-sdk@~1.9.11":
|
||||
version "1.9.11"
|
||||
resolved "https://registry.yarnpkg.com/@langchain/langgraph-sdk/-/langgraph-sdk-1.9.11.tgz#d16f18b2c48e544436489172d86b3c78524eb679"
|
||||
integrity sha512-mhadkZy4LQ97NJwvATiVIkSxVfOnauXNhrVHFgGnzyqr5zzPLS0VIKJW9xKT+pM8yLqW8Qj6+nPPNhwGUaxoRw==
|
||||
"@langchain/langgraph-sdk@~1.12.0":
|
||||
version "1.12.0"
|
||||
resolved "https://registry.yarnpkg.com/@langchain/langgraph-sdk/-/langgraph-sdk-1.12.0.tgz#b063a7eba0a66f51cbc841a824626a47eca1eec9"
|
||||
integrity sha512-F3AOZjKZRUGmE3FzY+RaVZI6WzXOkwnuF7jqgto6rDPSnO9OdVdYGvwGDi3jjRGf5xLoDtX2dpsR9z5KptU+5A==
|
||||
dependencies:
|
||||
"@langchain/protocol" "^0.0.16"
|
||||
"@langchain/protocol" "^0.0.19"
|
||||
"@types/json-schema" "^7.0.15"
|
||||
p-queue "^9.0.1"
|
||||
p-retry "^7.1.1"
|
||||
uuid "^14.0.0"
|
||||
|
||||
"@langchain/langgraph@^1.3.3":
|
||||
version "1.3.3"
|
||||
resolved "https://registry.yarnpkg.com/@langchain/langgraph/-/langgraph-1.3.3.tgz#c7bb19029e37971116e07101be3d8fd4b6483d50"
|
||||
integrity sha512-8xbpGUQNBcWua7ivT5vUvDnQ+6Qbt0JO8RisgXZ8guPXNqh8plGVvrODW68S4AlJbOYY2yi0ROKtrL/1yN3MBQ==
|
||||
"@langchain/langgraph@^1.4.18":
|
||||
version "1.4.18"
|
||||
resolved "https://registry.yarnpkg.com/@langchain/langgraph/-/langgraph-1.4.18.tgz#c8cc4ea734834262fdb00ae6a45a0849620e4fd2"
|
||||
integrity sha512-yrMMJ9hk2NVMD2xU2WoVrgFawAU6s/RzEl/dX9BhlyxZHazo1wHY35z4K2BIBzTUh4z3giCzrUoqRAqW2CWpWg==
|
||||
dependencies:
|
||||
"@langchain/langgraph-checkpoint" "^1.0.4"
|
||||
"@langchain/langgraph-sdk" "~1.9.11"
|
||||
"@langchain/protocol" "^0.0.16"
|
||||
"@langchain/langgraph-checkpoint" "^1.1.5"
|
||||
"@langchain/langgraph-sdk" "~1.12.0"
|
||||
"@langchain/protocol" "^0.0.19"
|
||||
"@standard-schema/spec" "1.1.0"
|
||||
uuid "^14.0.0"
|
||||
|
||||
"@langchain/protocol@^0.0.16":
|
||||
version "0.0.16"
|
||||
resolved "https://registry.yarnpkg.com/@langchain/protocol/-/protocol-0.0.16.tgz#68b63d58feaf4f0acf77405cb8fb16f9c3f09b60"
|
||||
integrity sha512-ws+J7MaHyhO5dG7f0vdyHQiUn9hoCnki0f3crJPa4MCTGzcRC39jYSCghyrGtBPYQnZbUQiGyRVpW3z3M8IpJg==
|
||||
"@langchain/protocol@^0.0.19":
|
||||
version "0.0.19"
|
||||
resolved "https://registry.yarnpkg.com/@langchain/protocol/-/protocol-0.0.19.tgz#7fe43fed115dc34b3b4c246e8d5283fbcbfab7b0"
|
||||
integrity sha512-9hKcRrH7cBX6gfutdfXPoft1OCchHe4FEpALoDJMl5Qu+n/YG5ynZmyu8+8cxORlPwHBoKTxggvXz+76M1yX1Q==
|
||||
|
||||
"@pkgr/core@^0.3.6":
|
||||
version "0.3.6"
|
||||
@@ -170,35 +197,35 @@
|
||||
resolved "https://registry.yarnpkg.com/@tsconfig/recommended/-/recommended-1.0.13.tgz#269fce3ad04ca70b93269ff44cca81b950f542da"
|
||||
integrity sha512-sySRuBfMKyKO/j2ZAhR8kSembhjuPEV4Ra3AHtmWLq51+iGaudr45crPSzNC5b7/Ctrh9dfUpBuTlYrH6rM58Q==
|
||||
|
||||
"@turbo/darwin-64@2.9.16":
|
||||
version "2.9.16"
|
||||
resolved "https://registry.yarnpkg.com/@turbo/darwin-64/-/darwin-64-2.9.16.tgz#f4146eab98e020d658a9a5d7feb846acba653546"
|
||||
integrity sha512-jLjApWTSNd7JZ5JaLYfelW1ytnGQOvB7ivl+2RD1xQvJTbi8I9gBjzcga7tDZVPyaxpl10YTfJt3BrYXR18KDw==
|
||||
"@turbo/darwin-64@2.11.5":
|
||||
version "2.11.5"
|
||||
resolved "https://registry.yarnpkg.com/@turbo/darwin-64/-/darwin-64-2.11.5.tgz#6ddc580c2b7d05e578ca1c06b3f61473f4b7b00e"
|
||||
integrity sha512-KdY4y7BBjIisl+jAkb3FFaRjClGqHyaeHCcVcCT8LBzFFwUu6thgfxyRZAx0uL4KoxLmH+iOan8UhVf/aDDcaA==
|
||||
|
||||
"@turbo/darwin-arm64@2.9.16":
|
||||
version "2.9.16"
|
||||
resolved "https://registry.yarnpkg.com/@turbo/darwin-arm64/-/darwin-arm64-2.9.16.tgz#1d01ca232fc189045d6a4dba4741f462e0343b8c"
|
||||
integrity sha512-YPgrn+5HIGzrx0O2a631SV4MBQUe4W/DafMFUuBVgaU32PW9/OTT0ehviF0QSxTXuRJlHvW2eUTemddF5/spmw==
|
||||
"@turbo/darwin-arm64@2.11.5":
|
||||
version "2.11.5"
|
||||
resolved "https://registry.yarnpkg.com/@turbo/darwin-arm64/-/darwin-arm64-2.11.5.tgz#a756c67bb828cd00d03e909769dd39453c1f1e69"
|
||||
integrity sha512-ToZDGi2u410+TfRtC/O1/LpujGgvNJoRBZnwVXob7oO0+3OSlwIO3zofXKUvwQvlba7a8ZXPyCZwufjAXd7f0w==
|
||||
|
||||
"@turbo/linux-64@2.9.16":
|
||||
version "2.9.16"
|
||||
resolved "https://registry.yarnpkg.com/@turbo/linux-64/-/linux-64-2.9.16.tgz#8db630e6e5681a2ea48f7df1bca57bbd9bdf3e25"
|
||||
integrity sha512-vAEf1H6l26lTpl9FJ/peQo1NUB8RC0sbEJJz5mPcUhHA2bPDup2x3CZPgo/bH8S4cUcBLm4FN3UHd5iUO2RAew==
|
||||
"@turbo/linux-64@2.11.5":
|
||||
version "2.11.5"
|
||||
resolved "https://registry.yarnpkg.com/@turbo/linux-64/-/linux-64-2.11.5.tgz#b95c05218aebb06c06e5f991d48c55b78dc5e005"
|
||||
integrity sha512-Ub2d/nAbdWLaSytcEaPp1wm/YuIvHEIqNrCRMI/UgOGyFdOhgxm8VyWPAJilQWRYxYVrqEUoy2K5S5K8SG50rA==
|
||||
|
||||
"@turbo/linux-arm64@2.9.16":
|
||||
version "2.9.16"
|
||||
resolved "https://registry.yarnpkg.com/@turbo/linux-arm64/-/linux-arm64-2.9.16.tgz#d1de82fc8075c4774b214dd2f5e8e2570bb03643"
|
||||
integrity sha512-xDBLR2PZg4BrQOchfG6svgpv5FCNJ2TOtT2psLdEJcdKo1BH+pnPs9Xj6pvUjgfkHbuvBOfeE4R6tvxMoQKDHQ==
|
||||
"@turbo/linux-arm64@2.11.5":
|
||||
version "2.11.5"
|
||||
resolved "https://registry.yarnpkg.com/@turbo/linux-arm64/-/linux-arm64-2.11.5.tgz#c7a1d1a44c8c8dcff4b72fbafe9a5059c8fb668a"
|
||||
integrity sha512-JQjmGxp724Wuft9FAxPMtmBur1CEdUyxJ+FVoIvKkgj63rXHl9PYCWrsU2tMjVi609XTQ3M8vZD9Yc9qVAVYrw==
|
||||
|
||||
"@turbo/windows-64@2.9.16":
|
||||
version "2.9.16"
|
||||
resolved "https://registry.yarnpkg.com/@turbo/windows-64/-/windows-64-2.9.16.tgz#e369c51edd737978651c2f6b4ce49b582976d66f"
|
||||
integrity sha512-NBAJnaUiGdgkSzQwUIdOvkCkcpTSu58G/sBGa0mvBtzfvFOOgrQwepKOOQ8cp6sWM6OcKDNFj2p1dsZA1OWjPg==
|
||||
"@turbo/windows-64@2.11.5":
|
||||
version "2.11.5"
|
||||
resolved "https://registry.yarnpkg.com/@turbo/windows-64/-/windows-64-2.11.5.tgz#60bdaaa7e8ba32f32623b31a1c75f6c3db400a0c"
|
||||
integrity sha512-AFkbPmXNd8XgCi/66YEHR2bVl3gtusXyDihTIVEPAuSB+7ulLmJePvz1unWPVKLHdbCTTIdKq2mpJ1B1e3lJXw==
|
||||
|
||||
"@turbo/windows-arm64@2.9.16":
|
||||
version "2.9.16"
|
||||
resolved "https://registry.yarnpkg.com/@turbo/windows-arm64/-/windows-arm64-2.9.16.tgz#dbb2b2863e80eb9ca8da8bd8da63e2cc457e470c"
|
||||
integrity sha512-Y7SJppD0Z8wjO3Ec0ZGd9KQ4Yv0BMnA8CIowj5Vp+OEVsosXDG2weK6/t1RRLfJmc2Ozrnd6y4DOgQys+mn3WQ==
|
||||
"@turbo/windows-arm64@2.11.5":
|
||||
version "2.11.5"
|
||||
resolved "https://registry.yarnpkg.com/@turbo/windows-arm64/-/windows-arm64-2.11.5.tgz#18bd5a0ad30c4e094995786bd78916b6de6b5c1c"
|
||||
integrity sha512-52ziXYP3snGOxjv19xA3WL1WDKv9Q6uDAa/7EKIR5j4hocMlYURkrOHbJ2ZZI+VfPU5MwKpcGeRfHxHJu7Y/Pw==
|
||||
|
||||
"@types/esrecurse@^4.3.1":
|
||||
version "4.3.1"
|
||||
@@ -220,102 +247,212 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
|
||||
integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==
|
||||
|
||||
"@typescript-eslint/eslint-plugin@^8.60.1":
|
||||
version "8.60.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.1.tgz#c1060bb8fa4be80624d3f3dec8dd9caca373af76"
|
||||
integrity sha512-JQ4S5GB0tfjO8BuJ4fcX+HodkzJjYBV+7OJ+wLygaX7OGQ7FudyHL4NSCA6ob+w3Yn+5MkKIozOwQhXeM7opVg==
|
||||
"@typescript-eslint/eslint-plugin@^8.70.1":
|
||||
version "8.70.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.1.tgz#d404bac4cb04e4dcd20086e46e13c952bd0771e0"
|
||||
integrity sha512-nDNrUQ/4ruSNYbu749TRY7cfrzPtoLHEXSNBI8aaNY32LlZCajixqRf3FqcKC4p5Cam4VOHYx/t+i5+nKXvrqA==
|
||||
dependencies:
|
||||
"@eslint-community/regexpp" "^4.12.2"
|
||||
"@typescript-eslint/scope-manager" "8.60.1"
|
||||
"@typescript-eslint/type-utils" "8.60.1"
|
||||
"@typescript-eslint/utils" "8.60.1"
|
||||
"@typescript-eslint/visitor-keys" "8.60.1"
|
||||
"@typescript-eslint/scope-manager" "8.70.1"
|
||||
"@typescript-eslint/type-utils" "8.70.1"
|
||||
"@typescript-eslint/utils" "8.70.1"
|
||||
"@typescript-eslint/visitor-keys" "8.70.1"
|
||||
ignore "^7.0.5"
|
||||
natural-compare "^1.4.0"
|
||||
ts-api-utils "^2.5.0"
|
||||
|
||||
"@typescript-eslint/parser@^8.60.1":
|
||||
version "8.60.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.60.1.tgz#a9d7f30850384d34b41f4687dd8944823c09e289"
|
||||
integrity sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==
|
||||
"@typescript-eslint/parser@^8.70.1":
|
||||
version "8.70.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.70.1.tgz#6c99b7b954efc05dae179001c8b477d1a8948dcf"
|
||||
integrity sha512-nO974WLllwhSFWQXnMLj6nDGa8f0khKEz1JzpPJ1u7Vm/4X1X6ZHajpoknU4bb41vJyMB0HHVyS2GqdhWfIXZw==
|
||||
dependencies:
|
||||
"@typescript-eslint/scope-manager" "8.60.1"
|
||||
"@typescript-eslint/types" "8.60.1"
|
||||
"@typescript-eslint/typescript-estree" "8.60.1"
|
||||
"@typescript-eslint/visitor-keys" "8.60.1"
|
||||
"@typescript-eslint/scope-manager" "8.70.1"
|
||||
"@typescript-eslint/types" "8.70.1"
|
||||
"@typescript-eslint/typescript-estree" "8.70.1"
|
||||
"@typescript-eslint/visitor-keys" "8.70.1"
|
||||
debug "^4.4.3"
|
||||
|
||||
"@typescript-eslint/project-service@8.60.1":
|
||||
version "8.60.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.60.1.tgz#eb29712f58d72c222fc727162e92f2ab4670971b"
|
||||
integrity sha512-eXkTH2bxmXlqD1RnOPmLZ9ZM9D3VwSx04JOwBnP9RQ+yUA5a2Mu7SfW8uaV2Aon53NJzZlZYuX7tn91Izf+xaw==
|
||||
"@typescript-eslint/project-service@8.70.1":
|
||||
version "8.70.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.70.1.tgz#d5635594cc8a573f0e15a772b1704e93a00134c1"
|
||||
integrity sha512-62xOgboPfwc3/IgPSX/W6oQR3ZbF04194FPGUGH8HL8iLFHbt/456/8Ph1wLNUgVF+s94FlHoipBsz+v7+LMnA==
|
||||
dependencies:
|
||||
"@typescript-eslint/tsconfig-utils" "^8.60.1"
|
||||
"@typescript-eslint/types" "^8.60.1"
|
||||
"@typescript-eslint/tsconfig-utils" "^8.70.1"
|
||||
"@typescript-eslint/types" "^8.70.1"
|
||||
debug "^4.4.3"
|
||||
|
||||
"@typescript-eslint/scope-manager@8.60.1":
|
||||
version "8.60.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.60.1.tgz#2f875962eaad0a0789cc3c36aea9b4ddeb2dd9c8"
|
||||
integrity sha512-gvI5OQoptnxQnchOirukCuQ55svJSTuD/4k5+pC267xyBtYry748R9/c3tYUzb/iE6RZfllRz2lVulLCHkTm4w==
|
||||
"@typescript-eslint/scope-manager@8.70.1":
|
||||
version "8.70.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.70.1.tgz#0feeae4da2d279b960cdbfe5e0a02fbf1195e93f"
|
||||
integrity sha512-Pa0EeSeAusQc1WbjQMac+YfenewYTBu0KjgYvkUKwhXaHUKbFog23Dm/rp0DX/6tyYOQ3Xl1a+3EcFNZynGHCw==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.60.1"
|
||||
"@typescript-eslint/visitor-keys" "8.60.1"
|
||||
"@typescript-eslint/types" "8.70.1"
|
||||
"@typescript-eslint/visitor-keys" "8.70.1"
|
||||
|
||||
"@typescript-eslint/tsconfig-utils@8.60.1", "@typescript-eslint/tsconfig-utils@^8.60.1":
|
||||
version "8.60.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.1.tgz#bee8b942a13679a878101c9c74577d732062ed93"
|
||||
integrity sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA==
|
||||
"@typescript-eslint/tsconfig-utils@8.70.1":
|
||||
version "8.70.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.1.tgz#e9353a6e5f3498a29b40946ca3e11f6166fef09e"
|
||||
integrity sha512-jumze1fPI+sDOaM2TWGQdn39PDxTr7TZGeuyLkAbNyx2vtMT3uRnVKChN0hfht5V2TugphJzF6bYXvBcE09qqg==
|
||||
|
||||
"@typescript-eslint/type-utils@8.60.1":
|
||||
version "8.60.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.60.1.tgz#1ae45f0f2a701354beea4a58c2161e40a5e3c379"
|
||||
integrity sha512-sdwTrpjosW7ANQYJ39ZBF1ZyEMEGVB2UsikrserVM/30a/F1dTLnu9bGxEdosugyu5caigjLrR2qiD11asjI1A==
|
||||
"@typescript-eslint/tsconfig-utils@^8.70.1":
|
||||
version "8.71.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.0.tgz#83a69c9a514af8f5ff099cc6c0d609c273e8f039"
|
||||
integrity sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==
|
||||
|
||||
"@typescript-eslint/type-utils@8.70.1":
|
||||
version "8.70.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.70.1.tgz#6ea36f2c7dc0776c1c8e1c380b4896f00bc4c27f"
|
||||
integrity sha512-7zKTnyvaVWqzLZHPFQtX1hVHqgkMC+WebPWakNCSyrQVbIP1AM0L0TlBZtACldIRb6PptI8Odk+jyZ5kP3B1VA==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.60.1"
|
||||
"@typescript-eslint/typescript-estree" "8.60.1"
|
||||
"@typescript-eslint/utils" "8.60.1"
|
||||
"@typescript-eslint/types" "8.70.1"
|
||||
"@typescript-eslint/typescript-estree" "8.70.1"
|
||||
"@typescript-eslint/utils" "8.70.1"
|
||||
debug "^4.4.3"
|
||||
ts-api-utils "^2.5.0"
|
||||
|
||||
"@typescript-eslint/types@8.60.1", "@typescript-eslint/types@^8.60.1":
|
||||
version "8.60.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.60.1.tgz#ccdc482ba9e17f9723a10ce240b5e67dad3046c4"
|
||||
integrity sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==
|
||||
"@typescript-eslint/types@8.70.1":
|
||||
version "8.70.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.70.1.tgz#2022b9d07d057174eba25b9bbd289641d262a196"
|
||||
integrity sha512-Dm1ypdhhrGCTyyehxElhgJ6kgk8MVCv5qXdoOVqPr1uqk42jX8KjrZqhROvdShczA8qrDoYiOWn1ykWlx2k81Q==
|
||||
|
||||
"@typescript-eslint/typescript-estree@8.60.1":
|
||||
version "8.60.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.1.tgz#016630b119228bf483ddc652703a6a038f3fdd74"
|
||||
integrity sha512-alpRkfG8hlVE5kdJW2GkfgDgXxold3e8e4l6EnmhRmRLbekgAPCCGDVD++sABy9FcgPFroq+uFcCSM1vR57Cew==
|
||||
"@typescript-eslint/types@^8.70.1":
|
||||
version "8.71.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.71.0.tgz#a230a121e71665060afe27d1f1ad110371f9451e"
|
||||
integrity sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==
|
||||
|
||||
"@typescript-eslint/typescript-estree@8.70.1":
|
||||
version "8.70.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.70.1.tgz#ced48fee6f7043835d2edfed9786ae74813a3c99"
|
||||
integrity sha512-TU8PwyGN0PQJUcE96mw8eCQ44SmxGdQlJmlWakHaHQ15eIuuvye5yNtmh/i6oS88jzXVQB71xdNkbkB/fMwL0g==
|
||||
dependencies:
|
||||
"@typescript-eslint/project-service" "8.60.1"
|
||||
"@typescript-eslint/tsconfig-utils" "8.60.1"
|
||||
"@typescript-eslint/types" "8.60.1"
|
||||
"@typescript-eslint/visitor-keys" "8.60.1"
|
||||
"@typescript-eslint/project-service" "8.70.1"
|
||||
"@typescript-eslint/tsconfig-utils" "8.70.1"
|
||||
"@typescript-eslint/types" "8.70.1"
|
||||
"@typescript-eslint/visitor-keys" "8.70.1"
|
||||
debug "^4.4.3"
|
||||
minimatch "^10.2.2"
|
||||
semver "^7.7.3"
|
||||
tinyglobby "^0.2.15"
|
||||
ts-api-utils "^2.5.0"
|
||||
|
||||
"@typescript-eslint/utils@8.60.1":
|
||||
version "8.60.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.60.1.tgz#31cf566095602d9fe8ad91837d2eb520b8de762b"
|
||||
integrity sha512-h2MPBLoNtjc3qZWfY3Tl51yPorQ2McHn8pJfcMNTcIvrrZrr90Ykffit0yjrPFWQcRcUxzH20+6OcVdW4yHtUg==
|
||||
"@typescript-eslint/utils@8.70.1":
|
||||
version "8.70.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.70.1.tgz#6a97887cee702df513692f5cdc7f79baeef48e02"
|
||||
integrity sha512-Esgul8MsnKnRLdYU2Eb2cRV9bS5HJYtKj1ByJnOzzG2M58DGdSUQ1jUuILxipqcpB2h9WLrbD5GijIWUjX/Tqw==
|
||||
dependencies:
|
||||
"@eslint-community/eslint-utils" "^4.9.1"
|
||||
"@typescript-eslint/scope-manager" "8.60.1"
|
||||
"@typescript-eslint/types" "8.60.1"
|
||||
"@typescript-eslint/typescript-estree" "8.60.1"
|
||||
"@typescript-eslint/scope-manager" "8.70.1"
|
||||
"@typescript-eslint/types" "8.70.1"
|
||||
"@typescript-eslint/typescript-estree" "8.70.1"
|
||||
|
||||
"@typescript-eslint/visitor-keys@8.60.1":
|
||||
version "8.60.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.1.tgz#165d1d8901137b944efaf18f00ab5ecb57f06995"
|
||||
integrity sha512-EbGRQg4FhrmwLodl+t3JNAnXHWVr9Vp+Zl1QBZVPY4ByfkzIT8cX3K6QWODHtkIZqqJVEWvhHSx3v5PDHsaQag==
|
||||
"@typescript-eslint/visitor-keys@8.70.1":
|
||||
version "8.70.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.70.1.tgz#b4cde0ed16982bd963a6310fcae554f59fd14124"
|
||||
integrity sha512-Vwj9lUIW5Xq3wQ9w6gv3R86g1hMK8f2zNOdGTAgeXUMMXFK78G9ruCjjqutHMNJc0+CH7LYRnHeUB9IT8wFmcw==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.60.1"
|
||||
"@typescript-eslint/types" "8.70.1"
|
||||
eslint-visitor-keys "^5.0.0"
|
||||
|
||||
"@typescript/typescript-aix-ppc64@7.0.2":
|
||||
version "7.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz#cdc7ce81d60f1e09034960ddfb1fb880d7a776b6"
|
||||
integrity sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==
|
||||
|
||||
"@typescript/typescript-darwin-arm64@7.0.2":
|
||||
version "7.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz#a55fdfcfa58df58d27db2237cde6a5c1e35a7235"
|
||||
integrity sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==
|
||||
|
||||
"@typescript/typescript-darwin-x64@7.0.2":
|
||||
version "7.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz#38d1c9172800a91d707bec64d2a370a016634db4"
|
||||
integrity sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==
|
||||
|
||||
"@typescript/typescript-freebsd-arm64@7.0.2":
|
||||
version "7.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz#f1ff8810030b35d2b5be0db6a2dc650460ea94fa"
|
||||
integrity sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==
|
||||
|
||||
"@typescript/typescript-freebsd-x64@7.0.2":
|
||||
version "7.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz#3d86b03f353c5b1ba95162eb6ce35533bfc294bd"
|
||||
integrity sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==
|
||||
|
||||
"@typescript/typescript-linux-arm64@7.0.2":
|
||||
version "7.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz#d9334d96d6dac6ff85da9c865588948de939e91f"
|
||||
integrity sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==
|
||||
|
||||
"@typescript/typescript-linux-arm@7.0.2":
|
||||
version "7.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz#ad94b41e1aee2a4dcc6a298c7b67c43345fde32e"
|
||||
integrity sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==
|
||||
|
||||
"@typescript/typescript-linux-loong64@7.0.2":
|
||||
version "7.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz#2965aee4fc873360139d893daafe6397a29138ad"
|
||||
integrity sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==
|
||||
|
||||
"@typescript/typescript-linux-mips64el@7.0.2":
|
||||
version "7.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz#1a887a311bed3a833f80bfd4a9ed37c271936cf0"
|
||||
integrity sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==
|
||||
|
||||
"@typescript/typescript-linux-ppc64@7.0.2":
|
||||
version "7.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz#8b63c9b2f445b393eb4e43ec21da225dade3577d"
|
||||
integrity sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==
|
||||
|
||||
"@typescript/typescript-linux-riscv64@7.0.2":
|
||||
version "7.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz#b6e8a35c289b3ea97a92a41d461aaeed0d3b36e1"
|
||||
integrity sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==
|
||||
|
||||
"@typescript/typescript-linux-s390x@7.0.2":
|
||||
version "7.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz#2ef96693be4861f6d17965427e5b009cbbed1a3e"
|
||||
integrity sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==
|
||||
|
||||
"@typescript/typescript-linux-x64@7.0.2":
|
||||
version "7.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz#73269cb0baba50aea0ca060445a6b88e583f1ce2"
|
||||
integrity sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==
|
||||
|
||||
"@typescript/typescript-netbsd-arm64@7.0.2":
|
||||
version "7.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz#3a3649f97fafa210b4e6e3798c15e06605c8a901"
|
||||
integrity sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==
|
||||
|
||||
"@typescript/typescript-netbsd-x64@7.0.2":
|
||||
version "7.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz#47ec59491a40c470d2807dc4d2b825528fd979ab"
|
||||
integrity sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==
|
||||
|
||||
"@typescript/typescript-openbsd-arm64@7.0.2":
|
||||
version "7.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz#796be8da0bd989d8a3fb96f2801e38a8365b4baf"
|
||||
integrity sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==
|
||||
|
||||
"@typescript/typescript-openbsd-x64@7.0.2":
|
||||
version "7.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz#d37fe2a729eb942c076c454ee7f1815faf7d560f"
|
||||
integrity sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==
|
||||
|
||||
"@typescript/typescript-sunos-x64@7.0.2":
|
||||
version "7.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz#aba8d3464c3565a7044789baba96916bd4ab2c88"
|
||||
integrity sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==
|
||||
|
||||
"@typescript/typescript-win32-arm64@7.0.2":
|
||||
version "7.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz#b9de50a17196383f62620b5f9d0a2f34ad3b60d7"
|
||||
integrity sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==
|
||||
|
||||
"@typescript/typescript-win32-x64@7.0.2":
|
||||
version "7.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz#cf3b7b0d6ce5635daca4c8e01c189cdcde47ec3c"
|
||||
integrity sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==
|
||||
|
||||
acorn-jsx@^5.3.2:
|
||||
version "5.3.2"
|
||||
resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937"
|
||||
@@ -451,13 +588,24 @@ brace-expansion@^1.1.7:
|
||||
balanced-match "^1.0.0"
|
||||
concat-map "0.0.1"
|
||||
|
||||
brace-expansion@^5.0.2:
|
||||
version "5.0.2"
|
||||
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.2.tgz#b6c16d0791087af6c2bc463f52a8142046c06b6f"
|
||||
integrity sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==
|
||||
brace-expansion@^5.0.8:
|
||||
version "5.0.9"
|
||||
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.9.tgz#7c72438809b5fa5babf54199a1f1c281a6984fcf"
|
||||
integrity sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==
|
||||
dependencies:
|
||||
balanced-match "^4.0.2"
|
||||
|
||||
cacheable@^2.5.0:
|
||||
version "2.5.0"
|
||||
resolved "https://registry.yarnpkg.com/cacheable/-/cacheable-2.5.0.tgz#d142d41043e5a865f6053cc70ef4e3ad068bd5ce"
|
||||
integrity sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==
|
||||
dependencies:
|
||||
"@cacheable/memory" "^2.2.0"
|
||||
"@cacheable/utils" "^2.5.0"
|
||||
hookified "^1.15.0"
|
||||
keyv "^5.6.0"
|
||||
qified "^0.10.1"
|
||||
|
||||
call-bind-apply-helpers@^1.0.0, call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6"
|
||||
@@ -775,17 +923,17 @@ eslint-visitor-keys@^5.0.0, eslint-visitor-keys@^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.4.1:
|
||||
version "10.4.1"
|
||||
resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.4.1.tgz#f6640b176e0912246d9ddbf8fcfa5e8b7f02445a"
|
||||
integrity sha512-AyIKhnOBuOAdueD7RB3xB+YeAWScb9jHsJBgH2Hcde8InP5JYhqrRR6iTMHyTEwgENK54Cp44e4v8BwNhsuHuw==
|
||||
eslint@^10.11.0:
|
||||
version "10.11.0"
|
||||
resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.11.0.tgz#304d1591b7c6a327e3f64b4f550f99fda160ae20"
|
||||
integrity sha512-P7a6UEEqb9G95MYAtqkmsTbVXIYyzIfl6NGOIJk162PaahFxFyeGcrlXYFSiagECg4sEm8IseJdZBKR3rx6MsQ==
|
||||
dependencies:
|
||||
"@eslint-community/eslint-utils" "^4.8.0"
|
||||
"@eslint-community/regexpp" "^4.12.2"
|
||||
"@eslint/config-array" "^0.23.5"
|
||||
"@eslint/config-helpers" "^0.6.0"
|
||||
"@eslint/config-helpers" "^0.7.0"
|
||||
"@eslint/core" "^1.2.1"
|
||||
"@eslint/plugin-kit" "^0.7.2"
|
||||
"@eslint/plugin-kit" "^0.7.3"
|
||||
"@humanfs/node" "^0.16.6"
|
||||
"@humanwhocodes/module-importer" "^1.0.1"
|
||||
"@humanwhocodes/retry" "^0.4.2"
|
||||
@@ -800,14 +948,14 @@ eslint@^10.4.1:
|
||||
esquery "^1.7.0"
|
||||
esutils "^2.0.2"
|
||||
fast-deep-equal "^3.1.3"
|
||||
file-entry-cache "^8.0.0"
|
||||
file-entry-cache "11.1.5 || >11.1.6 <12"
|
||||
find-up "^5.0.0"
|
||||
glob-parent "^6.0.2"
|
||||
ignore "^5.2.0"
|
||||
imurmurhash "^0.1.4"
|
||||
is-glob "^4.0.0"
|
||||
json-stable-stringify-without-jsonify "^1.0.1"
|
||||
minimatch "^10.2.4"
|
||||
minimatch "^10.2.5"
|
||||
natural-compare "^1.4.0"
|
||||
optionator "^0.9.3"
|
||||
|
||||
@@ -888,12 +1036,12 @@ fdir@^6.5.0:
|
||||
resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350"
|
||||
integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==
|
||||
|
||||
file-entry-cache@^8.0.0:
|
||||
version "8.0.0"
|
||||
resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz#7787bddcf1131bffb92636c69457bbc0edd6d81f"
|
||||
integrity sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==
|
||||
"file-entry-cache@11.1.5 || >11.1.6 <12":
|
||||
version "11.1.5"
|
||||
resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-11.1.5.tgz#c8210eb055de63e68685ccfb6a017e386d4577d0"
|
||||
integrity sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==
|
||||
dependencies:
|
||||
flat-cache "^4.0.0"
|
||||
flat-cache "^6.1.23"
|
||||
|
||||
find-up@^5.0.0:
|
||||
version "5.0.0"
|
||||
@@ -903,18 +1051,19 @@ find-up@^5.0.0:
|
||||
locate-path "^6.0.0"
|
||||
path-exists "^4.0.0"
|
||||
|
||||
flat-cache@^4.0.0:
|
||||
version "4.0.1"
|
||||
resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-4.0.1.tgz#0ece39fcb14ee012f4b0410bd33dd9c1f011127c"
|
||||
integrity sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==
|
||||
flat-cache@^6.1.23:
|
||||
version "6.1.23"
|
||||
resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-6.1.23.tgz#735dc888c271868d7301b3bbb8edba5028afb61d"
|
||||
integrity sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==
|
||||
dependencies:
|
||||
flatted "^3.2.9"
|
||||
keyv "^4.5.4"
|
||||
cacheable "^2.5.0"
|
||||
flatted "^3.4.2"
|
||||
hookified "^1.15.0"
|
||||
|
||||
flatted@^3.2.9:
|
||||
version "3.4.2"
|
||||
resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.4.2.tgz#f5c23c107f0f37de8dbdf24f13722b3b98d52726"
|
||||
integrity sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==
|
||||
flatted@^3.4.2:
|
||||
version "3.4.4"
|
||||
resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.4.4.tgz#aeeca2a506303f0cee61c59e6c9f2a88d2f29fc6"
|
||||
integrity sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==
|
||||
|
||||
for-each@^0.3.3, for-each@^0.3.5:
|
||||
version "0.3.5"
|
||||
@@ -1034,6 +1183,13 @@ has-tostringtag@^1.0.2:
|
||||
dependencies:
|
||||
has-symbols "^1.0.3"
|
||||
|
||||
hashery@^1.4.0, hashery@^1.5.1:
|
||||
version "1.5.1"
|
||||
resolved "https://registry.yarnpkg.com/hashery/-/hashery-1.5.1.tgz#4ba82ad54911ac617467870845d57a9fe508a400"
|
||||
integrity sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==
|
||||
dependencies:
|
||||
hookified "^1.15.0"
|
||||
|
||||
hasown@^2.0.2:
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003"
|
||||
@@ -1041,6 +1197,16 @@ hasown@^2.0.2:
|
||||
dependencies:
|
||||
function-bind "^1.1.2"
|
||||
|
||||
hookified@^1.15.0, hookified@^1.15.1:
|
||||
version "1.15.1"
|
||||
resolved "https://registry.yarnpkg.com/hookified/-/hookified-1.15.1.tgz#b1fafeaa5489cdc29cb85546a8f837ed4ffbbcb6"
|
||||
integrity sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==
|
||||
|
||||
hookified@^2.1.1:
|
||||
version "2.2.0"
|
||||
resolved "https://registry.yarnpkg.com/hookified/-/hookified-2.2.0.tgz#1d024ac1668973dd5bcc4a96ab9ccdb7639ef8d4"
|
||||
integrity sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==
|
||||
|
||||
ignore@^5.2.0:
|
||||
version "5.3.2"
|
||||
resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5"
|
||||
@@ -1279,18 +1445,13 @@ js-tiktoken@^1.0.12:
|
||||
dependencies:
|
||||
base64-js "^1.5.1"
|
||||
|
||||
js-yaml@^4.1.1:
|
||||
version "4.2.0"
|
||||
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.2.0.tgz#2bd9e85682dd91bd469afb809d816043b3d49524"
|
||||
integrity sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==
|
||||
js-yaml@^4.3.2:
|
||||
version "4.3.2"
|
||||
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.3.2.tgz#8e44fb14a2643c59726bb15787b5f1512cb3d3fb"
|
||||
integrity sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==
|
||||
dependencies:
|
||||
argparse "^2.0.1"
|
||||
|
||||
json-buffer@3.0.1:
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13"
|
||||
integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==
|
||||
|
||||
json-schema-traverse@^0.4.1:
|
||||
version "0.4.1"
|
||||
resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660"
|
||||
@@ -1308,12 +1469,12 @@ json5@^1.0.2:
|
||||
dependencies:
|
||||
minimist "^1.2.0"
|
||||
|
||||
keyv@^4.5.4:
|
||||
version "4.5.4"
|
||||
resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93"
|
||||
integrity sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==
|
||||
keyv@^5.6.0:
|
||||
version "5.6.0"
|
||||
resolved "https://registry.yarnpkg.com/keyv/-/keyv-5.6.0.tgz#03044074c6b4d072d0a62c7b9fa649537baf0105"
|
||||
integrity sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==
|
||||
dependencies:
|
||||
json-buffer "3.0.1"
|
||||
"@keyv/serialize" "^1.1.1"
|
||||
|
||||
"langsmith@>=0.5.0 <1.0.0":
|
||||
version "0.7.1"
|
||||
@@ -1342,12 +1503,12 @@ 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.2.2, minimatch@^10.2.4:
|
||||
version "10.2.4"
|
||||
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.4.tgz#465b3accbd0218b8281f5301e27cedc697f96fde"
|
||||
integrity sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==
|
||||
minimatch@^10.2.2, minimatch@^10.2.4, minimatch@^10.2.5:
|
||||
version "10.2.6"
|
||||
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.6.tgz#fd956bbe0b77241e9f15ac5dccb1c638060968ef"
|
||||
integrity sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==
|
||||
dependencies:
|
||||
brace-expansion "^5.0.2"
|
||||
brace-expansion "^5.0.8"
|
||||
|
||||
minimatch@^3.1.2, minimatch@^3.1.5:
|
||||
version "3.1.5"
|
||||
@@ -1546,16 +1707,23 @@ prettier-linter-helpers@^1.0.1:
|
||||
dependencies:
|
||||
fast-diff "^1.1.2"
|
||||
|
||||
prettier@^3.8.3:
|
||||
version "3.8.3"
|
||||
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.3.tgz#560f2de55bf01b4c0503bc629d5df99b9a1d09b0"
|
||||
integrity sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==
|
||||
prettier@^3.9.9:
|
||||
version "3.9.9"
|
||||
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.9.9.tgz#09b826918c91cd4cbc80e0cbd1d2a922ff04f233"
|
||||
integrity sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==
|
||||
|
||||
punycode@^2.1.0:
|
||||
version "2.3.1"
|
||||
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
|
||||
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
|
||||
|
||||
qified@^0.10.1:
|
||||
version "0.10.1"
|
||||
resolved "https://registry.yarnpkg.com/qified/-/qified-0.10.1.tgz#0640bf21bbe6ca540db290ad8f5fde4d870b8bda"
|
||||
integrity sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==
|
||||
dependencies:
|
||||
hookified "^2.1.1"
|
||||
|
||||
reflect.getprototypeof@^1.0.6, reflect.getprototypeof@^1.0.9:
|
||||
version "1.0.10"
|
||||
resolved "https://registry.yarnpkg.com/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz#c629219e78a3316d8b604c765ef68996964e7bf9"
|
||||
@@ -1802,17 +1970,17 @@ tsconfig-paths@^3.15.0:
|
||||
minimist "^1.2.6"
|
||||
strip-bom "^3.0.0"
|
||||
|
||||
turbo@^2.9.16:
|
||||
version "2.9.16"
|
||||
resolved "https://registry.yarnpkg.com/turbo/-/turbo-2.9.16.tgz#ed72ff679c2c54bde0e3a2e40107416181e203a4"
|
||||
integrity sha512-NqgRQy6j6dPYcdSdv0q1g9QsZg7SWg87RERM8otw/1AtKU2yTFVClOM7cbwKzOonZr/Ek1blTBucw64L9H0Bwg==
|
||||
turbo@^2.11.5:
|
||||
version "2.11.5"
|
||||
resolved "https://registry.yarnpkg.com/turbo/-/turbo-2.11.5.tgz#e7223abd0555b978d3efe1b00ea9a98f43afb7ab"
|
||||
integrity sha512-qLXheqz3TUvJpHIV6MUBwkNkvwKxMVG/QsimyiKT9DfNWPHJ294G/i5nDaH+fbHJt3UH7orubNNxAqYkbUNGAQ==
|
||||
optionalDependencies:
|
||||
"@turbo/darwin-64" "2.9.16"
|
||||
"@turbo/darwin-arm64" "2.9.16"
|
||||
"@turbo/linux-64" "2.9.16"
|
||||
"@turbo/linux-arm64" "2.9.16"
|
||||
"@turbo/windows-64" "2.9.16"
|
||||
"@turbo/windows-arm64" "2.9.16"
|
||||
"@turbo/darwin-64" "2.11.5"
|
||||
"@turbo/darwin-arm64" "2.11.5"
|
||||
"@turbo/linux-64" "2.11.5"
|
||||
"@turbo/linux-arm64" "2.11.5"
|
||||
"@turbo/windows-64" "2.11.5"
|
||||
"@turbo/windows-arm64" "2.11.5"
|
||||
|
||||
type-check@^0.4.0, type-check@~0.4.0:
|
||||
version "0.4.0"
|
||||
@@ -1866,10 +2034,31 @@ typed-array-length@^1.0.7:
|
||||
possible-typed-array-names "^1.0.0"
|
||||
reflect.getprototypeof "^1.0.6"
|
||||
|
||||
typescript@^6.0.3:
|
||||
version "6.0.3"
|
||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-6.0.3.tgz#90251dc007916e972786cb94d74d15b185577d21"
|
||||
integrity sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==
|
||||
typescript@^7.0.2:
|
||||
version "7.0.2"
|
||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-7.0.2.tgz#9ec773d7954a8c182c17cc5bbd575aa28bc51582"
|
||||
integrity sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==
|
||||
optionalDependencies:
|
||||
"@typescript/typescript-aix-ppc64" "7.0.2"
|
||||
"@typescript/typescript-darwin-arm64" "7.0.2"
|
||||
"@typescript/typescript-darwin-x64" "7.0.2"
|
||||
"@typescript/typescript-freebsd-arm64" "7.0.2"
|
||||
"@typescript/typescript-freebsd-x64" "7.0.2"
|
||||
"@typescript/typescript-linux-arm" "7.0.2"
|
||||
"@typescript/typescript-linux-arm64" "7.0.2"
|
||||
"@typescript/typescript-linux-loong64" "7.0.2"
|
||||
"@typescript/typescript-linux-mips64el" "7.0.2"
|
||||
"@typescript/typescript-linux-ppc64" "7.0.2"
|
||||
"@typescript/typescript-linux-riscv64" "7.0.2"
|
||||
"@typescript/typescript-linux-s390x" "7.0.2"
|
||||
"@typescript/typescript-linux-x64" "7.0.2"
|
||||
"@typescript/typescript-netbsd-arm64" "7.0.2"
|
||||
"@typescript/typescript-netbsd-x64" "7.0.2"
|
||||
"@typescript/typescript-openbsd-arm64" "7.0.2"
|
||||
"@typescript/typescript-openbsd-x64" "7.0.2"
|
||||
"@typescript/typescript-sunos-x64" "7.0.2"
|
||||
"@typescript/typescript-win32-arm64" "7.0.2"
|
||||
"@typescript/typescript-win32-x64" "7.0.2"
|
||||
|
||||
unbox-primitive@^1.1.0:
|
||||
version "1.1.0"
|
||||
@@ -1888,11 +2077,6 @@ uri-js@^4.2.2:
|
||||
dependencies:
|
||||
punycode "^2.1.0"
|
||||
|
||||
uuid@^14.0.0:
|
||||
version "14.0.0"
|
||||
resolved "https://registry.yarnpkg.com/uuid/-/uuid-14.0.0.tgz#0af883220163d264ffe0c084f6b8a89b9666966d"
|
||||
integrity sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==
|
||||
|
||||
which-boxed-primitive@^1.1.0, which-boxed-primitive@^1.1.1:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz#d76ec27df7fa165f18d5808374a5fe23c29b176e"
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.4.30"
|
||||
__version__ = "0.4.36"
|
||||
|
||||
@@ -6,6 +6,7 @@ import re
|
||||
import shlex
|
||||
import textwrap
|
||||
from collections import Counter
|
||||
from collections.abc import Iterable
|
||||
from typing import Literal, NamedTuple
|
||||
|
||||
import click
|
||||
@@ -36,6 +37,10 @@ DISALLOWED_BUILD_COMMAND_CHARS = [
|
||||
# This blocks background execution (cmd &) while allowing command
|
||||
# chaining (cmd1 && cmd2) which is common in build commands.
|
||||
_SINGLE_AMPERSAND_RE = re.compile(r"(?<!&)&(?:&&)*(?!&)")
|
||||
_GIT_HTTP_AUTHORITY_RES = (
|
||||
re.compile(r"git\+https?://(?P<authority>[^/\s\"']+)", re.I),
|
||||
re.compile(r"\bgit\s*=\s*[\"']https?://(?P<authority>[^/\s\"']+)", re.I),
|
||||
)
|
||||
_API_VERSION_PATTERN = re.compile(
|
||||
r"^(?P<major>\d+)"
|
||||
r"(?:\.(?P<minor>\d+))?"
|
||||
@@ -78,6 +83,62 @@ def has_disallowed_build_command_content(command: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _has_git_http_url_userinfo(dependency: str) -> bool:
|
||||
"""Check whether a Git HTTP URL contains userinfo."""
|
||||
return any(
|
||||
"@" in match.group("authority")
|
||||
for pattern in _GIT_HTTP_AUTHORITY_RES
|
||||
for match in pattern.finditer(dependency)
|
||||
)
|
||||
|
||||
|
||||
def _validate_git_http_url_userinfo(
|
||||
values: Iterable[str], *, source: pathlib.Path | None = None
|
||||
) -> None:
|
||||
"""Reject credential-bearing Git HTTP URLs without echoing their values."""
|
||||
if not any(_has_git_http_url_userinfo(value) for value in values):
|
||||
return
|
||||
message = (
|
||||
"Git dependency URLs must not contain credentials or other URL "
|
||||
"userinfo because generated Dockerfiles and image layers can retain "
|
||||
"them. Use a credential-free Git URL and provide short-lived "
|
||||
"credentials through your build environment's secret-backed Git "
|
||||
"credential helper."
|
||||
)
|
||||
if source is not None:
|
||||
message += f" Found in: {source}"
|
||||
raise click.UsageError(message)
|
||||
|
||||
|
||||
def _validate_git_http_url_userinfo_files(paths: Iterable[pathlib.Path]) -> None:
|
||||
"""Reject credential-bearing Git HTTP URLs in dependency files."""
|
||||
for path in paths:
|
||||
path = path.resolve()
|
||||
if not path.is_file():
|
||||
continue
|
||||
try:
|
||||
contents = path.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
raise click.UsageError(
|
||||
f"Could not inspect dependency file for embedded credentials: {path}"
|
||||
) from None
|
||||
_validate_git_http_url_userinfo([contents], source=path)
|
||||
|
||||
|
||||
def _validate_local_dependency_files(config_path: pathlib.Path, config: Config) -> None:
|
||||
"""Validate dependency files copied into a non-uv Python image."""
|
||||
paths: list[pathlib.Path] = []
|
||||
for dependency in config["dependencies"]:
|
||||
if not isinstance(dependency, str) or not dependency.startswith("."):
|
||||
continue
|
||||
root = (config_path.parent / dependency).resolve()
|
||||
paths.extend(
|
||||
root / name
|
||||
for name in ("requirements.txt", "pyproject.toml", "setup.py", "setup.cfg")
|
||||
)
|
||||
_validate_git_http_url_userinfo_files(paths)
|
||||
|
||||
|
||||
MIN_PYTHON_VERSION = "3.11"
|
||||
DEFAULT_PYTHON_VERSION = "3.11"
|
||||
|
||||
@@ -320,7 +381,9 @@ def _get_source_kind(config: Config) -> str | None:
|
||||
return kind if isinstance(kind, str) else None
|
||||
|
||||
|
||||
def validate_config(config: Config) -> Config:
|
||||
def validate_config(
|
||||
config: Config, *, source_path: pathlib.Path | None = None
|
||||
) -> Config:
|
||||
"""Validate a configuration dictionary."""
|
||||
|
||||
graphs = config.get("graphs", {})
|
||||
@@ -415,6 +478,15 @@ def validate_config(config: Config) -> Config:
|
||||
' "source": {"kind": "uv", "root": ".."}'
|
||||
)
|
||||
|
||||
_validate_git_http_url_userinfo(
|
||||
(
|
||||
dependency
|
||||
for dependency in config["dependencies"]
|
||||
if isinstance(dependency, str)
|
||||
),
|
||||
source=source_path,
|
||||
)
|
||||
|
||||
source = config.get("source")
|
||||
source_kind = _get_source_kind(config)
|
||||
if source is not None and not isinstance(source, dict):
|
||||
@@ -609,7 +681,7 @@ def validate_config_file(config_path: pathlib.Path) -> Config:
|
||||
"""Load and validate a configuration file."""
|
||||
with open(config_path) as f:
|
||||
config = json.load(f)
|
||||
validated = validate_config(config)
|
||||
validated = validate_config(config, source_path=config_path.resolve())
|
||||
# Enforce the package.json doesn't enforce an
|
||||
# incompatible Node.js version
|
||||
if validated.get("node_version"):
|
||||
@@ -1280,6 +1352,7 @@ def python_config_to_docker(
|
||||
api_version=api_version,
|
||||
build_tools_to_uninstall=build_tools_to_uninstall,
|
||||
)
|
||||
_validate_local_dependency_files(config_path, config)
|
||||
if pip_installer == "auto":
|
||||
if _image_supports_uv(base_image):
|
||||
pip_installer = "uv"
|
||||
@@ -1490,7 +1563,18 @@ def node_config_to_docker(
|
||||
) -> tuple[str, dict[str, str]]:
|
||||
# Calculate paths for monorepo support
|
||||
install_root = (
|
||||
pathlib.Path(build_context).resolve() if build_context else config_path.parent
|
||||
pathlib.Path(build_context).resolve()
|
||||
if build_context
|
||||
else config_path.parent.resolve()
|
||||
)
|
||||
config_root = config_path.parent.resolve()
|
||||
dependency_roots = (
|
||||
(install_root, config_root) if install_root != config_root else (install_root,)
|
||||
)
|
||||
_validate_git_http_url_userinfo_files(
|
||||
root / name
|
||||
for root in dependency_roots
|
||||
for name in ("package.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml")
|
||||
)
|
||||
install_cmd = install_command or _get_node_pm_install_cmd(install_root)
|
||||
if build_context:
|
||||
|
||||
+1216
-352
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,18 @@
|
||||
import asyncio
|
||||
import signal
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Coroutine
|
||||
from contextlib import contextmanager
|
||||
from typing import cast
|
||||
from typing import Any, Protocol, TypeVar, cast
|
||||
|
||||
import click.exceptions
|
||||
|
||||
_T = TypeVar("_T")
|
||||
|
||||
|
||||
class CommandRunner(Protocol):
|
||||
def run(self, coro: Coroutine[Any, Any, _T]) -> _T: ...
|
||||
|
||||
|
||||
@contextmanager
|
||||
def Runner():
|
||||
|
||||
@@ -2,18 +2,125 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import click
|
||||
import httpx
|
||||
|
||||
CLOUD_CONTROL_PLANE_URL = "https://api.host.langchain.com"
|
||||
CLOUD_DASHBOARD_URL = "https://smith.langchain.com"
|
||||
CLOUD_DOMAIN = "langchain.com"
|
||||
CLOUD_API_HOST = "api.smith.langchain.com"
|
||||
CLOUD_CONTROL_PLANE_HOST = "api.host.langchain.com"
|
||||
CLOUD_DASHBOARD_HOST = "smith.langchain.com"
|
||||
CONTROL_PLANE_PATH = "/api-host"
|
||||
LANGSMITH_API_PATHS = ("/api/v1", "/api")
|
||||
LOCAL_HOSTNAMES = ("localhost", "127.0.0.1")
|
||||
MAX_PAGE_SIZE = 100
|
||||
SourceName = Literal["internal_docker", "internal_source", "external_docker"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ControlPlaneEndpoints:
|
||||
control_plane_url: str
|
||||
dashboard_url: str
|
||||
|
||||
@classmethod
|
||||
def resolve(
|
||||
cls, host_url: str | None, langsmith_endpoint: str | None
|
||||
) -> ControlPlaneEndpoints:
|
||||
if host_url:
|
||||
return cls.from_control_plane_url(host_url)
|
||||
if langsmith_endpoint:
|
||||
return cls.from_langsmith_endpoint(langsmith_endpoint)
|
||||
return cls(CLOUD_CONTROL_PLANE_URL, CLOUD_DASHBOARD_URL)
|
||||
|
||||
@property
|
||||
def is_cloud(self) -> bool:
|
||||
hostname = urlparse(self.control_plane_url).hostname or ""
|
||||
return hostname == CLOUD_CONTROL_PLANE_HOST or hostname.endswith(
|
||||
f".{CLOUD_CONTROL_PLANE_HOST}"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_control_plane_url(cls, url: str) -> ControlPlaneEndpoints:
|
||||
control_plane_url = url.rstrip("/")
|
||||
hostname = urlparse(control_plane_url).hostname or ""
|
||||
if control_plane_url.endswith(CONTROL_PLANE_PATH):
|
||||
return cls(control_plane_url, control_plane_url[: -len(CONTROL_PLANE_PATH)])
|
||||
if hostname in LOCAL_HOSTNAMES:
|
||||
return cls(control_plane_url, control_plane_url)
|
||||
return cls(control_plane_url, _cloud_dashboard_for(hostname))
|
||||
|
||||
@classmethod
|
||||
def from_langsmith_endpoint(cls, endpoint: str) -> ControlPlaneEndpoints:
|
||||
parsed = urlparse(endpoint.rstrip("/"))
|
||||
hostname = parsed.hostname or ""
|
||||
if _is_cloud_host(hostname):
|
||||
return cls.from_control_plane_url(
|
||||
f"https://{_cloud_control_plane_host_for(hostname)}"
|
||||
)
|
||||
root = f"{parsed.scheme}://{parsed.netloc}{_without_api_path(parsed.path)}"
|
||||
return cls(f"{root}{CONTROL_PLANE_PATH}", root)
|
||||
|
||||
|
||||
def _is_cloud_host(hostname: str) -> bool:
|
||||
return hostname == CLOUD_DOMAIN or hostname.endswith(f".{CLOUD_DOMAIN}")
|
||||
|
||||
|
||||
def _cloud_control_plane_host_for(langsmith_api_host: str) -> str:
|
||||
if langsmith_api_host.endswith(f".{CLOUD_API_HOST}"):
|
||||
region = langsmith_api_host[: -len(CLOUD_API_HOST)]
|
||||
return f"{region}{CLOUD_CONTROL_PLANE_HOST}"
|
||||
return CLOUD_CONTROL_PLANE_HOST
|
||||
|
||||
|
||||
def _cloud_dashboard_for(control_plane_host: str) -> str:
|
||||
if control_plane_host.endswith(f".{CLOUD_CONTROL_PLANE_HOST}"):
|
||||
region = control_plane_host[: -len(CLOUD_CONTROL_PLANE_HOST) - 1]
|
||||
return f"https://{region}.{CLOUD_DASHBOARD_HOST}"
|
||||
return CLOUD_DASHBOARD_URL
|
||||
|
||||
|
||||
def _without_api_path(path: str) -> str:
|
||||
for api_path in LANGSMITH_API_PATHS:
|
||||
if path.endswith(api_path):
|
||||
return path[: -len(api_path)]
|
||||
return path
|
||||
|
||||
|
||||
def _resources(payload: object) -> list[dict[str, Any]]:
|
||||
if not isinstance(payload, dict):
|
||||
return []
|
||||
resources = payload.get("resources")
|
||||
if not isinstance(resources, list):
|
||||
return []
|
||||
return [item for item in resources if isinstance(item, dict)]
|
||||
|
||||
|
||||
class HostBackendError(click.ClickException):
|
||||
"""Raised when the host backend returns an error response."""
|
||||
|
||||
def __init__(self, message: str, status_code: int | None = None):
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
status_code: int | None = None,
|
||||
detail: str | None = None,
|
||||
):
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
self.detail = detail
|
||||
|
||||
|
||||
def _error_detail(response: httpx.Response) -> str | None:
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError:
|
||||
return None
|
||||
detail = body.get("detail") if isinstance(body, dict) else None
|
||||
return detail if isinstance(detail, str) else None
|
||||
|
||||
|
||||
class HostBackendClient:
|
||||
@@ -24,24 +131,37 @@ class HostBackendClient:
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
tenant_id: str | None = None,
|
||||
*,
|
||||
transport: httpx.BaseTransport | None = None,
|
||||
):
|
||||
if not base_url:
|
||||
raise click.UsageError("Host backend URL is required")
|
||||
transport = httpx.HTTPTransport(retries=3)
|
||||
headers: dict[str, str] = {
|
||||
"X-Api-Key": api_key,
|
||||
"Accept": "application/json",
|
||||
}
|
||||
if tenant_id:
|
||||
headers["X-Tenant-ID"] = tenant_id
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._endpoints = ControlPlaneEndpoints.from_control_plane_url(base_url)
|
||||
self._base_url = self._endpoints.control_plane_url
|
||||
self._client = httpx.Client(
|
||||
base_url=self._base_url,
|
||||
headers=headers,
|
||||
transport=transport,
|
||||
transport=transport or httpx.HTTPTransport(retries=3),
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
@property
|
||||
def base_url(self) -> str:
|
||||
return self._base_url
|
||||
|
||||
@property
|
||||
def endpoints(self) -> ControlPlaneEndpoints:
|
||||
return self._endpoints
|
||||
|
||||
def set_tenant(self, tenant_id: str) -> None:
|
||||
self._client.headers["X-Tenant-ID"] = tenant_id
|
||||
|
||||
def _request(
|
||||
self,
|
||||
method: str,
|
||||
@@ -53,10 +173,12 @@ class HostBackendClient:
|
||||
resp = self._client.request(method, path, json=payload, params=params)
|
||||
resp.raise_for_status()
|
||||
except httpx.HTTPStatusError as err:
|
||||
detail = err.response.text or str(err.response.status_code)
|
||||
detail = _error_detail(err.response)
|
||||
reason = detail or err.response.text or str(err.response.status_code)
|
||||
raise HostBackendError(
|
||||
f"{method} {path} failed with status {err.response.status_code}: {detail}",
|
||||
f"{method} {path} failed with status {err.response.status_code}: {reason}",
|
||||
status_code=err.response.status_code,
|
||||
detail=detail,
|
||||
) from None
|
||||
except httpx.TransportError as err:
|
||||
raise HostBackendError(str(err)) from None
|
||||
@@ -72,30 +194,52 @@ class HostBackendClient:
|
||||
|
||||
def create_deployment(
|
||||
self,
|
||||
name: str,
|
||||
deployment_type: str,
|
||||
source: str,
|
||||
config_path: str | None = None,
|
||||
*,
|
||||
name: str | None,
|
||||
source: SourceName,
|
||||
source_config: dict[str, object],
|
||||
source_revision_config: dict[str, object],
|
||||
secrets: list[dict[str, str]] | None = None,
|
||||
agent: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a deployment."""
|
||||
payload: dict[str, Any] = {
|
||||
"name": name,
|
||||
"source": source,
|
||||
"source_config": {"deployment_type": deployment_type},
|
||||
"source_revision_config": {},
|
||||
"source_config": source_config,
|
||||
"source_revision_config": source_revision_config,
|
||||
}
|
||||
if source == "internal_source" and config_path:
|
||||
payload["source_revision_config"]["langgraph_config_path"] = config_path
|
||||
if agent is not None:
|
||||
payload["agent"] = agent
|
||||
else:
|
||||
payload["name"] = name
|
||||
if secrets is not None:
|
||||
payload["secrets"] = secrets
|
||||
return self._request("POST", "/v2/deployments", payload)
|
||||
|
||||
def list_deployments(self, name_contains: str = "") -> dict[str, Any]:
|
||||
return self._request(
|
||||
"GET",
|
||||
"/v2/deployments",
|
||||
params={"name_contains": name_contains},
|
||||
def list_deployments(
|
||||
self,
|
||||
*,
|
||||
name: str | None = None,
|
||||
name_contains: str | None = None,
|
||||
limit: int | None = None,
|
||||
agent_id: str | None = None,
|
||||
agent_environment: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
given = (
|
||||
("name", name),
|
||||
("name_contains", name_contains),
|
||||
("limit", limit),
|
||||
("agent_id", agent_id),
|
||||
("agent_environment", agent_environment),
|
||||
)
|
||||
params = {key: value for key, value in given if value is not None}
|
||||
return _resources(self._request("GET", "/v2/deployments", params=params))
|
||||
|
||||
def get_listener(self, listener_id: str) -> dict[str, Any]:
|
||||
return self._request("GET", f"/v2/listeners/{listener_id}")
|
||||
|
||||
def list_listeners(self) -> list[dict[str, Any]]:
|
||||
return _resources(
|
||||
self._request("GET", "/v2/listeners", params={"limit": MAX_PAGE_SIZE})
|
||||
)
|
||||
|
||||
def get_deployment(self, deployment_id: str) -> dict[str, Any]:
|
||||
@@ -105,9 +249,12 @@ class HostBackendClient:
|
||||
return self._request("DELETE", f"/v2/deployments/{deployment_id}")
|
||||
|
||||
def request_push_token(self, deployment_id: str) -> dict[str, Any]:
|
||||
# Tells the control plane this client logs in with the returned
|
||||
# `username` and pushes to `repository_url`. LangSmith AWS requires it.
|
||||
return self._request(
|
||||
"POST",
|
||||
f"/v2/deployments/{deployment_id}/push-token",
|
||||
{"supports_registry_username": True},
|
||||
)
|
||||
|
||||
def request_upload_url(self, deployment_id: str) -> dict[str, Any]:
|
||||
@@ -121,22 +268,21 @@ class HostBackendClient:
|
||||
self,
|
||||
deployment_id: str,
|
||||
image_uri: str,
|
||||
*,
|
||||
revision_source: SourceName | None,
|
||||
secrets: list[dict[str, str]] | None = None,
|
||||
tracked_packages: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {
|
||||
"revision_source": "internal_docker",
|
||||
"source_revision_config": {"image_uri": image_uri},
|
||||
}
|
||||
if revision_source is not None:
|
||||
payload["revision_source"] = revision_source
|
||||
if tracked_packages:
|
||||
payload["tracked_packages"] = tracked_packages
|
||||
if secrets is not None:
|
||||
payload["secrets"] = secrets
|
||||
return self._request(
|
||||
"PATCH",
|
||||
f"/v2/deployments/{deployment_id}",
|
||||
payload,
|
||||
)
|
||||
return self._request("PATCH", f"/v2/deployments/{deployment_id}", payload)
|
||||
|
||||
def update_deployment_internal_source(
|
||||
self,
|
||||
@@ -171,10 +317,15 @@ class HostBackendClient:
|
||||
payload["secrets"] = secrets
|
||||
return self._request("PATCH", f"/v2/deployments/{deployment_id}", payload)
|
||||
|
||||
def list_revisions(self, deployment_id: str, limit: int = 1) -> dict[str, Any]:
|
||||
return self._request(
|
||||
"GET",
|
||||
f"/v2/deployments/{deployment_id}/revisions?limit={limit}",
|
||||
def list_revisions(
|
||||
self, deployment_id: str, limit: int = 1
|
||||
) -> list[dict[str, Any]]:
|
||||
return _resources(
|
||||
self._request(
|
||||
"GET",
|
||||
f"/v2/deployments/{deployment_id}/revisions",
|
||||
params={"limit": limit},
|
||||
)
|
||||
)
|
||||
|
||||
def get_revision(self, deployment_id: str, revision_id: str) -> dict[str, Any]:
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
|
||||
DIGEST_SEPARATOR = "@sha256:"
|
||||
DIGEST_MARKER = "@"
|
||||
TAG_SEPARATOR = ":"
|
||||
PATH_SEPARATOR = "/"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ImageReference:
|
||||
repository: str
|
||||
tag: str | None = None
|
||||
|
||||
@classmethod
|
||||
def parse(cls, reference: str) -> ImageReference:
|
||||
if DIGEST_MARKER in reference:
|
||||
raise ValueError(f"{reference!r} carries a digest and cannot be tagged")
|
||||
path_start = reference.rfind(PATH_SEPARATOR) + 1
|
||||
name, separator, tag = reference[path_start:].partition(TAG_SEPARATOR)
|
||||
if not separator:
|
||||
return cls(reference)
|
||||
return cls(reference[:path_start] + name, tag)
|
||||
|
||||
def with_tag(self, tag: str) -> ImageReference:
|
||||
return replace(self, tag=tag)
|
||||
|
||||
def matches_digest(self, repo_digest: str) -> bool:
|
||||
return repo_digest.startswith(f"{self.repository}{DIGEST_SEPARATOR}")
|
||||
|
||||
def __str__(self) -> str:
|
||||
if self.tag is None:
|
||||
return self.repository
|
||||
return f"{self.repository}{TAG_SEPARATOR}{self.tag}"
|
||||
@@ -650,7 +650,8 @@ class Config(TypedDict, total=False):
|
||||
|
||||
pip_config_file: str | None
|
||||
"""Optional. Path to a pip config file (e.g., "/etc/pip.conf" or "pip.ini") for controlling
|
||||
package installation (custom indices, credentials, etc.).
|
||||
package installation (custom indices, timeouts, etc.). The file is copied into the
|
||||
generated image, so it must not contain credentials or other secrets.
|
||||
|
||||
Only relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.
|
||||
"""
|
||||
@@ -689,6 +690,9 @@ class Config(TypedDict, total=False):
|
||||
- "." or "./src" if you have a local Python package
|
||||
- str (aka "anthropic") for a PyPI package
|
||||
- "git+https://github.com/org/repo.git@main" for a Git-based package
|
||||
Git HTTP URLs must not contain userinfo such as a username or token. For private
|
||||
dependencies, provide short-lived credentials through the build environment's
|
||||
secret-backed Git credential helper.
|
||||
Defaults to an empty list, meaning no additional packages installed beyond your base environment.
|
||||
|
||||
This field is not supported when `source.kind` is `uv`.
|
||||
|
||||
@@ -880,6 +880,7 @@ def python_config_to_docker_uv_lock(
|
||||
_get_node_pm_install_cmd,
|
||||
_get_pip_cleanup_lines,
|
||||
_image_supports_uv,
|
||||
_validate_git_http_url_userinfo_files,
|
||||
docker_tag,
|
||||
)
|
||||
|
||||
@@ -890,11 +891,20 @@ def python_config_to_docker_uv_lock(
|
||||
)
|
||||
|
||||
config_root = config_path.parent.resolve()
|
||||
source_root = config["source"].get("root", ".")
|
||||
project_root = (config_root / source_root).resolve()
|
||||
_validate_git_http_url_userinfo_files(
|
||||
[project_root / "pyproject.toml", project_root / "uv.lock"]
|
||||
)
|
||||
|
||||
install_cmd = "uv pip install --system"
|
||||
_, global_reqs_pip_install, pip_config_file_str = _build_python_install_commands(
|
||||
config, install_cmd
|
||||
)
|
||||
plan = _plan_uv_lock_workspace(config_path, config)
|
||||
_validate_git_http_url_userinfo_files(
|
||||
package.pyproject_path for package in plan.install_order
|
||||
)
|
||||
|
||||
_update_uv_lock_graph_paths(config_path, config, plan)
|
||||
for section, key in [
|
||||
@@ -970,6 +980,22 @@ def python_config_to_docker_uv_lock(
|
||||
f"{uv_export_project_dir}/uv.lock",
|
||||
)
|
||||
)
|
||||
for package_root in sorted(
|
||||
plan.all_workspace_roots,
|
||||
key=lambda root: root.as_posix(),
|
||||
):
|
||||
if package_root == plan.project_root:
|
||||
continue
|
||||
package_relative_path = pathlib.PurePosixPath(
|
||||
package_root.relative_to(plan.project_root).as_posix()
|
||||
)
|
||||
package_pyproject_path = package_relative_path / "pyproject.toml"
|
||||
docker_plan.add_raw(
|
||||
copy_from_project_root(
|
||||
package_pyproject_path,
|
||||
f"{uv_export_project_dir}/{package_pyproject_path.as_posix()}",
|
||||
)
|
||||
)
|
||||
docker_plan.add_instruction("WORKDIR", uv_export_project_dir)
|
||||
docker_plan.add_instruction(
|
||||
"RUN",
|
||||
|
||||
@@ -23,7 +23,7 @@ dependencies = [
|
||||
path = "langgraph_cli/__init__.py"
|
||||
[project.optional-dependencies]
|
||||
inmem = [
|
||||
"langgraph-api>=0.5.35,<0.12.0 ; python_version >= '3.11'",
|
||||
"langgraph-api>=0.5.35,<1.0.0 ; python_version >= '3.11'",
|
||||
"langgraph-runtime-inmem>=0.7 ; python_version >= '3.11'",
|
||||
]
|
||||
|
||||
@@ -72,9 +72,15 @@ lint.select = [
|
||||
"UP", # pyupgrade
|
||||
"B", # flake8-bugbear
|
||||
"I", # isort
|
||||
"PLC0415", # import-outside-top-level
|
||||
"RUF100", # unused noqa directive
|
||||
"UP", # pyupgrade
|
||||
]
|
||||
lint.ignore = ["E501", "B008"]
|
||||
# PLC0415 (import-outside-top-level) is enforced in tests only. Library code
|
||||
# still has deferred imports that have not been reviewed, so it stays exempt
|
||||
# for now.
|
||||
lint.per-file-ignores = { "langgraph_cli/**" = ["PLC0415"], "generate_schema.py" = ["PLC0415"] }
|
||||
target-version = "py310"
|
||||
|
||||
[tool.ty.rules]
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional. Path to a pip config file (e.g., \"/etc/pip.conf\" or \"pip.ini\") for controlling\npackage installation (custom indices, credentials, etc.).\n\nOnly relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.\n"
|
||||
"description": "Optional. Path to a pip config file (e.g., \"/etc/pip.conf\" or \"pip.ini\") for controlling\npackage installation (custom indices, timeouts, etc.). The file is copied into the\ngenerated image, so it must not contain credentials or other secrets.\n\nOnly relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.\n"
|
||||
},
|
||||
"_INTERNAL_docker_tag": {
|
||||
"anyOf": [
|
||||
@@ -270,7 +270,7 @@
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional. Path to a pip config file (e.g., \"/etc/pip.conf\" or \"pip.ini\") for controlling\npackage installation (custom indices, credentials, etc.).\n\nOnly relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.\n"
|
||||
"description": "Optional. Path to a pip config file (e.g., \"/etc/pip.conf\" or \"pip.ini\") for controlling\npackage installation (custom indices, timeouts, etc.). The file is copied into the\ngenerated image, so it must not contain credentials or other secrets.\n\nOnly relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.\n"
|
||||
},
|
||||
"_INTERNAL_docker_tag": {
|
||||
"anyOf": [
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional. Path to a pip config file (e.g., \"/etc/pip.conf\" or \"pip.ini\") for controlling\npackage installation (custom indices, credentials, etc.).\n\nOnly relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.\n"
|
||||
"description": "Optional. Path to a pip config file (e.g., \"/etc/pip.conf\" or \"pip.ini\") for controlling\npackage installation (custom indices, timeouts, etc.). The file is copied into the\ngenerated image, so it must not contain credentials or other secrets.\n\nOnly relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.\n"
|
||||
},
|
||||
"_INTERNAL_docker_tag": {
|
||||
"anyOf": [
|
||||
@@ -270,7 +270,7 @@
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional. Path to a pip config file (e.g., \"/etc/pip.conf\" or \"pip.ini\") for controlling\npackage installation (custom indices, credentials, etc.).\n\nOnly relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.\n"
|
||||
"description": "Optional. Path to a pip config file (e.g., \"/etc/pip.conf\" or \"pip.ini\") for controlling\npackage installation (custom indices, timeouts, etc.). The file is copied into the\ngenerated image, so it must not contain credentials or other secrets.\n\nOnly relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.\n"
|
||||
},
|
||||
"_INTERNAL_docker_tag": {
|
||||
"anyOf": [
|
||||
|
||||
@@ -14,6 +14,7 @@ import langgraph_cli.deploy as deploy_module
|
||||
from langgraph_cli.cli import cli, prepare_args_and_stdin
|
||||
from langgraph_cli.config import Config, _get_pip_cleanup_lines, validate_config
|
||||
from langgraph_cli.docker import DEFAULT_POSTGRES_URI, DockerCapabilities, Version
|
||||
from langgraph_cli.host_backend import HostBackendError
|
||||
from langgraph_cli.util import clean_empty_lines
|
||||
|
||||
FORMATTED_CLEANUP_LINES = _get_pip_cleanup_lines(
|
||||
@@ -320,6 +321,31 @@ def test_top_level_help_truncates_command_descriptions_to_single_line() -> None:
|
||||
assert "[Beta] List LangSmith Deployments." in deploy_list_line
|
||||
|
||||
|
||||
def test_deploy_missing_config_shows_actionable_error(tmp_path, monkeypatch) -> None:
|
||||
runner = CliRunner()
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
result = runner.invoke(cli, ["deploy"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "We couldn't find a langgraph.json file." in result.output
|
||||
assert "Run `langgraph deploy` from the root" in result.output
|
||||
assert "https://docs.langchain.com/langsmith/deployment-quickstart" in result.output
|
||||
assert "Traceback" not in result.output
|
||||
|
||||
|
||||
def test_deploy_missing_config_emits_json_error(tmp_path, monkeypatch) -> None:
|
||||
runner = CliRunner()
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
result = runner.invoke(cli, ["deploy", "--json"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
events = [json.loads(line) for line in result.output.splitlines()]
|
||||
assert events[-1]["event"] == "error"
|
||||
assert "We couldn't find a langgraph.json file." in events[-1]["message"]
|
||||
|
||||
|
||||
def test_dev_command_requires_ssl_certfile_and_keyfile_together(tmp_path) -> None:
|
||||
config_path = tmp_path / "langgraph.json"
|
||||
config_path.write_text(
|
||||
@@ -357,20 +383,18 @@ def test_deploy_list_command(monkeypatch) -> None:
|
||||
|
||||
def list_deployments(self, name_contains: str = ""):
|
||||
captured["name_contains"] = name_contains
|
||||
return {
|
||||
"resources": [
|
||||
{
|
||||
"id": "dep-123",
|
||||
"name": "alpha",
|
||||
"source_config": {"custom_url": "https://alpha.example.com"},
|
||||
},
|
||||
{
|
||||
"id": "dep-456",
|
||||
"name": "beta",
|
||||
"source_config": {"custom_url": "https://beta.example.com"},
|
||||
},
|
||||
]
|
||||
}
|
||||
return [
|
||||
{
|
||||
"id": "dep-123",
|
||||
"name": "alpha",
|
||||
"source_config": {"custom_url": "https://alpha.example.com"},
|
||||
},
|
||||
{
|
||||
"id": "dep-456",
|
||||
"name": "beta",
|
||||
"source_config": {"custom_url": "https://beta.example.com"},
|
||||
},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
|
||||
|
||||
@@ -410,7 +434,7 @@ def test_deploy_list_command_no_results(monkeypatch) -> None:
|
||||
pass
|
||||
|
||||
def list_deployments(self, name_contains: str = ""):
|
||||
return {"resources": []}
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
|
||||
|
||||
@@ -430,6 +454,203 @@ def test_deploy_list_command_no_results(monkeypatch) -> None:
|
||||
assert result.output.strip() == "No deployments found."
|
||||
|
||||
|
||||
def test_deploy_listeners_list_command(monkeypatch) -> None:
|
||||
runner = CliRunner()
|
||||
captured: dict[str, str] = {}
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, host_url: str, api_key: str, tenant_id: str | None = None):
|
||||
captured["host_url"] = host_url
|
||||
captured["api_key"] = api_key
|
||||
captured["tenant_id"] = tenant_id or ""
|
||||
|
||||
def list_listeners(self):
|
||||
return [
|
||||
{
|
||||
"id": "listener-1",
|
||||
"compute_id": "prod-cluster",
|
||||
"compute_config": {"k8s_namespaces": ["agents"]},
|
||||
},
|
||||
{
|
||||
"id": "listener-2",
|
||||
"compute_id": "multi-cluster",
|
||||
"compute_config": {"k8s_namespaces": ["agents", "agents-staging"]},
|
||||
},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
|
||||
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"deploy",
|
||||
"listeners",
|
||||
"list",
|
||||
"--api-key",
|
||||
"test-key",
|
||||
"--host-url",
|
||||
"https://api.example.com",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert captured == {
|
||||
"host_url": "https://api.example.com",
|
||||
"api_key": "test-key",
|
||||
"tenant_id": "",
|
||||
}
|
||||
assert "Listener ID" in result.output
|
||||
assert "Compute ID" in result.output
|
||||
assert "Namespaces" in result.output
|
||||
assert "listener-1" in result.output
|
||||
assert "prod-cluster" in result.output
|
||||
assert "agents, agents-staging" in result.output
|
||||
|
||||
|
||||
def test_deploy_listeners_list_command_no_results(monkeypatch) -> None:
|
||||
runner = CliRunner()
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, host_url: str, api_key: str, tenant_id: str | None = None):
|
||||
pass
|
||||
|
||||
def list_listeners(self):
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
|
||||
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"deploy",
|
||||
"listeners",
|
||||
"list",
|
||||
"--api-key",
|
||||
"test-key",
|
||||
"--host-url",
|
||||
"https://api.example.com",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert result.output.strip() == "No listeners found for this workspace."
|
||||
|
||||
|
||||
def test_deploy_listeners_list_command_json(monkeypatch) -> None:
|
||||
runner = CliRunner()
|
||||
|
||||
listeners = [
|
||||
{
|
||||
"id": "listener-1",
|
||||
"compute_id": "prod-cluster",
|
||||
"compute_config": {"k8s_namespaces": ["agents"]},
|
||||
},
|
||||
{
|
||||
"id": "listener-2",
|
||||
"compute_id": "multi-cluster",
|
||||
"compute_config": {"k8s_namespaces": ["agents", "agents-staging"]},
|
||||
},
|
||||
]
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, host_url: str, api_key: str, tenant_id: str | None = None):
|
||||
pass
|
||||
|
||||
def list_listeners(self):
|
||||
return listeners
|
||||
|
||||
monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
|
||||
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"deploy",
|
||||
"listeners",
|
||||
"list",
|
||||
"--json",
|
||||
"--api-key",
|
||||
"test-key",
|
||||
"--host-url",
|
||||
"https://api.example.com",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert json.loads(result.output) == listeners
|
||||
|
||||
|
||||
def test_deploy_listeners_list_command_json_no_results(monkeypatch) -> None:
|
||||
runner = CliRunner()
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, host_url: str, api_key: str, tenant_id: str | None = None):
|
||||
pass
|
||||
|
||||
def list_listeners(self):
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
|
||||
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"deploy",
|
||||
"listeners",
|
||||
"list",
|
||||
"--json",
|
||||
"--api-key",
|
||||
"test-key",
|
||||
"--host-url",
|
||||
"https://api.example.com",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert json.loads(result.output) == []
|
||||
|
||||
|
||||
def test_deploy_listeners_list_command_json_org_scoped_403(monkeypatch) -> None:
|
||||
"""With --json, an org-scoped 403 must raise cleanly instead of
|
||||
prompting for a workspace ID, which would write to stdout and hang
|
||||
waiting for input that a scripted invocation never provides."""
|
||||
runner = CliRunner()
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, host_url: str, api_key: str, tenant_id: str | None = None):
|
||||
pass
|
||||
|
||||
def list_listeners(self):
|
||||
raise HostBackendError("requires workspace specification", status_code=403)
|
||||
|
||||
def set_tenant(self, tenant_id: str) -> None:
|
||||
raise AssertionError("must not retry with a prompted tenant with --json")
|
||||
|
||||
monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
|
||||
|
||||
prompt_calls = []
|
||||
monkeypatch.setattr(
|
||||
click, "prompt", lambda *a, **k: prompt_calls.append((a, k)) or "unreachable"
|
||||
)
|
||||
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"deploy",
|
||||
"listeners",
|
||||
"list",
|
||||
"--json",
|
||||
"--api-key",
|
||||
"test-key",
|
||||
"--host-url",
|
||||
"https://api.example.com",
|
||||
],
|
||||
)
|
||||
|
||||
assert prompt_calls == [], "must not prompt interactively with --json"
|
||||
assert result.exit_code != 0
|
||||
assert "LANGSMITH_TENANT_ID" in result.output
|
||||
|
||||
|
||||
def test_deploy_revisions_list_command(monkeypatch) -> None:
|
||||
runner = CliRunner()
|
||||
captured: dict[str, str] = {}
|
||||
@@ -443,20 +664,18 @@ def test_deploy_revisions_list_command(monkeypatch) -> None:
|
||||
def list_revisions(self, deployment_id: str, limit: int = 1):
|
||||
captured["deployment_id"] = deployment_id
|
||||
captured["limit"] = str(limit)
|
||||
return {
|
||||
"resources": [
|
||||
{
|
||||
"id": "rev-123",
|
||||
"status": "CREATING",
|
||||
"created_at": "2023-11-07T05:31:56Z",
|
||||
},
|
||||
{
|
||||
"id": "rev-456",
|
||||
"status": "DEPLOYED",
|
||||
"created_at": "2023-11-08T10:00:00Z",
|
||||
},
|
||||
]
|
||||
}
|
||||
return [
|
||||
{
|
||||
"id": "rev-123",
|
||||
"status": "CREATING",
|
||||
"created_at": "2023-11-07T05:31:56Z",
|
||||
},
|
||||
{
|
||||
"id": "rev-456",
|
||||
"status": "DEPLOYED",
|
||||
"created_at": "2023-11-08T10:00:00Z",
|
||||
},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
|
||||
|
||||
@@ -497,7 +716,7 @@ def test_deploy_revisions_list_command_no_results(monkeypatch) -> None:
|
||||
pass
|
||||
|
||||
def list_revisions(self, deployment_id: str, limit: int = 1):
|
||||
return {"resources": []}
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
|
||||
|
||||
@@ -530,7 +749,7 @@ def test_deploy_revisions_list_command_with_explicit_limit(monkeypatch) -> None:
|
||||
def list_revisions(self, deployment_id: str, limit: int = 1):
|
||||
captured["deployment_id"] = deployment_id
|
||||
captured["limit"] = str(limit)
|
||||
return {"resources": []}
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,5 +12,5 @@ def disable_analytics_env() -> None:
|
||||
if "LANGGRAPH_CLI_NO_ANALYTICS" in os.environ:
|
||||
print("⚠️ LANGGRAPH_CLI_NO_ANALYTICS is set. Overriding it for the test.")
|
||||
|
||||
with patch.dict(os.environ, {"LANGGRAPH_CLI_NO_ANALYTICS": "0"}):
|
||||
with patch.dict(os.environ, {"LANGGRAPH_CLI_NO_ANALYTICS": "1"}):
|
||||
yield
|
||||
|
||||
@@ -11,6 +11,7 @@ from langgraph_cli.archive import (
|
||||
_tar_filter,
|
||||
create_archive,
|
||||
)
|
||||
from langgraph_cli.config import LocalDeps
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _tar_filter
|
||||
@@ -198,7 +199,6 @@ class TestCreateArchive:
|
||||
|
||||
@patch("langgraph_cli.archive._assemble_local_deps")
|
||||
def test_yields_archive_with_config(self, mock_deps, tmp_path):
|
||||
from langgraph_cli.config import LocalDeps
|
||||
|
||||
config_file = self._make_project(tmp_path)
|
||||
mock_deps.return_value = LocalDeps(
|
||||
@@ -218,7 +218,6 @@ class TestCreateArchive:
|
||||
|
||||
@patch("langgraph_cli.archive._assemble_local_deps")
|
||||
def test_excludes_pycache(self, mock_deps, tmp_path):
|
||||
from langgraph_cli.config import LocalDeps
|
||||
|
||||
config_file = self._make_project(tmp_path)
|
||||
mock_deps.return_value = LocalDeps(
|
||||
@@ -232,7 +231,6 @@ class TestCreateArchive:
|
||||
|
||||
@patch("langgraph_cli.archive._assemble_local_deps")
|
||||
def test_cleans_up_tmp_dir_on_normal_exit(self, mock_deps, tmp_path):
|
||||
from langgraph_cli.config import LocalDeps
|
||||
|
||||
config_file = self._make_project(tmp_path)
|
||||
mock_deps.return_value = LocalDeps(
|
||||
@@ -247,7 +245,6 @@ class TestCreateArchive:
|
||||
|
||||
@patch("langgraph_cli.archive._assemble_local_deps")
|
||||
def test_cleans_up_tmp_dir_on_exception(self, mock_deps, tmp_path):
|
||||
from langgraph_cli.config import LocalDeps
|
||||
|
||||
config_file = self._make_project(tmp_path)
|
||||
mock_deps.return_value = LocalDeps(
|
||||
@@ -264,7 +261,6 @@ class TestCreateArchive:
|
||||
@patch("langgraph_cli.archive._assemble_local_deps")
|
||||
@patch("langgraph_cli.archive._MAX_SIZE", 10)
|
||||
def test_raises_on_oversized_archive(self, mock_deps, tmp_path):
|
||||
from langgraph_cli.config import LocalDeps
|
||||
|
||||
config_file = self._make_project(tmp_path)
|
||||
mock_deps.return_value = LocalDeps(
|
||||
@@ -278,7 +274,6 @@ class TestCreateArchive:
|
||||
@patch("langgraph_cli.archive._assemble_local_deps")
|
||||
def test_handles_extra_contexts(self, mock_deps, tmp_path):
|
||||
"""Monorepo case: project + sibling dependency directory."""
|
||||
from langgraph_cli.config import LocalDeps
|
||||
|
||||
project = tmp_path / "myproject"
|
||||
project.mkdir()
|
||||
|
||||
@@ -255,6 +255,243 @@ def test_validate_config():
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"dependency",
|
||||
[
|
||||
"git+https://user:secret-token@github.com/org/private.git@main",
|
||||
"private-package @ git+http://token@github.com/org/private.git",
|
||||
"git+HTTPS://user%40example.com:secret%2Ftoken@github.com/org/private.git",
|
||||
"git+https://${GIT_TOKEN}@github.com/org/private.git",
|
||||
],
|
||||
)
|
||||
def test_validate_config_rejects_git_http_url_userinfo(dependency: str):
|
||||
with pytest.raises(click.UsageError) as exc_info:
|
||||
validate_config(
|
||||
{
|
||||
"python_version": "3.11",
|
||||
"dependencies": [dependency],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
}
|
||||
)
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "must not contain credentials or other URL userinfo" in message
|
||||
assert "secret-token" not in message
|
||||
assert "secret%2Ftoken" not in message
|
||||
|
||||
|
||||
def test_validate_config_file_reports_source_for_git_http_url_userinfo(
|
||||
tmp_path: pathlib.Path,
|
||||
):
|
||||
config_path = tmp_path / "langgraph.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"python_version": "3.11",
|
||||
"dependencies": ["git+https://secret-token@github.com/org/private.git"],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(click.UsageError) as exc_info:
|
||||
validate_config_file(config_path)
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "secret-token" not in message
|
||||
assert f"Found in: {config_path.resolve()}" in message
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"manifest", ["package.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml"]
|
||||
)
|
||||
def test_config_to_docker_rejects_git_http_url_userinfo_in_node_files(
|
||||
tmp_path: pathlib.Path, manifest: str
|
||||
):
|
||||
config_path = tmp_path / "langgraph.json"
|
||||
config_path.write_text("{}\n")
|
||||
(tmp_path / "agent.js").write_text("export const graph = {};\n")
|
||||
(tmp_path / "package.json").write_text('{"name":"agent"}\n')
|
||||
(tmp_path / manifest).write_text(
|
||||
'"priv": "git+https://user:secret-token@github.com/org/private.git"\n'
|
||||
)
|
||||
config = validate_config(
|
||||
{
|
||||
"node_version": "20",
|
||||
"graphs": {"agent": "./agent.js:graph"},
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(click.UsageError) as exc_info:
|
||||
config_to_docker(
|
||||
config_path,
|
||||
config,
|
||||
base_image="langchain/langgraphjs-api",
|
||||
)
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "must not contain credentials or other URL userinfo" in message
|
||||
assert "secret-token" not in message
|
||||
assert f"Found in: {(tmp_path / manifest).resolve()}" in message
|
||||
|
||||
|
||||
def test_config_to_docker_allows_node_git_urls_without_http_userinfo(
|
||||
tmp_path: pathlib.Path,
|
||||
):
|
||||
config_path = tmp_path / "langgraph.json"
|
||||
config_path.write_text("{}\n")
|
||||
(tmp_path / "agent.js").write_text("export const graph = {};\n")
|
||||
(tmp_path / "package.json").write_text(
|
||||
'{"dependencies":{"public":"git+https://github.com/org/public.git"}}\n'
|
||||
)
|
||||
config = validate_config(
|
||||
{
|
||||
"node_version": "20",
|
||||
"graphs": {"agent": "./agent.js:graph"},
|
||||
}
|
||||
)
|
||||
|
||||
docker, _ = config_to_docker(
|
||||
config_path,
|
||||
config,
|
||||
base_image="langchain/langgraphjs-api",
|
||||
)
|
||||
|
||||
assert f"ADD . /deps/{tmp_path.name}" in docker
|
||||
|
||||
|
||||
def test_config_to_docker_rejects_git_http_url_userinfo_in_node_workspace(
|
||||
tmp_path: pathlib.Path,
|
||||
):
|
||||
config_root = tmp_path / "apps" / "agent"
|
||||
config_root.mkdir(parents=True)
|
||||
config_path = config_root / "langgraph.json"
|
||||
config_path.write_text("{}\n")
|
||||
(config_root / "agent.js").write_text("export const graph = {};\n")
|
||||
(config_root / "package.json").write_text(
|
||||
'{"dependencies":{"priv":"git+https://secret-token@github.com/org/private.git"}}\n'
|
||||
)
|
||||
(tmp_path / "package.json").write_text('{"name":"workspace"}\n')
|
||||
config = validate_config(
|
||||
{
|
||||
"node_version": "20",
|
||||
"graphs": {"agent": "./agent.js:graph"},
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(click.UsageError) as exc_info:
|
||||
config_to_docker(
|
||||
config_path,
|
||||
config,
|
||||
base_image="langchain/langgraphjs-api",
|
||||
build_context=str(tmp_path),
|
||||
)
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "secret-token" not in message
|
||||
assert f"Found in: {(config_root / 'package.json').resolve()}" in message
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"dependency",
|
||||
[
|
||||
"git+https://github.com/org/public.git@main",
|
||||
"private-package @ git+https://github.com/org/private.git@main",
|
||||
"git+ssh://git@github.com/org/private.git@main",
|
||||
],
|
||||
)
|
||||
def test_validate_config_allows_git_urls_without_http_userinfo(dependency: str):
|
||||
config = validate_config(
|
||||
{
|
||||
"python_version": "3.11",
|
||||
"dependencies": [dependency],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
}
|
||||
)
|
||||
|
||||
assert config["dependencies"] == [dependency]
|
||||
|
||||
|
||||
def test_config_to_docker_rejects_git_http_url_userinfo_in_requirements(
|
||||
tmp_path: pathlib.Path,
|
||||
):
|
||||
config_path = tmp_path / "langgraph.json"
|
||||
config_path.write_text("{}\n")
|
||||
(tmp_path / "agent.py").write_text("graph = object()\n")
|
||||
(tmp_path / "requirements.txt").write_text(
|
||||
"private @ git+https://secret-token@github.com/org/private.git\n"
|
||||
)
|
||||
config = validate_config(
|
||||
{
|
||||
"python_version": "3.11",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(click.UsageError) as exc_info:
|
||||
config_to_docker(
|
||||
config_path,
|
||||
config,
|
||||
base_image="langchain/langgraph-api:0.2.47",
|
||||
)
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "must not contain credentials or other URL userinfo" in message
|
||||
assert "secret-token" not in message
|
||||
assert f"Found in: {(tmp_path / 'requirements.txt').resolve()}" in message
|
||||
|
||||
|
||||
@pytest.mark.parametrize("manifest", ["pyproject.toml", "uv.lock"])
|
||||
def test_config_to_docker_rejects_git_http_url_userinfo_in_uv_files(
|
||||
tmp_path: pathlib.Path, manifest: str
|
||||
):
|
||||
config_path = tmp_path / "langgraph.json"
|
||||
config_path.write_text("{}\n")
|
||||
(tmp_path / "src").mkdir()
|
||||
(tmp_path / "src" / "agent.py").write_text("graph = object()\n")
|
||||
pyproject = textwrap.dedent(
|
||||
"""
|
||||
[project]
|
||||
name = "agent"
|
||||
version = "0.1.0"
|
||||
dependencies = ["private"]
|
||||
|
||||
[tool.uv.sources]
|
||||
private = { git = "https://github.com/org/private.git" }
|
||||
"""
|
||||
).strip()
|
||||
uv_lock = "# uv lock file\n"
|
||||
if manifest == "pyproject.toml":
|
||||
pyproject = pyproject.replace(
|
||||
"https://github.com", "https://secret-token@github.com"
|
||||
)
|
||||
else:
|
||||
uv_lock += (
|
||||
'source = { git = "https://secret-token@github.com/org/private.git" }\n'
|
||||
)
|
||||
(tmp_path / "pyproject.toml").write_text(pyproject + "\n")
|
||||
(tmp_path / "uv.lock").write_text(uv_lock)
|
||||
config = validate_config(
|
||||
{
|
||||
"python_version": "3.11",
|
||||
"graphs": {"agent": "./src/agent.py:graph"},
|
||||
"source": {"kind": "uv"},
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(click.UsageError) as exc_info:
|
||||
config_to_docker(
|
||||
config_path,
|
||||
config,
|
||||
base_image="langchain/langgraph-api:0.2.47",
|
||||
)
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "must not contain credentials or other URL userinfo" in message
|
||||
assert "secret-token" not in message
|
||||
|
||||
|
||||
def test_validate_config_image_distro():
|
||||
"""Test validation of image_distro field."""
|
||||
# Valid image_distro values should work
|
||||
@@ -1403,6 +1640,19 @@ def test_config_to_docker_uv_lock():
|
||||
"COPY --from=uv-workspace-root uv.lock /tmp/uv_export/project/uv.lock"
|
||||
in docker
|
||||
)
|
||||
workspace_pyprojects = [
|
||||
"apps/agent/pyproject.toml",
|
||||
"libs/extra/pyproject.toml",
|
||||
"libs/shared/pyproject.toml",
|
||||
]
|
||||
export_instruction = "RUN uv export --package agent"
|
||||
for pyproject_path in workspace_pyprojects:
|
||||
copy_instruction = (
|
||||
"COPY --from=uv-workspace-root "
|
||||
f"{pyproject_path} /tmp/uv_export/project/{pyproject_path}"
|
||||
)
|
||||
assert copy_instruction in docker
|
||||
assert docker.index(copy_instruction) < docker.index(export_instruction)
|
||||
assert additional_contexts == {"uv-workspace-root": str(project_root.resolve())}
|
||||
|
||||
assert (
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import json
|
||||
from unittest.mock import Mock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
import langgraph_cli.deploy as deploy
|
||||
from langgraph_cli.cli import cli
|
||||
from langgraph_cli.host_backend import HostBackendClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def deployment_api(monkeypatch, tmp_path):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv("LANGSMITH_DEPLOYMENT_NAME", raising=False)
|
||||
monkeypatch.setattr(deploy, "_emitter", None)
|
||||
monkeypatch.setattr(deploy, "_no_input", False)
|
||||
(tmp_path / "langgraph.json").write_text(
|
||||
json.dumps({"dependencies": ["."], "graphs": {"agent": "./agent.py:graph"}})
|
||||
)
|
||||
(tmp_path / ".env").write_text("LANGSMITH_DEPLOYMENT_NAME=legacy\n")
|
||||
requests = []
|
||||
state = {"enabled": True, "resources": []}
|
||||
|
||||
def handler(request):
|
||||
requests.append(request)
|
||||
assert request.url.path == "/v2/deployments"
|
||||
if request.method == "GET":
|
||||
if not state["enabled"] and (
|
||||
"agent_id" in request.url.params
|
||||
or "agent_environment" in request.url.params
|
||||
):
|
||||
return httpx.Response(
|
||||
400, text="Agent filters are not available for this tenant."
|
||||
)
|
||||
return httpx.Response(200, json={"resources": state["resources"]})
|
||||
assert request.method == "POST"
|
||||
return httpx.Response(200, json={"id": "runtime-id", "name": "server-name"})
|
||||
|
||||
client = HostBackendClient("https://api.example.com", "test-key")
|
||||
client._client.close()
|
||||
client._client = httpx.Client(
|
||||
base_url="https://api.example.com",
|
||||
transport=httpx.MockTransport(handler),
|
||||
headers={"X-Api-Key": "test-key"},
|
||||
)
|
||||
monkeypatch.setattr(deploy, "_create_host_backend_client", lambda *a, **kw: client)
|
||||
monkeypatch.setattr(deploy, "find_tracked_packages", lambda *a: [])
|
||||
remote_build = Mock(return_value=deploy.BuildResult())
|
||||
monkeypatch.setattr(deploy, "_run_remote_build", remote_build)
|
||||
monkeypatch.setattr(deploy, "_resolve_build_mode", lambda flag, **kw: (flag, None))
|
||||
yield state, requests, remote_build
|
||||
client._client.close()
|
||||
|
||||
|
||||
AGENT_ARGS = [
|
||||
"deploy",
|
||||
"--agent-id",
|
||||
"customer-support",
|
||||
"--agent-environment",
|
||||
"staging",
|
||||
"--remote",
|
||||
"--no-wait",
|
||||
"--no-input",
|
||||
]
|
||||
|
||||
|
||||
def test_agent_create(deployment_api, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("LANGSMITH_DEPLOYMENT_NAME", "legacy")
|
||||
_, requests, build = deployment_api
|
||||
result = CliRunner().invoke(cli, AGENT_ARGS)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert dict(requests[0].url.params) == {
|
||||
"agent_id": "customer-support",
|
||||
"agent_environment": "staging",
|
||||
"limit": "100",
|
||||
}
|
||||
payload = json.loads(requests[1].content)
|
||||
assert payload["agent"] == {
|
||||
"agent_id": "customer-support",
|
||||
"environment": "staging",
|
||||
}
|
||||
assert "name" not in payload
|
||||
assert build.call_args.kwargs["deployment_id"] == "runtime-id"
|
||||
assert "server-name" in result.output
|
||||
assert (tmp_path / ".env").read_text() == "LANGSMITH_DEPLOYMENT_NAME=legacy\n"
|
||||
|
||||
|
||||
def test_agent_update(deployment_api):
|
||||
state, requests, build = deployment_api
|
||||
state["resources"] = [{"id": "existing-id", "is_preview": False}]
|
||||
result = CliRunner().invoke(cli, AGENT_ARGS)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert len(requests) == 1
|
||||
assert build.call_args.kwargs["deployment_id"] == "existing-id"
|
||||
|
||||
|
||||
def test_agent_rejects_explicit_name(deployment_api, monkeypatch):
|
||||
monkeypatch.setenv("LANGSMITH_DEPLOYMENT_NAME", "legacy")
|
||||
_, requests, _ = deployment_api
|
||||
result = CliRunner().invoke(cli, [*AGENT_ARGS, "--name", "legacy"])
|
||||
assert result.exit_code == 2
|
||||
assert "cannot be combined" in result.output
|
||||
assert not requests
|
||||
|
||||
|
||||
def test_agent_lookup_refuses_a_control_plane_that_ignores_the_filter(deployment_api):
|
||||
state, requests, _ = deployment_api
|
||||
state["resources"] = [
|
||||
{"id": "someone-elses", "is_preview": False},
|
||||
{"id": "another", "is_preview": False},
|
||||
]
|
||||
|
||||
result = CliRunner().invoke(cli, AGENT_ARGS)
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "does not filter deployments by agent" in result.output
|
||||
assert len(requests) == 1
|
||||
@@ -13,6 +13,20 @@ import pytest
|
||||
|
||||
import langgraph_cli.deploy as deploy_mod
|
||||
from langgraph_cli.deploy import (
|
||||
BuildAndPush,
|
||||
ById,
|
||||
ByName,
|
||||
CreateOnlySettings,
|
||||
CustomerRegistrySource,
|
||||
DockerBuildCommand,
|
||||
ExistingDeployment,
|
||||
Listener,
|
||||
ManagedRegistrySource,
|
||||
OnListener,
|
||||
PublishedImage,
|
||||
RemoteBuildSource,
|
||||
RequestedPlacement,
|
||||
Unplaced,
|
||||
_call_host_backend_with_optional_tenant,
|
||||
_create_host_backend_client,
|
||||
_docker_config_for_token,
|
||||
@@ -21,17 +35,22 @@ from langgraph_cli.deploy import (
|
||||
_parse_env_from_config,
|
||||
_resolve_env_path,
|
||||
_resolve_pushed_image_digest,
|
||||
_smith_dashboard_base_url,
|
||||
_resource_spec,
|
||||
_select_source,
|
||||
_validate_prebuilt_image,
|
||||
find_deployment_by_name,
|
||||
normalize_image_tag,
|
||||
normalize_name,
|
||||
)
|
||||
from langgraph_cli.host_backend import HostBackendClient, HostBackendError
|
||||
from langgraph_cli.image_reference import ImageReference
|
||||
|
||||
|
||||
class TestDockerConfigForToken:
|
||||
def test_creates_config_json(self):
|
||||
with _docker_config_for_token("us-docker.pkg.dev", "my-token") as cfg:
|
||||
with _docker_config_for_token(
|
||||
"us-docker.pkg.dev", "my-token", "oauth2accesstoken"
|
||||
) as cfg:
|
||||
config_path = os.path.join(cfg, "config.json")
|
||||
assert os.path.isfile(config_path)
|
||||
with open(config_path) as f:
|
||||
@@ -39,13 +58,26 @@ class TestDockerConfigForToken:
|
||||
expected_auth = base64.b64encode(b"oauth2accesstoken:my-token").decode()
|
||||
assert data == {"auths": {"us-docker.pkg.dev": {"auth": expected_auth}}}
|
||||
|
||||
def test_uses_the_given_username(self):
|
||||
with _docker_config_for_token(
|
||||
"123456789012.dkr.ecr.us-east-2.amazonaws.com", "ecr-password", "AWS"
|
||||
) as cfg:
|
||||
with open(os.path.join(cfg, "config.json")) as f:
|
||||
data = json.load(f)
|
||||
expected_auth = base64.b64encode(b"AWS:ecr-password").decode()
|
||||
assert data["auths"]["123456789012.dkr.ecr.us-east-2.amazonaws.com"] == {
|
||||
"auth": expected_auth
|
||||
}
|
||||
|
||||
def test_tempdir_cleaned_up(self):
|
||||
with _docker_config_for_token("registry.example.com", "tok") as cfg:
|
||||
with _docker_config_for_token(
|
||||
"registry.example.com", "tok", "oauth2accesstoken"
|
||||
) as cfg:
|
||||
assert os.path.isdir(cfg)
|
||||
assert not os.path.exists(cfg)
|
||||
|
||||
def test_different_registries(self):
|
||||
with _docker_config_for_token("gcr.io", "token123") as cfg:
|
||||
with _docker_config_for_token("gcr.io", "token123", "oauth2accesstoken") as cfg:
|
||||
with open(os.path.join(cfg, "config.json")) as f:
|
||||
data = json.load(f)
|
||||
assert "gcr.io" in data["auths"]
|
||||
@@ -259,31 +291,29 @@ class TestEnvWithoutDeploymentName:
|
||||
|
||||
class TestCallHostBackendWithOptionalTenant:
|
||||
def _make_client(self, handler):
|
||||
c = HostBackendClient("https://api.example.com", "test-key")
|
||||
c._client = httpx.Client(
|
||||
base_url="https://api.example.com",
|
||||
c = HostBackendClient(
|
||||
"https://api.example.com",
|
||||
"test-key",
|
||||
transport=httpx.MockTransport(handler),
|
||||
headers={"X-Api-Key": "test-key", "Accept": "application/json"},
|
||||
timeout=30,
|
||||
)
|
||||
return c
|
||||
|
||||
def _make_eu_client(self, handler):
|
||||
c = HostBackendClient("https://eu.api.host.langchain.com", "test-key")
|
||||
c._client = httpx.Client(
|
||||
base_url="https://eu.api.host.langchain.com",
|
||||
c = HostBackendClient(
|
||||
"https://eu.api.host.langchain.com",
|
||||
"test-key",
|
||||
transport=httpx.MockTransport(handler),
|
||||
headers={"X-Api-Key": "test-key", "Accept": "application/json"},
|
||||
timeout=30,
|
||||
)
|
||||
return c
|
||||
|
||||
def test_success_passes_through(self):
|
||||
client = self._make_client(lambda req: httpx.Response(200, json={"ok": True}))
|
||||
client = self._make_client(
|
||||
lambda req: httpx.Response(200, json={"resources": [{"id": "dep-1"}]})
|
||||
)
|
||||
result = _call_host_backend_with_optional_tenant(
|
||||
client, lambda c: c.list_deployments()
|
||||
)
|
||||
assert result == {"ok": True}
|
||||
assert result == [{"id": "dep-1"}]
|
||||
|
||||
def test_403_not_enabled_gives_actionable_error(self):
|
||||
detail = (
|
||||
@@ -334,7 +364,6 @@ class TestCallHostBackendWithOptionalTenant:
|
||||
assert exc_info.value.status_code == 403
|
||||
assert "smith.langchain.com" in exc_info.value.message
|
||||
assert seen_tenant_ids == [None, "workspace-123"]
|
||||
assert client._client.headers["X-Tenant-ID"] == "workspace-123"
|
||||
|
||||
def test_other_403_re_raises_original(self):
|
||||
client = self._make_client(
|
||||
@@ -347,7 +376,6 @@ class TestCallHostBackendWithOptionalTenant:
|
||||
|
||||
def test_workspace_prompt_blocked_by_no_input(self, monkeypatch):
|
||||
"""With _no_input=True, 403 requiring workspace should raise ClickException."""
|
||||
import langgraph_cli.deploy as deploy_mod
|
||||
|
||||
monkeypatch.setattr(deploy_mod, "_no_input", True)
|
||||
|
||||
@@ -515,7 +543,6 @@ class TestEmitterTextMode:
|
||||
|
||||
class TestCreateHostBackendClientNoInput:
|
||||
def test_raises_when_no_api_key_and_no_input(self, monkeypatch, tmp_path):
|
||||
import langgraph_cli.deploy as deploy_mod
|
||||
|
||||
monkeypatch.setattr(deploy_mod, "_no_input", True)
|
||||
monkeypatch.delenv("LANGSMITH_API_KEY", raising=False)
|
||||
@@ -530,7 +557,6 @@ class TestCreateHostBackendClientNoInput:
|
||||
)
|
||||
|
||||
def test_succeeds_with_api_key_in_env(self, monkeypatch, tmp_path):
|
||||
import langgraph_cli.deploy as deploy_mod
|
||||
|
||||
monkeypatch.setattr(deploy_mod, "_no_input", True)
|
||||
monkeypatch.setenv("LANGSMITH_API_KEY", "lsv2_test")
|
||||
@@ -543,60 +569,334 @@ class TestCreateHostBackendClientNoInput:
|
||||
assert client is not None
|
||||
|
||||
|
||||
class TestSmithDashboardBaseUrl:
|
||||
def test_none_returns_default(self):
|
||||
assert _smith_dashboard_base_url(None) == "https://smith.langchain.com"
|
||||
class TestCreateHostBackendClientEndpoint:
|
||||
def test_langsmith_endpoint_from_project_env_selects_self_hosted_control_plane(
|
||||
self, monkeypatch
|
||||
):
|
||||
monkeypatch.setenv("LANGSMITH_API_KEY", "lsv2_test")
|
||||
monkeypatch.delenv("LANGSMITH_ENDPOINT", raising=False)
|
||||
|
||||
def test_empty_returns_default(self):
|
||||
assert _smith_dashboard_base_url("") == "https://smith.langchain.com"
|
||||
|
||||
def test_prod_host_url(self):
|
||||
assert (
|
||||
_smith_dashboard_base_url("https://api.host.langchain.com")
|
||||
== "https://smith.langchain.com"
|
||||
client = _create_host_backend_client(
|
||||
host_url=None,
|
||||
api_key=None,
|
||||
env_vars={"LANGSMITH_ENDPOINT": "https://smith.example.com/api/v1"},
|
||||
)
|
||||
|
||||
def test_dev_host_url(self):
|
||||
assert (
|
||||
_smith_dashboard_base_url("https://dev.api.host.langchain.com")
|
||||
== "https://dev.smith.langchain.com"
|
||||
assert client.base_url == "https://smith.example.com/api-host"
|
||||
|
||||
def test_explicit_host_url_wins_over_langsmith_endpoint(self, monkeypatch):
|
||||
monkeypatch.setenv("LANGSMITH_API_KEY", "lsv2_test")
|
||||
monkeypatch.setenv("LANGSMITH_ENDPOINT", "https://smith.example.com/api/v1")
|
||||
|
||||
client = _create_host_backend_client(
|
||||
host_url="https://custom.host.com", api_key=None, env_vars={}
|
||||
)
|
||||
|
||||
def test_eu_host_url(self):
|
||||
assert (
|
||||
_smith_dashboard_base_url("https://eu.api.host.langchain.com")
|
||||
== "https://eu.smith.langchain.com"
|
||||
assert client.base_url == "https://custom.host.com"
|
||||
|
||||
|
||||
class TestDockerBuildCommand:
|
||||
@pytest.mark.parametrize(
|
||||
("machine", "verbose", "expected"),
|
||||
[
|
||||
pytest.param(
|
||||
"x86_64",
|
||||
False,
|
||||
DockerBuildCommand(("docker", "build"), ()),
|
||||
id="amd64_host_builds_natively",
|
||||
),
|
||||
pytest.param(
|
||||
"arm64",
|
||||
False,
|
||||
DockerBuildCommand(
|
||||
("docker", "buildx", "build"),
|
||||
("--platform", "linux/amd64", "--load", "--progress=plain"),
|
||||
),
|
||||
id="other_hosts_capture_plain_build_logs",
|
||||
),
|
||||
pytest.param(
|
||||
"arm64",
|
||||
True,
|
||||
DockerBuildCommand(
|
||||
("docker", "buildx", "build"),
|
||||
("--platform", "linux/amd64", "--load"),
|
||||
),
|
||||
id="verbose_cross_build_keeps_progress_output",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_for_host_targets_the_deployment_platform(self, machine, verbose, expected):
|
||||
assert DockerBuildCommand.for_host(machine, verbose=verbose) == expected
|
||||
|
||||
|
||||
class TestSelectSource:
|
||||
OPTIONS = {
|
||||
"push_to": None,
|
||||
"image": None,
|
||||
"image_uri": None,
|
||||
"image_name": None,
|
||||
"tag": None,
|
||||
"remote_build_flag": None,
|
||||
"placement": RequestedPlacement(),
|
||||
"image_pull_secrets": (),
|
||||
"selector": ByName("my-app"),
|
||||
}
|
||||
REPOSITORY = "registry.example.com/app"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("flags", "docker_available", "expected"),
|
||||
[
|
||||
pytest.param(
|
||||
{"push_to": REPOSITORY},
|
||||
True,
|
||||
CustomerRegistrySource(
|
||||
image=BuildAndPush(
|
||||
reference=ImageReference(REPOSITORY, "latest"),
|
||||
prebuilt_image=None,
|
||||
),
|
||||
create_only=CreateOnlySettings(RequestedPlacement()),
|
||||
),
|
||||
id="push_to_selects_the_external_source_with_the_default_tag",
|
||||
),
|
||||
pytest.param(
|
||||
{"push_to": f"{REPOSITORY}:v2"},
|
||||
True,
|
||||
CustomerRegistrySource(
|
||||
image=BuildAndPush(
|
||||
reference=ImageReference(REPOSITORY, "v2"),
|
||||
prebuilt_image=None,
|
||||
),
|
||||
create_only=CreateOnlySettings(RequestedPlacement()),
|
||||
),
|
||||
id="push_to_keeps_a_tag_given_in_the_reference",
|
||||
),
|
||||
pytest.param(
|
||||
{"push_to": REPOSITORY, "tag": "v3"},
|
||||
True,
|
||||
CustomerRegistrySource(
|
||||
image=BuildAndPush(
|
||||
reference=ImageReference(REPOSITORY, "v3"),
|
||||
prebuilt_image=None,
|
||||
),
|
||||
create_only=CreateOnlySettings(RequestedPlacement()),
|
||||
),
|
||||
id="tag_flag_composes_with_push_to",
|
||||
),
|
||||
pytest.param(
|
||||
{"push_to": REPOSITORY, "image": "app:dev"},
|
||||
False,
|
||||
CustomerRegistrySource(
|
||||
image=BuildAndPush(
|
||||
reference=ImageReference(REPOSITORY, "latest"),
|
||||
prebuilt_image="app:dev",
|
||||
),
|
||||
create_only=CreateOnlySettings(RequestedPlacement()),
|
||||
),
|
||||
id="prebuilt_image_is_retagged_for_push_to_without_docker_checks",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"push_to": REPOSITORY,
|
||||
"placement": RequestedPlacement("listener-1", "agents"),
|
||||
},
|
||||
True,
|
||||
CustomerRegistrySource(
|
||||
image=BuildAndPush(
|
||||
reference=ImageReference(REPOSITORY, "latest"),
|
||||
prebuilt_image=None,
|
||||
),
|
||||
create_only=CreateOnlySettings(
|
||||
RequestedPlacement("listener-1", "agents")
|
||||
),
|
||||
),
|
||||
id="push_to_carries_the_requested_placement",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"push_to": REPOSITORY,
|
||||
"image_pull_secrets": ("regcred", "other-secret"),
|
||||
},
|
||||
True,
|
||||
CustomerRegistrySource(
|
||||
image=BuildAndPush(
|
||||
reference=ImageReference(REPOSITORY, "latest"),
|
||||
prebuilt_image=None,
|
||||
),
|
||||
create_only=CreateOnlySettings(
|
||||
RequestedPlacement(), ("regcred", "other-secret")
|
||||
),
|
||||
),
|
||||
id="push_to_carries_the_image_pull_secrets",
|
||||
),
|
||||
pytest.param(
|
||||
{"image_uri": f"{REPOSITORY}@sha256:abc123"},
|
||||
True,
|
||||
CustomerRegistrySource(
|
||||
image=PublishedImage(f"{REPOSITORY}@sha256:abc123"),
|
||||
create_only=CreateOnlySettings(RequestedPlacement()),
|
||||
),
|
||||
id="image_uri_selects_the_published_image_source_by_digest",
|
||||
),
|
||||
pytest.param(
|
||||
{"image_uri": f" {REPOSITORY}@sha256:abc123 "},
|
||||
False,
|
||||
CustomerRegistrySource(
|
||||
image=PublishedImage(f"{REPOSITORY}@sha256:abc123"),
|
||||
create_only=CreateOnlySettings(RequestedPlacement()),
|
||||
),
|
||||
id="image_uri_needs_no_local_docker_and_is_trimmed",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"image_uri": f"{REPOSITORY}@sha256:abc123",
|
||||
"placement": RequestedPlacement("listener-1", "agents"),
|
||||
},
|
||||
False,
|
||||
CustomerRegistrySource(
|
||||
image=PublishedImage(f"{REPOSITORY}@sha256:abc123"),
|
||||
create_only=CreateOnlySettings(
|
||||
RequestedPlacement("listener-1", "agents")
|
||||
),
|
||||
),
|
||||
id="image_uri_carries_the_requested_placement",
|
||||
),
|
||||
pytest.param(
|
||||
{"remote_build_flag": True},
|
||||
True,
|
||||
RemoteBuildSource(),
|
||||
id="remote_flag_selects_the_source_upload",
|
||||
),
|
||||
pytest.param(
|
||||
{},
|
||||
False,
|
||||
RemoteBuildSource(),
|
||||
id="no_local_docker_falls_back_to_the_source_upload",
|
||||
),
|
||||
pytest.param(
|
||||
{},
|
||||
True,
|
||||
ManagedRegistrySource(
|
||||
prebuilt_image=None, image_name=None, tag="latest"
|
||||
),
|
||||
id="local_docker_selects_the_internal_docker_source",
|
||||
),
|
||||
pytest.param(
|
||||
{"image": "app:dev", "tag": "v1"},
|
||||
False,
|
||||
ManagedRegistrySource(
|
||||
prebuilt_image="app:dev", image_name=None, tag="v1"
|
||||
),
|
||||
id="prebuilt_image_forces_the_internal_docker_source",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_flags_select_one_source(
|
||||
self, monkeypatch, mocker, flags, docker_available, expected
|
||||
):
|
||||
mocker.patch(
|
||||
"langgraph_cli.deploy._get_emitter", return_value=mocker.MagicMock()
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
deploy_mod,
|
||||
"can_build_locally",
|
||||
lambda: (True, None) if docker_available else (False, "Docker is required"),
|
||||
)
|
||||
|
||||
def test_staging_host_url(self):
|
||||
assert (
|
||||
_smith_dashboard_base_url("https://staging.api.host.langchain.com")
|
||||
== "https://staging.smith.langchain.com"
|
||||
assert _select_source(**{**self.OPTIONS, **flags}) == expected
|
||||
|
||||
def test_push_to_build_requires_local_docker(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
deploy_mod, "can_build_locally", lambda: (False, "Docker is required")
|
||||
)
|
||||
|
||||
def test_localhost(self):
|
||||
assert (
|
||||
_smith_dashboard_base_url("http://localhost:8080")
|
||||
== "http://localhost:8080"
|
||||
)
|
||||
with pytest.raises(click.UsageError, match="Docker is required"):
|
||||
_select_source(**{**self.OPTIONS, "push_to": self.REPOSITORY})
|
||||
|
||||
def test_localhost_trailing_slash(self):
|
||||
assert (
|
||||
_smith_dashboard_base_url("http://localhost:8080/")
|
||||
== "http://localhost:8080"
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("flags", "message"),
|
||||
[
|
||||
pytest.param(
|
||||
{"push_to": REPOSITORY, "remote_build_flag": True},
|
||||
"--push-to cannot be combined with --remote.",
|
||||
id="push_to_with_remote",
|
||||
),
|
||||
pytest.param(
|
||||
{"push_to": f"{REPOSITORY}:v1", "tag": "v2"},
|
||||
"already includes a tag",
|
||||
id="push_to_with_a_tag_and_the_tag_flag",
|
||||
),
|
||||
pytest.param(
|
||||
{"push_to": f"{REPOSITORY}@sha256:abc"},
|
||||
"not a digest",
|
||||
id="push_to_with_a_digest",
|
||||
),
|
||||
pytest.param(
|
||||
{"image": "app:dev", "remote_build_flag": True},
|
||||
"--image cannot be combined with --remote builds.",
|
||||
id="image_with_remote",
|
||||
),
|
||||
pytest.param(
|
||||
{"placement": RequestedPlacement(listener_id="listener-1")},
|
||||
"only apply when creating a deployment with --push-to",
|
||||
id="listener_without_push_to",
|
||||
),
|
||||
pytest.param(
|
||||
{"placement": RequestedPlacement(k8s_namespace="agents")},
|
||||
"only apply when creating a deployment with --push-to",
|
||||
id="namespace_without_push_to",
|
||||
),
|
||||
pytest.param(
|
||||
{"image_pull_secrets": ("regcred",)},
|
||||
"only apply when creating a deployment with --push-to",
|
||||
id="image_pull_secret_without_push_to",
|
||||
),
|
||||
pytest.param(
|
||||
{"image_uri": REPOSITORY, "push_to": REPOSITORY},
|
||||
"--image-uri cannot be combined with --push-to.",
|
||||
id="image_uri_with_push_to",
|
||||
),
|
||||
pytest.param(
|
||||
{"image_uri": REPOSITORY, "image": "app:dev"},
|
||||
"--image-uri cannot be combined with --image.",
|
||||
id="image_uri_with_image",
|
||||
),
|
||||
pytest.param(
|
||||
{"image_uri": REPOSITORY, "tag": "v2"},
|
||||
"--image-uri cannot be combined with --tag.",
|
||||
id="image_uri_with_tag",
|
||||
),
|
||||
pytest.param(
|
||||
{"image_uri": REPOSITORY, "remote_build_flag": True},
|
||||
"--image-uri cannot be combined with --remote.",
|
||||
id="image_uri_with_remote",
|
||||
),
|
||||
pytest.param(
|
||||
{"image_uri": ""},
|
||||
"--image-uri must not be empty.",
|
||||
id="image_uri_empty",
|
||||
),
|
||||
pytest.param(
|
||||
{"image_uri": " "},
|
||||
"--image-uri must not be empty.",
|
||||
id="image_uri_blank",
|
||||
),
|
||||
pytest.param(
|
||||
{"image_uri": f"{REPOSITORY}:v1.2.3"},
|
||||
"--image-uri must pin a digest",
|
||||
id="image_uri_with_a_mutable_tag",
|
||||
),
|
||||
pytest.param(
|
||||
{"image_uri": REPOSITORY},
|
||||
"--image-uri must pin a digest",
|
||||
id="image_uri_without_any_tag_or_digest",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_conflicting_flags_are_rejected(self, monkeypatch, flags, message):
|
||||
monkeypatch.setattr(deploy_mod, "can_build_locally", lambda: (True, None))
|
||||
|
||||
def test_127_0_0_1(self):
|
||||
assert (
|
||||
_smith_dashboard_base_url("http://127.0.0.1:3000")
|
||||
== "http://127.0.0.1:3000"
|
||||
)
|
||||
|
||||
def test_unknown_domain_returns_default(self):
|
||||
assert (
|
||||
_smith_dashboard_base_url("https://custom.example.com")
|
||||
== "https://smith.langchain.com"
|
||||
)
|
||||
with pytest.raises(click.UsageError, match=message):
|
||||
_select_source(**{**self.OPTIONS, **flags})
|
||||
|
||||
|
||||
class TestResolvePushedImageDigest:
|
||||
@@ -647,6 +947,16 @@ class TestResolvePushedImageDigest:
|
||||
)
|
||||
assert out == "us-central1-docker.pkg.dev/proj/repo@sha256:abc123"
|
||||
|
||||
def test_registry_port_without_tag_still_resolves_the_digest(self):
|
||||
runner = self._runner('["localhost:5000/repo@sha256:abc123"]')
|
||||
out = _resolve_pushed_image_digest(
|
||||
runner,
|
||||
remote_image="localhost:5000/repo",
|
||||
docker_config_dir=None,
|
||||
verbose=False,
|
||||
)
|
||||
assert out == "localhost:5000/repo@sha256:abc123"
|
||||
|
||||
def test_empty_repodigests_falls_back_with_warning(self, mocker):
|
||||
emitter = mocker.MagicMock()
|
||||
mocker.patch("langgraph_cli.deploy._get_emitter", return_value=emitter)
|
||||
@@ -750,3 +1060,357 @@ class TestResolvePushedImageDigest:
|
||||
frame_locals = captured["coro"].cr_frame.f_locals
|
||||
assert "--config" not in frame_locals["args"]
|
||||
captured["coro"].close()
|
||||
|
||||
|
||||
class TestListener:
|
||||
@pytest.mark.parametrize(
|
||||
("resource", "expected"),
|
||||
[
|
||||
pytest.param(
|
||||
{
|
||||
"id": "listener-1",
|
||||
"compute_id": "prod-cluster",
|
||||
"compute_config": {"k8s_namespaces": ["agents", "agents-staging"]},
|
||||
},
|
||||
Listener("listener-1", "prod-cluster", ("agents", "agents-staging")),
|
||||
id="reads_id_cluster_and_namespaces",
|
||||
),
|
||||
pytest.param(
|
||||
{"id": "listener-1", "compute_id": "c", "compute_config": {}},
|
||||
Listener("listener-1", "c", ()),
|
||||
id="missing_namespaces",
|
||||
),
|
||||
pytest.param(
|
||||
{"id": "listener-1", "compute_id": "c", "compute_config": None},
|
||||
Listener("listener-1", "c", ()),
|
||||
id="null_compute_config",
|
||||
),
|
||||
pytest.param(
|
||||
{"id": "listener-1"},
|
||||
Listener("listener-1", "", ()),
|
||||
id="only_an_id",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_from_resource_reads_the_control_plane_shape(self, resource, expected):
|
||||
assert Listener.from_resource(resource) == expected
|
||||
|
||||
|
||||
ONE_NAMESPACE = Listener("listener-1", "prod-cluster", ("agents",))
|
||||
TWO_NAMESPACES = Listener("listener-2", "multi-cluster", ("agents", "agents-staging"))
|
||||
NO_NAMESPACE = Listener("listener-3", "broken-cluster", ())
|
||||
|
||||
|
||||
class TestRequestedPlacement:
|
||||
@pytest.mark.parametrize(
|
||||
("request_", "listeners", "expected"),
|
||||
[
|
||||
pytest.param(
|
||||
RequestedPlacement(), (), Unplaced(), id="no_listeners_no_request"
|
||||
),
|
||||
pytest.param(
|
||||
RequestedPlacement(),
|
||||
(ONE_NAMESPACE,),
|
||||
OnListener("listener-1", "agents"),
|
||||
id="uses_the_only_possible_answer",
|
||||
),
|
||||
pytest.param(
|
||||
RequestedPlacement(k8s_namespace="agents-staging"),
|
||||
(TWO_NAMESPACES,),
|
||||
OnListener("listener-2", "agents-staging"),
|
||||
id="namespace_alone_picks_the_only_listener",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_resolves_to_a_placement(self, request_, listeners, expected):
|
||||
assert request_.among(listeners) == expected
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("request_", "listeners", "message"),
|
||||
[
|
||||
pytest.param(
|
||||
RequestedPlacement(listener_id="listener-1"),
|
||||
(),
|
||||
"no listeners",
|
||||
id="workspace_has_no_listeners",
|
||||
),
|
||||
pytest.param(
|
||||
RequestedPlacement(),
|
||||
(ONE_NAMESPACE, TWO_NAMESPACES),
|
||||
"--listener-id",
|
||||
id="several_listeners_need_a_choice",
|
||||
),
|
||||
pytest.param(
|
||||
RequestedPlacement(k8s_namespace="agents"),
|
||||
(ONE_NAMESPACE, TWO_NAMESPACES),
|
||||
"--listener-id",
|
||||
id="namespace_alone_is_ambiguous_with_several_listeners",
|
||||
),
|
||||
pytest.param(
|
||||
RequestedPlacement(k8s_namespace="agents"),
|
||||
(),
|
||||
"no listeners",
|
||||
id="namespace_without_any_listener",
|
||||
),
|
||||
pytest.param(
|
||||
RequestedPlacement(),
|
||||
(TWO_NAMESPACES,),
|
||||
"--k8s-namespace",
|
||||
id="several_namespaces_need_a_choice",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_refuses_and_names_the_choices(self, request_, listeners, message):
|
||||
with pytest.raises(click.UsageError, match=message):
|
||||
request_.among(listeners)
|
||||
|
||||
def test_the_error_lists_every_listener_with_its_cluster_and_namespaces(self):
|
||||
with pytest.raises(click.UsageError) as error:
|
||||
RequestedPlacement().among((ONE_NAMESPACE, TWO_NAMESPACES))
|
||||
|
||||
assert "listener-1" in error.value.message
|
||||
assert "prod-cluster" in error.value.message
|
||||
assert "agents-staging" in error.value.message
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("placement", "expected"),
|
||||
[
|
||||
pytest.param(Unplaced(), {}, id="unplaced_adds_nothing"),
|
||||
pytest.param(
|
||||
OnListener("listener-1", "agents"),
|
||||
{
|
||||
"listener_id": "listener-1",
|
||||
"listener_config": {"k8s_namespace": "agents"},
|
||||
},
|
||||
id="placed_carries_listener_and_namespace",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_source_config_matches_the_control_plane_shape(self, placement, expected):
|
||||
assert placement.source_config() == expected
|
||||
|
||||
|
||||
def test_finding_a_deployment_by_name_narrows_the_search_for_every_server_version():
|
||||
seen: dict = {}
|
||||
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
seen["params"] = dict(req.url.params)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"resources": [{"id": "dep-1", "name": "agent", "source": "github"}]},
|
||||
)
|
||||
|
||||
client = HostBackendClient(
|
||||
"https://api.example.com", "key", transport=httpx.MockTransport(handler)
|
||||
)
|
||||
|
||||
found = find_deployment_by_name(client, "agent")
|
||||
|
||||
assert seen["params"] == {
|
||||
"name": "agent",
|
||||
"name_contains": "agent",
|
||||
"limit": "100",
|
||||
}
|
||||
assert found == ExistingDeployment("dep-1", "github")
|
||||
|
||||
|
||||
def test_a_server_that_ignores_the_exact_name_filter_never_matches_another_deployment():
|
||||
client = HostBackendClient(
|
||||
"https://api.example.com",
|
||||
"key",
|
||||
transport=httpx.MockTransport(
|
||||
lambda req: httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"resources": [
|
||||
{
|
||||
"id": "dep-other",
|
||||
"name": "another-teams-agent",
|
||||
"source": "external_docker",
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
assert find_deployment_by_name(client, "brand-new-agent") is None
|
||||
|
||||
|
||||
def test_a_full_page_without_a_match_refuses_to_claim_the_name_is_free():
|
||||
page = [
|
||||
{"id": f"dep-{index}", "name": f"other-agent-{index}"} for index in range(100)
|
||||
]
|
||||
client = HostBackendClient(
|
||||
"https://api.example.com",
|
||||
"key",
|
||||
transport=httpx.MockTransport(
|
||||
lambda req: httpx.Response(200, json={"resources": page})
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(click.ClickException, match="--deployment-id"):
|
||||
find_deployment_by_name(client, "brand-new-agent")
|
||||
|
||||
|
||||
def test_a_partial_page_without_a_match_means_the_name_is_free():
|
||||
client = HostBackendClient(
|
||||
"https://api.example.com",
|
||||
"key",
|
||||
transport=httpx.MockTransport(
|
||||
lambda req: httpx.Response(
|
||||
200, json={"resources": [{"id": "dep-1", "name": "other"}]}
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
assert find_deployment_by_name(client, "brand-new-agent") is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"resource",
|
||||
[
|
||||
pytest.param({"compute_id": "c"}, id="no_id"),
|
||||
pytest.param({"id": ""}, id="empty_id"),
|
||||
],
|
||||
)
|
||||
def test_a_listener_without_an_id_is_refused(resource):
|
||||
with pytest.raises(HostBackendError, match="without an id"):
|
||||
Listener.from_resource(resource)
|
||||
|
||||
|
||||
def test_a_deployment_id_with_listener_flags_is_refused_without_probing_docker(
|
||||
monkeypatch,
|
||||
):
|
||||
def explode() -> tuple[bool, str | None]:
|
||||
raise AssertionError("docker must not be probed for an argv-only conflict")
|
||||
|
||||
monkeypatch.setattr(deploy_mod, "can_build_locally", explode)
|
||||
|
||||
with pytest.raises(click.UsageError, match="dep-1"):
|
||||
_select_source(
|
||||
push_to="registry.example.com/app",
|
||||
image=None,
|
||||
image_uri=None,
|
||||
image_name=None,
|
||||
tag=None,
|
||||
remote_build_flag=None,
|
||||
placement=RequestedPlacement(listener_id="listener-1"),
|
||||
image_pull_secrets=(),
|
||||
selector=ById("dep-1"),
|
||||
)
|
||||
|
||||
|
||||
def test_a_deployment_id_with_image_pull_secrets_is_refused_without_probing_docker(
|
||||
monkeypatch,
|
||||
):
|
||||
def explode() -> tuple[bool, str | None]:
|
||||
raise AssertionError("docker must not be probed for an argv-only conflict")
|
||||
|
||||
monkeypatch.setattr(deploy_mod, "can_build_locally", explode)
|
||||
|
||||
with pytest.raises(click.UsageError, match="dep-1"):
|
||||
_select_source(
|
||||
push_to="registry.example.com/app",
|
||||
image=None,
|
||||
image_uri=None,
|
||||
image_name=None,
|
||||
tag=None,
|
||||
remote_build_flag=None,
|
||||
placement=RequestedPlacement(),
|
||||
image_pull_secrets=("regcred",),
|
||||
selector=ById("dep-1"),
|
||||
)
|
||||
|
||||
|
||||
class TestResourceSpec:
|
||||
def test_no_secrets_keeps_the_operator_default(self):
|
||||
assert _resource_spec(()) == {}
|
||||
|
||||
def test_secrets_become_local_object_references_in_order(self):
|
||||
assert _resource_spec(("regcred", "other-secret")) == {
|
||||
"image_pull_secrets": [
|
||||
{"name": "regcred"},
|
||||
{"name": "other-secret"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class TestCreateOnlySettings:
|
||||
@pytest.mark.parametrize(
|
||||
"name",
|
||||
[
|
||||
pytest.param("Bad_Name", id="uppercase_and_underscore"),
|
||||
pytest.param("", id="empty"),
|
||||
pytest.param("-leading-hyphen", id="leading_hyphen"),
|
||||
pytest.param("a" * 254, id="too_long"),
|
||||
],
|
||||
)
|
||||
def test_an_invalid_secret_name_is_rejected_up_front(self, name):
|
||||
with pytest.raises(click.UsageError, match="not a valid Kubernetes"):
|
||||
CreateOnlySettings(RequestedPlacement(), (name,))
|
||||
|
||||
def test_a_valid_secret_name_is_accepted(self):
|
||||
CreateOnlySettings(RequestedPlacement(), ("regcred-1.example",))
|
||||
|
||||
def test_ensure_not_existing_mentions_the_api_and_ui_for_pull_secrets(self):
|
||||
settings = CreateOnlySettings(RequestedPlacement(), ("regcred",))
|
||||
with pytest.raises(click.UsageError, match="API or the LangSmith UI"):
|
||||
settings.ensure_not_existing("dep-1")
|
||||
|
||||
def test_ensure_not_existing_keeps_the_placement_message_unchanged(self):
|
||||
settings = CreateOnlySettings(RequestedPlacement(listener_id="listener-1"))
|
||||
with pytest.raises(
|
||||
click.UsageError,
|
||||
match="Listener and namespace are fixed when a deployment is created",
|
||||
):
|
||||
settings.ensure_not_existing("dep-1")
|
||||
|
||||
|
||||
class TestPlacementOnAKnownListener:
|
||||
@pytest.mark.parametrize(
|
||||
("request_", "listener", "expected"),
|
||||
[
|
||||
pytest.param(
|
||||
RequestedPlacement(listener_id="listener-1"),
|
||||
ONE_NAMESPACE,
|
||||
OnListener("listener-1", "agents"),
|
||||
id="the_only_namespace_is_used",
|
||||
),
|
||||
pytest.param(
|
||||
RequestedPlacement(listener_id="listener-2", k8s_namespace="agents"),
|
||||
TWO_NAMESPACES,
|
||||
OnListener("listener-2", "agents"),
|
||||
id="the_chosen_namespace_is_used",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_places_on_the_listener(self, request_, listener, expected):
|
||||
assert request_.on(listener) == expected
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("request_", "listener", "message"),
|
||||
[
|
||||
pytest.param(
|
||||
RequestedPlacement(listener_id="listener-2"),
|
||||
TWO_NAMESPACES,
|
||||
"--k8s-namespace",
|
||||
id="several_namespaces_need_a_choice",
|
||||
),
|
||||
pytest.param(
|
||||
RequestedPlacement(listener_id="listener-2", k8s_namespace="nope"),
|
||||
TWO_NAMESPACES,
|
||||
"does not serve namespace",
|
||||
id="unknown_namespace",
|
||||
),
|
||||
pytest.param(
|
||||
RequestedPlacement(listener_id="listener-3"),
|
||||
NO_NAMESPACE,
|
||||
"serves no namespaces",
|
||||
id="listener_without_namespaces",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_refuses_and_names_the_namespaces(self, request_, listener, message):
|
||||
with pytest.raises(click.UsageError, match=message):
|
||||
request_.on(listener)
|
||||
|
||||
@@ -3,29 +3,16 @@ import json
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from langgraph_cli.host_backend import HostBackendClient, HostBackendError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_transport():
|
||||
return httpx.MockTransport(lambda req: httpx.Response(200, json={"ok": True}))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(mock_transport):
|
||||
c = HostBackendClient("https://api.example.com", "test-key")
|
||||
c._client = httpx.Client(
|
||||
base_url="https://api.example.com",
|
||||
transport=mock_transport,
|
||||
headers={"X-Api-Key": "test-key", "Accept": "application/json"},
|
||||
timeout=30,
|
||||
)
|
||||
return c
|
||||
from langgraph_cli.host_backend import (
|
||||
ControlPlaneEndpoints,
|
||||
HostBackendClient,
|
||||
HostBackendError,
|
||||
)
|
||||
|
||||
|
||||
def test_constructor_strips_trailing_slash():
|
||||
c = HostBackendClient("https://api.example.com/", "key")
|
||||
assert str(c._client.base_url) == "https://api.example.com"
|
||||
assert c.base_url == "https://api.example.com"
|
||||
|
||||
|
||||
def test_constructor_empty_url_raises():
|
||||
@@ -39,12 +26,8 @@ def test_request_sends_headers():
|
||||
assert req.headers["accept"] == "application/json"
|
||||
return httpx.Response(200, json={"ok": True})
|
||||
|
||||
c = HostBackendClient("https://api.example.com", "test-key")
|
||||
c._client = httpx.Client(
|
||||
base_url="https://api.example.com",
|
||||
transport=httpx.MockTransport(handler),
|
||||
headers={"X-Api-Key": "test-key", "Accept": "application/json"},
|
||||
timeout=30,
|
||||
c = HostBackendClient(
|
||||
"https://api.example.com", "test-key", transport=httpx.MockTransport(handler)
|
||||
)
|
||||
result = c._request("GET", "/test")
|
||||
assert result == {"ok": True}
|
||||
@@ -56,12 +39,8 @@ def test_request_sends_json_payload():
|
||||
assert req.content == b'{"key":"value"}'
|
||||
return httpx.Response(200, json={"created": True})
|
||||
|
||||
c = HostBackendClient("https://api.example.com", "test-key")
|
||||
c._client = httpx.Client(
|
||||
base_url="https://api.example.com",
|
||||
transport=httpx.MockTransport(handler),
|
||||
headers={"X-Api-Key": "test-key", "Accept": "application/json"},
|
||||
timeout=30,
|
||||
c = HostBackendClient(
|
||||
"https://api.example.com", "test-key", transport=httpx.MockTransport(handler)
|
||||
)
|
||||
result = c._request("POST", "/test", {"key": "value"})
|
||||
assert result == {"created": True}
|
||||
@@ -69,25 +48,13 @@ def test_request_sends_json_payload():
|
||||
|
||||
def test_request_empty_body_returns_none():
|
||||
transport = httpx.MockTransport(lambda req: httpx.Response(200, content=b""))
|
||||
c = HostBackendClient("https://api.example.com", "test-key")
|
||||
c._client = httpx.Client(
|
||||
base_url="https://api.example.com",
|
||||
transport=transport,
|
||||
headers={"X-Api-Key": "test-key", "Accept": "application/json"},
|
||||
timeout=30,
|
||||
)
|
||||
c = HostBackendClient("https://api.example.com", "test-key", transport=transport)
|
||||
assert c._request("DELETE", "/test") is None
|
||||
|
||||
|
||||
def test_request_http_error_raises():
|
||||
transport = httpx.MockTransport(lambda req: httpx.Response(404, text="not found"))
|
||||
c = HostBackendClient("https://api.example.com", "test-key")
|
||||
c._client = httpx.Client(
|
||||
base_url="https://api.example.com",
|
||||
transport=transport,
|
||||
headers={"X-Api-Key": "test-key", "Accept": "application/json"},
|
||||
timeout=30,
|
||||
)
|
||||
c = HostBackendClient("https://api.example.com", "test-key", transport=transport)
|
||||
with pytest.raises(HostBackendError, match="404"):
|
||||
c._request("GET", "/missing")
|
||||
|
||||
@@ -96,13 +63,7 @@ def test_request_invalid_json_raises():
|
||||
transport = httpx.MockTransport(
|
||||
lambda req: httpx.Response(200, content=b"not json")
|
||||
)
|
||||
c = HostBackendClient("https://api.example.com", "test-key")
|
||||
c._client = httpx.Client(
|
||||
base_url="https://api.example.com",
|
||||
transport=transport,
|
||||
headers={"X-Api-Key": "test-key", "Accept": "application/json"},
|
||||
timeout=30,
|
||||
)
|
||||
c = HostBackendClient("https://api.example.com", "test-key", transport=transport)
|
||||
with pytest.raises(HostBackendError, match="Failed to decode"):
|
||||
c._request("GET", "/bad-json")
|
||||
|
||||
@@ -111,84 +72,20 @@ def test_request_transport_error_raises():
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
raise httpx.ConnectError("connection refused")
|
||||
|
||||
c = HostBackendClient("https://api.example.com", "test-key")
|
||||
c._client = httpx.Client(
|
||||
base_url="https://api.example.com",
|
||||
transport=httpx.MockTransport(handler),
|
||||
headers={"X-Api-Key": "test-key", "Accept": "application/json"},
|
||||
timeout=30,
|
||||
c = HostBackendClient(
|
||||
"https://api.example.com", "test-key", transport=httpx.MockTransport(handler)
|
||||
)
|
||||
with pytest.raises(HostBackendError, match="connection refused"):
|
||||
c._request("GET", "/test")
|
||||
|
||||
|
||||
def test_create_deployment(client):
|
||||
result = client.create_deployment(
|
||||
name="my-deploy", deployment_type="dev", source="internal_docker"
|
||||
)
|
||||
assert result == {"ok": True}
|
||||
|
||||
|
||||
def test_get_deployment(client):
|
||||
result = client.get_deployment("dep-123")
|
||||
assert result == {"ok": True}
|
||||
|
||||
|
||||
def test_list_deployments(client):
|
||||
result = client.list_deployments("my-app")
|
||||
assert result == {"ok": True}
|
||||
|
||||
|
||||
def test_list_deployments_sends_query_params():
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
assert req.url.path == "/v2/deployments"
|
||||
assert req.url.params["name_contains"] == "my app"
|
||||
return httpx.Response(200, json={"ok": True})
|
||||
|
||||
c = HostBackendClient("https://api.example.com", "test-key")
|
||||
c._client = httpx.Client(
|
||||
base_url="https://api.example.com",
|
||||
transport=httpx.MockTransport(handler),
|
||||
headers={"X-Api-Key": "test-key", "Accept": "application/json"},
|
||||
timeout=30,
|
||||
)
|
||||
result = c.list_deployments("my app")
|
||||
assert result == {"ok": True}
|
||||
|
||||
|
||||
def test_delete_deployment(client):
|
||||
result = client.delete_deployment("dep-123")
|
||||
assert result == {"ok": True}
|
||||
|
||||
|
||||
def test_request_push_token(client):
|
||||
result = client.request_push_token("dep-123")
|
||||
assert result == {"ok": True}
|
||||
|
||||
|
||||
def test_update_deployment(client):
|
||||
result = client.update_deployment(
|
||||
"dep-123", "image:latest", secrets=[{"name": "KEY", "value": "val"}]
|
||||
)
|
||||
assert result == {"ok": True}
|
||||
|
||||
|
||||
def test_update_deployment_no_secrets(client):
|
||||
result = client.update_deployment("dep-123", "image:latest")
|
||||
assert result == {"ok": True}
|
||||
|
||||
|
||||
def _capturing_client(captured: dict) -> HostBackendClient:
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
captured["body"] = req.read()
|
||||
return httpx.Response(200, json={"ok": True})
|
||||
|
||||
c = HostBackendClient("https://api.example.com", "key")
|
||||
c._client = httpx.Client(
|
||||
base_url="https://api.example.com",
|
||||
transport=httpx.MockTransport(handler),
|
||||
headers={"X-Api-Key": "key", "Accept": "application/json"},
|
||||
timeout=30,
|
||||
c = HostBackendClient(
|
||||
"https://api.example.com", "key", transport=httpx.MockTransport(handler)
|
||||
)
|
||||
return c
|
||||
|
||||
@@ -199,6 +96,7 @@ def test_update_deployment_forwards_tracked_packages():
|
||||
c.update_deployment(
|
||||
"dep-123",
|
||||
"image:latest",
|
||||
revision_source="internal_docker",
|
||||
tracked_packages=["google-adk:1.0.0"],
|
||||
)
|
||||
body = json.loads(captured["body"])
|
||||
@@ -209,7 +107,7 @@ def test_update_deployment_forwards_tracked_packages():
|
||||
def test_update_deployment_omits_tracked_packages_when_absent():
|
||||
captured: dict = {}
|
||||
c = _capturing_client(captured)
|
||||
c.update_deployment("dep-123", "image:latest")
|
||||
c.update_deployment("dep-123", "image:latest", revision_source="internal_docker")
|
||||
body = json.loads(captured["body"])
|
||||
assert "tracked_packages" not in body
|
||||
|
||||
@@ -241,33 +139,14 @@ def test_update_deployment_internal_source_omits_tracked_packages_when_absent():
|
||||
assert "tracked_packages" not in body
|
||||
|
||||
|
||||
def test_list_revisions(client):
|
||||
result = client.list_revisions("dep-123", limit=5)
|
||||
assert result == {"ok": True}
|
||||
|
||||
|
||||
def test_get_revision(client):
|
||||
result = client.get_revision("dep-123", "rev-456")
|
||||
assert result == {"ok": True}
|
||||
|
||||
|
||||
def test_get_build_logs(client):
|
||||
result = client.get_build_logs("proj-1", "rev-1", {"limit": 10})
|
||||
assert result == {"ok": True}
|
||||
|
||||
|
||||
def test_get_deploy_logs_all_revisions():
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
assert "/v1/projects/proj-1/deploy_logs" in str(req.url)
|
||||
assert "/revisions/" not in str(req.url)
|
||||
return httpx.Response(200, json={"logs": [{"message": "running"}]})
|
||||
|
||||
c = HostBackendClient("https://api.example.com", "key")
|
||||
c._client = httpx.Client(
|
||||
base_url="https://api.example.com",
|
||||
transport=httpx.MockTransport(handler),
|
||||
headers={"X-Api-Key": "key", "Accept": "application/json"},
|
||||
timeout=30,
|
||||
c = HostBackendClient(
|
||||
"https://api.example.com", "key", transport=httpx.MockTransport(handler)
|
||||
)
|
||||
result = c.get_deploy_logs("proj-1", {"limit": 10})
|
||||
assert result == {"logs": [{"message": "running"}]}
|
||||
@@ -278,12 +157,520 @@ def test_get_deploy_logs_specific_revision():
|
||||
assert "/v1/projects/proj-1/revisions/rev-2/deploy_logs" in str(req.url)
|
||||
return httpx.Response(200, json={"logs": []})
|
||||
|
||||
c = HostBackendClient("https://api.example.com", "key")
|
||||
c._client = httpx.Client(
|
||||
base_url="https://api.example.com",
|
||||
transport=httpx.MockTransport(handler),
|
||||
headers={"X-Api-Key": "key", "Accept": "application/json"},
|
||||
timeout=30,
|
||||
c = HostBackendClient(
|
||||
"https://api.example.com", "key", transport=httpx.MockTransport(handler)
|
||||
)
|
||||
result = c.get_deploy_logs("proj-1", {"limit": 10}, revision_id="rev-2")
|
||||
assert result == {"logs": []}
|
||||
|
||||
|
||||
def _routing_client(seen: dict) -> HostBackendClient:
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
seen["method"] = req.method
|
||||
seen["url"] = str(req.url)
|
||||
return httpx.Response(200, json={"ok": True})
|
||||
|
||||
c = HostBackendClient(
|
||||
"https://api.example.com/prefix", "key", transport=httpx.MockTransport(handler)
|
||||
)
|
||||
return c
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("call", "expected_body"),
|
||||
[
|
||||
pytest.param(
|
||||
lambda c: c.create_deployment(
|
||||
name="my-deploy",
|
||||
source="internal_docker",
|
||||
source_config={"deployment_type": "dev"},
|
||||
source_revision_config={},
|
||||
),
|
||||
{
|
||||
"name": "my-deploy",
|
||||
"source": "internal_docker",
|
||||
"source_config": {"deployment_type": "dev"},
|
||||
"source_revision_config": {},
|
||||
},
|
||||
id="internal_docker_create_omits_secrets_key_when_not_given",
|
||||
),
|
||||
pytest.param(
|
||||
lambda c: c.create_deployment(
|
||||
name="my-deploy",
|
||||
source="internal_docker",
|
||||
source_config={"deployment_type": "prod"},
|
||||
source_revision_config={},
|
||||
secrets=[{"name": "KEY", "value": "val"}],
|
||||
),
|
||||
{
|
||||
"name": "my-deploy",
|
||||
"source": "internal_docker",
|
||||
"source_config": {"deployment_type": "prod"},
|
||||
"source_revision_config": {},
|
||||
"secrets": [{"name": "KEY", "value": "val"}],
|
||||
},
|
||||
id="internal_docker_create_forwards_secrets",
|
||||
),
|
||||
pytest.param(
|
||||
lambda c: c.update_deployment(
|
||||
"dep-123",
|
||||
"registry.example.com/app@sha256:abc",
|
||||
revision_source="internal_docker",
|
||||
secrets=[{"name": "KEY", "value": "val"}],
|
||||
),
|
||||
{
|
||||
"revision_source": "internal_docker",
|
||||
"source_revision_config": {
|
||||
"image_uri": "registry.example.com/app@sha256:abc"
|
||||
},
|
||||
"secrets": [{"name": "KEY", "value": "val"}],
|
||||
},
|
||||
id="internal_docker_revision_names_its_source",
|
||||
),
|
||||
pytest.param(
|
||||
lambda c: c.update_deployment_internal_source(
|
||||
"dep-123",
|
||||
source_tarball_path="tarballs/src.tgz",
|
||||
config_path="langgraph.json",
|
||||
secrets=[],
|
||||
install_command="yarn install",
|
||||
build_command="yarn build",
|
||||
),
|
||||
{
|
||||
"revision_source": "internal_source",
|
||||
"source_revision_config": {
|
||||
"source_tarball_path": "tarballs/src.tgz",
|
||||
"langgraph_config_path": "langgraph.json",
|
||||
},
|
||||
"source_config": {
|
||||
"install_command": "yarn install",
|
||||
"build_command": "yarn build",
|
||||
},
|
||||
"secrets": [],
|
||||
},
|
||||
id="internal_source_revision_sends_js_build_commands",
|
||||
),
|
||||
pytest.param(
|
||||
lambda c: c.update_deployment_internal_source(
|
||||
"dep-123",
|
||||
source_tarball_path="tarballs/src.tgz",
|
||||
config_path="langgraph.json",
|
||||
),
|
||||
{
|
||||
"revision_source": "internal_source",
|
||||
"source_revision_config": {
|
||||
"source_tarball_path": "tarballs/src.tgz",
|
||||
"langgraph_config_path": "langgraph.json",
|
||||
},
|
||||
},
|
||||
id="internal_source_revision_omits_source_config_without_commands",
|
||||
),
|
||||
pytest.param(
|
||||
lambda c: c.create_deployment(
|
||||
name="agent",
|
||||
source="external_docker",
|
||||
source_config={"resource_spec": {}},
|
||||
source_revision_config={
|
||||
"image_uri": "registry.example.com/agent@sha256:1"
|
||||
},
|
||||
secrets=[],
|
||||
),
|
||||
{
|
||||
"name": "agent",
|
||||
"source": "external_docker",
|
||||
"source_config": {"resource_spec": {}},
|
||||
"source_revision_config": {
|
||||
"image_uri": "registry.example.com/agent@sha256:1"
|
||||
},
|
||||
"secrets": [],
|
||||
},
|
||||
id="create_sends_the_source_configs_as_given",
|
||||
),
|
||||
pytest.param(
|
||||
lambda c: c.update_deployment(
|
||||
"dep-1", "registry.example.com/agent@sha256:2", revision_source=None
|
||||
),
|
||||
{
|
||||
"source_revision_config": {
|
||||
"image_uri": "registry.example.com/agent@sha256:2"
|
||||
}
|
||||
},
|
||||
id="revision_without_source_override_omits_revision_source",
|
||||
),
|
||||
pytest.param(
|
||||
lambda c: c.update_deployment(
|
||||
"dep-1",
|
||||
"registry.example.com/agent@sha256:2",
|
||||
revision_source="internal_docker",
|
||||
tracked_packages=["langgraph:1.0.0"],
|
||||
),
|
||||
{
|
||||
"revision_source": "internal_docker",
|
||||
"source_revision_config": {
|
||||
"image_uri": "registry.example.com/agent@sha256:2"
|
||||
},
|
||||
"tracked_packages": ["langgraph:1.0.0"],
|
||||
},
|
||||
id="revision_with_source_override_names_it",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_request_body_matches_control_plane_contract(call, expected_body):
|
||||
captured: dict = {}
|
||||
call(_capturing_client(captured))
|
||||
assert json.loads(captured["body"]) == expected_body
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("call", "method", "route"),
|
||||
[
|
||||
pytest.param(
|
||||
lambda c: c.create_deployment(
|
||||
name="n",
|
||||
source="internal_docker",
|
||||
source_config={"deployment_type": "dev"},
|
||||
source_revision_config={},
|
||||
),
|
||||
"POST",
|
||||
"/v2/deployments",
|
||||
id="create_deployment",
|
||||
),
|
||||
pytest.param(
|
||||
lambda c: c.get_deployment("dep-1"),
|
||||
"GET",
|
||||
"/v2/deployments/dep-1",
|
||||
id="get_deployment",
|
||||
),
|
||||
pytest.param(
|
||||
lambda c: c.delete_deployment("dep-1"),
|
||||
"DELETE",
|
||||
"/v2/deployments/dep-1",
|
||||
id="delete_deployment",
|
||||
),
|
||||
pytest.param(
|
||||
lambda c: c.update_deployment("dep-1", "img", revision_source=None),
|
||||
"PATCH",
|
||||
"/v2/deployments/dep-1",
|
||||
id="patch_deployment",
|
||||
),
|
||||
pytest.param(
|
||||
lambda c: c.request_push_token("dep-1"),
|
||||
"POST",
|
||||
"/v2/deployments/dep-1/push-token",
|
||||
id="push_token",
|
||||
),
|
||||
pytest.param(
|
||||
lambda c: c.request_upload_url("dep-1"),
|
||||
"POST",
|
||||
"/v2/deployments/dep-1/upload-url",
|
||||
id="upload_url",
|
||||
),
|
||||
pytest.param(
|
||||
lambda c: c.list_revisions("dep-1", limit=5),
|
||||
"GET",
|
||||
"/v2/deployments/dep-1/revisions?limit=5",
|
||||
id="list_revisions_puts_limit_in_query",
|
||||
),
|
||||
pytest.param(
|
||||
lambda c: c.get_revision("dep-1", "rev-2"),
|
||||
"GET",
|
||||
"/v2/deployments/dep-1/revisions/rev-2",
|
||||
id="get_revision",
|
||||
),
|
||||
pytest.param(
|
||||
lambda c: c.get_build_logs("dep-1", "rev-2", {"limit": 10}),
|
||||
"POST",
|
||||
"/v1/projects/dep-1/revisions/rev-2/build_logs",
|
||||
id="build_logs",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_request_targets_control_plane_route_under_base_url(call, method, route):
|
||||
seen: dict = {}
|
||||
call(_routing_client(seen))
|
||||
assert (seen["method"], seen["url"]) == (
|
||||
method,
|
||||
f"https://api.example.com/prefix{route}",
|
||||
)
|
||||
|
||||
|
||||
def test_injected_transport_receives_requests_under_the_prefixed_base_url():
|
||||
seen: dict = {}
|
||||
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
seen["url"] = str(req.url)
|
||||
seen["api_key"] = req.headers["x-api-key"]
|
||||
return httpx.Response(200, json={"ok": True})
|
||||
|
||||
c = HostBackendClient(
|
||||
"https://smith.example.com/api-host",
|
||||
"key",
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
|
||||
assert c.list_revisions("dep-1", limit=2) == []
|
||||
assert seen == {
|
||||
"url": "https://smith.example.com/api-host/v2/deployments/dep-1/revisions?limit=2",
|
||||
"api_key": "key",
|
||||
}
|
||||
|
||||
|
||||
CLOUD = ("https://api.host.langchain.com", "https://smith.langchain.com")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("host_url", "langsmith_endpoint", "expected"),
|
||||
[
|
||||
pytest.param(None, None, CLOUD, id="nothing_configured_targets_cloud"),
|
||||
pytest.param(
|
||||
None, "https://api.smith.langchain.com", CLOUD, id="cloud_langsmith_api"
|
||||
),
|
||||
pytest.param(
|
||||
None,
|
||||
"https://api.smith.langchain.com/api/v1",
|
||||
CLOUD,
|
||||
id="cloud_langsmith_api_with_versioned_path",
|
||||
),
|
||||
pytest.param(
|
||||
None, "https://api.langchain.com", CLOUD, id="cloud_langchain_api_alias"
|
||||
),
|
||||
pytest.param(
|
||||
None,
|
||||
"https://xapi.smith.langchain.com",
|
||||
CLOUD,
|
||||
id="lookalike_cloud_host_is_not_rewritten_into_a_control_plane",
|
||||
),
|
||||
pytest.param(
|
||||
None,
|
||||
"https://eu.api.smith.langchain.com",
|
||||
("https://eu.api.host.langchain.com", "https://eu.smith.langchain.com"),
|
||||
id="eu_cloud_maps_to_eu_control_plane",
|
||||
),
|
||||
pytest.param(
|
||||
None,
|
||||
"https://dev.api.smith.langchain.com",
|
||||
("https://dev.api.host.langchain.com", "https://dev.smith.langchain.com"),
|
||||
id="dev_cloud_maps_to_dev_control_plane",
|
||||
),
|
||||
pytest.param(
|
||||
None,
|
||||
"https://aks.smith.langchain.dev/api",
|
||||
(
|
||||
"https://aks.smith.langchain.dev/api-host",
|
||||
"https://aks.smith.langchain.dev",
|
||||
),
|
||||
id="self_hosted_api_path_becomes_api_host",
|
||||
),
|
||||
pytest.param(
|
||||
None,
|
||||
"https://smith.example.com/api/v1",
|
||||
("https://smith.example.com/api-host", "https://smith.example.com"),
|
||||
id="self_hosted_versioned_api_path_becomes_api_host",
|
||||
),
|
||||
pytest.param(
|
||||
None,
|
||||
"https://smith.example.com",
|
||||
("https://smith.example.com/api-host", "https://smith.example.com"),
|
||||
id="self_hosted_origin_gets_api_host_appended",
|
||||
),
|
||||
pytest.param(
|
||||
None,
|
||||
"https://corp.example.com/langsmith/api/v1",
|
||||
(
|
||||
"https://corp.example.com/langsmith/api-host",
|
||||
"https://corp.example.com/langsmith",
|
||||
),
|
||||
id="self_hosted_path_prefix_is_kept",
|
||||
),
|
||||
pytest.param(
|
||||
"https://custom.host.example",
|
||||
"https://aks.smith.langchain.dev/api",
|
||||
("https://custom.host.example", "https://smith.langchain.com"),
|
||||
id="explicit_host_url_beats_langsmith_endpoint",
|
||||
),
|
||||
pytest.param(
|
||||
"https://api.host.langchain.com",
|
||||
"https://aks.smith.langchain.dev/api",
|
||||
CLOUD,
|
||||
id="explicit_cloud_host_url_beats_self_hosted_endpoint",
|
||||
),
|
||||
pytest.param(
|
||||
"https://smith.example.com/api-host/",
|
||||
None,
|
||||
("https://smith.example.com/api-host", "https://smith.example.com"),
|
||||
id="explicit_api_host_url_derives_dashboard_root",
|
||||
),
|
||||
pytest.param(
|
||||
"https://corp.example.com/langsmith/api-host",
|
||||
None,
|
||||
(
|
||||
"https://corp.example.com/langsmith/api-host",
|
||||
"https://corp.example.com/langsmith",
|
||||
),
|
||||
id="explicit_api_host_url_keeps_path_prefix_in_dashboard",
|
||||
),
|
||||
pytest.param(
|
||||
"http://localhost:8080",
|
||||
None,
|
||||
("http://localhost:8080", "http://localhost:8080"),
|
||||
id="localhost_dashboard_is_the_same_origin",
|
||||
),
|
||||
pytest.param(
|
||||
"http://localhost:8080/api-host",
|
||||
None,
|
||||
("http://localhost:8080/api-host", "http://localhost:8080"),
|
||||
id="localhost_api_host_dashboard_is_the_origin",
|
||||
),
|
||||
pytest.param(
|
||||
"https://eu.api.host.langchain.com",
|
||||
None,
|
||||
("https://eu.api.host.langchain.com", "https://eu.smith.langchain.com"),
|
||||
id="regional_control_plane_maps_to_regional_dashboard",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_control_plane_endpoints_resolve(host_url, langsmith_endpoint, expected):
|
||||
endpoints = ControlPlaneEndpoints.resolve(host_url, langsmith_endpoint)
|
||||
|
||||
assert (endpoints.control_plane_url, endpoints.dashboard_url) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("payload", "expected"),
|
||||
[
|
||||
pytest.param(
|
||||
{"resources": [{"id": "a"}, {"id": "b"}]},
|
||||
[{"id": "a"}, {"id": "b"}],
|
||||
id="list_returns_the_resources",
|
||||
),
|
||||
pytest.param({"resources": []}, [], id="empty_list"),
|
||||
pytest.param({}, [], id="missing_key"),
|
||||
pytest.param({"resources": None}, [], id="null_resources"),
|
||||
pytest.param(
|
||||
{"resources": ["nope", {"id": "a"}]}, [{"id": "a"}], id="skips_non_objects"
|
||||
),
|
||||
pytest.param([], [], id="unexpected_envelope"),
|
||||
],
|
||||
)
|
||||
def test_list_endpoints_return_resource_objects(payload, expected):
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json=payload)
|
||||
|
||||
c = HostBackendClient(
|
||||
"https://api.example.com", "key", transport=httpx.MockTransport(handler)
|
||||
)
|
||||
|
||||
assert c.list_deployments() == expected
|
||||
|
||||
|
||||
def test_list_listeners_asks_for_a_full_page():
|
||||
seen: dict = {}
|
||||
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
seen["url"] = str(req.url)
|
||||
return httpx.Response(200, json={"resources": [{"id": "listener-1"}]})
|
||||
|
||||
c = HostBackendClient(
|
||||
"https://api.example.com", "key", transport=httpx.MockTransport(handler)
|
||||
)
|
||||
|
||||
assert c.list_listeners() == [{"id": "listener-1"}]
|
||||
assert seen["url"] == "https://api.example.com/v2/listeners?limit=100"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("control_plane_url", "expected"),
|
||||
[
|
||||
pytest.param("https://api.host.langchain.com", True, id="cloud"),
|
||||
pytest.param("https://eu.api.host.langchain.com", True, id="cloud_region"),
|
||||
pytest.param("https://dev.api.host.langchain.com", True, id="cloud_dev"),
|
||||
pytest.param("https://smith.example.com/api-host", False, id="self_hosted"),
|
||||
pytest.param(
|
||||
"https://corp.example.com/langsmith/api-host",
|
||||
False,
|
||||
id="self_hosted_prefix",
|
||||
),
|
||||
pytest.param("http://localhost:8080/api-host", False, id="local"),
|
||||
pytest.param(
|
||||
"https://evil-api.host.langchain.com", False, id="lookalike_needs_a_dot"
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_is_cloud_recognises_the_managed_control_plane(control_plane_url, expected):
|
||||
endpoints = ControlPlaneEndpoints.from_control_plane_url(control_plane_url)
|
||||
|
||||
assert endpoints.is_cloud is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("call", "expected_params"),
|
||||
[
|
||||
pytest.param(
|
||||
lambda c: c.list_deployments(name="agent"),
|
||||
{"name": "agent"},
|
||||
id="exact_name_filters_server_side",
|
||||
),
|
||||
pytest.param(
|
||||
lambda c: c.list_deployments(name_contains="age"),
|
||||
{"name_contains": "age"},
|
||||
id="substring_search_keeps_its_own_parameter",
|
||||
),
|
||||
pytest.param(
|
||||
lambda c: c.list_deployments(),
|
||||
{},
|
||||
id="no_filter_sends_no_parameters",
|
||||
),
|
||||
pytest.param(
|
||||
lambda c: c.list_deployments(
|
||||
name="agent", name_contains="agent", limit=100
|
||||
),
|
||||
{"name": "agent", "name_contains": "agent", "limit": "100"},
|
||||
id="both_filters_travel_together_for_older_servers",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_list_deployments_sends_one_name_filter(call, expected_params):
|
||||
seen: dict = {}
|
||||
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
seen.update(dict(req.url.params))
|
||||
return httpx.Response(200, json={"resources": []})
|
||||
|
||||
call(
|
||||
HostBackendClient(
|
||||
"https://api.example.com", "key", transport=httpx.MockTransport(handler)
|
||||
)
|
||||
)
|
||||
|
||||
assert seen == expected_params
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("body", "expected"),
|
||||
[
|
||||
pytest.param(
|
||||
{"detail": "Source configuration error: bad listener"},
|
||||
"Source configuration error: bad listener",
|
||||
id="fastapi_detail_is_unwrapped",
|
||||
),
|
||||
pytest.param(
|
||||
{"detail": {"loc": ["body"], "msg": "nope"}},
|
||||
None,
|
||||
id="a_structured_detail_is_left_alone",
|
||||
),
|
||||
pytest.param({"other": "shape"}, None, id="an_unknown_shape_is_left_alone"),
|
||||
],
|
||||
)
|
||||
def test_error_detail_is_readable(body, expected):
|
||||
c = HostBackendClient(
|
||||
"https://api.example.com",
|
||||
"key",
|
||||
transport=httpx.MockTransport(lambda req: httpx.Response(400, json=body)),
|
||||
)
|
||||
|
||||
with pytest.raises(HostBackendError) as error:
|
||||
c.get_deployment("dep-1")
|
||||
|
||||
assert error.value.detail == expected
|
||||
if expected is not None:
|
||||
assert error.value.message.endswith(expected)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user