mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-08-25 09:02:25 +02:00
Compare commits
53
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ae01c7366 | ||
|
|
37014c09b9 | ||
|
|
8c0e498eaf | ||
|
|
5657ecd0bf | ||
|
|
127c6c2c7b | ||
|
|
0ae8870432 | ||
|
|
60e305596d | ||
|
|
125d10052c | ||
|
|
0a53c385b2 | ||
|
|
35cea28707 | ||
|
|
3fff7bc928 | ||
|
|
a0c4bdc3cb | ||
|
|
0d0324ad3a | ||
|
|
8593efe0ad | ||
|
|
402d767229 | ||
|
|
95541cbf08 | ||
|
|
30a1251dcc | ||
|
|
1a80cb3eab | ||
|
|
60135677dd | ||
|
|
4e833ceabe | ||
|
|
aa2a370fe4 | ||
|
|
67cd5b7718 | ||
|
|
b82d380634 | ||
|
|
afdfbd55da | ||
|
|
15113c0f60 | ||
|
|
85bca24635 | ||
|
|
f2bd3224f0 | ||
|
|
530fcabfc3 | ||
|
|
d8b7800183 | ||
|
|
c8c58a0768 | ||
|
|
de9b7c61c3 | ||
|
|
63d861165f | ||
|
|
9c1d65695e | ||
|
|
40ab009c62 | ||
|
|
f95d2309f9 | ||
|
|
4a5765dd23 | ||
|
|
5c18bde0f8 | ||
|
|
a48a045596 | ||
|
|
168674dd2a | ||
|
|
800071d0d4 | ||
|
|
3eb73e8ad2 | ||
|
|
08666353fc | ||
|
|
5af4c5addf | ||
|
|
f4388df77f | ||
|
|
521b4842d3 | ||
|
|
cb328b57f1 | ||
|
|
d177a0db43 | ||
|
|
372d54dc4f | ||
|
|
f4aee546ad | ||
|
|
85cd64ed69 | ||
|
|
53a9806e65 | ||
|
|
219fbbe8d0 | ||
|
|
aeff9549c2 |
@@ -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@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
if: ${{ !github.event.pull_request.head.repo.fork }}
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
cache-suffix: "test-langgraph"
|
||||
working-directory: libs/langgraph
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
if: ${{ !github.event.pull_request.head.repo.fork }}
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
path: ${{ inputs.working-directory }}/dist/
|
||||
|
||||
- name: Publish to test PyPI
|
||||
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # release/v1
|
||||
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
|
||||
with:
|
||||
packages-dir: ${{ inputs.working-directory }}/dist/
|
||||
verbose: true
|
||||
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
echo EOF
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
- name: Annotation
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const file = JSON.parse(`${{ steps.files.outputs.added_modified_renamed }}`)[0]
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
uses: actions/configure-pages@v6
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
uses: actions/upload-pages-artifact@v5
|
||||
with:
|
||||
path: 'docs/_site'
|
||||
|
||||
|
||||
@@ -276,7 +276,7 @@ jobs:
|
||||
path: ${{ inputs.working-directory }}/dist/
|
||||
|
||||
- name: Publish package distributions to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # release/v1
|
||||
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
|
||||
with:
|
||||
packages-dir: ${{ inputs.working-directory }}/dist/
|
||||
verbose: true
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Find and reopen matching PRs
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
steps:
|
||||
- name: Check for issue link and assignee
|
||||
id: check-link
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
@@ -283,7 +283,7 @@ jobs:
|
||||
if: >-
|
||||
env.ENFORCE_ISSUE_LINK == 'true' &&
|
||||
(steps.check-link.outputs.has-link != 'true' || steps.check-link.outputs.is-assigned != 'true')
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
@@ -312,7 +312,7 @@ jobs:
|
||||
if: >-
|
||||
env.ENFORCE_ISSUE_LINK == 'true' &&
|
||||
steps.check-link.outputs.has-link == 'true' && steps.check-link.outputs.is-assigned == 'true'
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
@@ -366,7 +366,7 @@ jobs:
|
||||
if: >-
|
||||
env.ENFORCE_ISSUE_LINK == 'true' &&
|
||||
(steps.check-link.outputs.has-link != 'true' || steps.check-link.outputs.is-assigned != 'true')
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
- name: Check if contributor is external
|
||||
if: steps.app-token.outcome == 'success'
|
||||
id: check-membership
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
@@ -102,7 +102,7 @@ jobs:
|
||||
|
||||
- name: Apply contributor tier label
|
||||
if: steps.check-membership.outputs.is-external == 'true'
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
@@ -155,7 +155,7 @@ jobs:
|
||||
|
||||
- name: Add external label
|
||||
if: steps.check-membership.outputs.is-external == 'true'
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
@@ -168,7 +168,7 @@ jobs:
|
||||
|
||||
- name: Add internal label
|
||||
if: steps.check-membership.outputs.is-external == 'false'
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
@@ -196,7 +196,7 @@ jobs:
|
||||
private-key: ${{ secrets.ORG_MEMBERSHIP_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Backfill labels
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
- name: Check if contributor is external
|
||||
if: steps.app-token.outcome == 'success'
|
||||
id: check-membership
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
@@ -90,7 +90,7 @@ jobs:
|
||||
# event fires and triggers require_issue_link.yml.
|
||||
- name: Apply contributor tier label
|
||||
if: steps.check-membership.outputs.is-external == 'true'
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
# Use App token so the "labeled" event propagates to downstream
|
||||
# workflows (e.g. require_issue_link.yml).
|
||||
@@ -145,7 +145,7 @@ jobs:
|
||||
|
||||
- name: Add external label
|
||||
if: steps.check-membership.outputs.is-external == 'true'
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
# Use App token so the "labeled" event propagates to downstream
|
||||
# workflows (e.g. require_issue_link.yml). Events created by the
|
||||
@@ -161,7 +161,7 @@ jobs:
|
||||
|
||||
- name: Add internal label
|
||||
if: steps.check-membership.outputs.is-external == 'false'
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
run: make lock-upgrade
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
commit-message: "chore(deps): upgrade dependencies with `uv lock --upgrade`"
|
||||
|
||||
@@ -100,3 +100,4 @@ dmypy.json
|
||||
.turbo
|
||||
.editorconfig
|
||||
.scratch
|
||||
.worktrees/
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<a href="https://opensource.org/licenses/MIT" target="_blank"><img src="https://img.shields.io/pypi/l/langgraph" alt="PyPI - License"></a>
|
||||
<a href="https://pypistats.org/packages/langgraph" target="_blank"><img src="https://img.shields.io/pepy/dt/langgraph" alt="PyPI - Downloads"></a>
|
||||
<a href="https://pypi.org/project/langgraph/" target="_blank"><img src="https://img.shields.io/pypi/v/langgraph.svg?label=%20" alt="Version"></a>
|
||||
<a href="https://x.com/langchain" target="_blank"><img src="https://img.shields.io/twitter/url/https/twitter.com/langchain.svg?style=social&label=Follow%20%40LangChain" alt="Twitter / X"></a>
|
||||
<a href="https://x.com/langchain_oss" target="_blank"><img src="https://img.shields.io/twitter/url/https/twitter.com/langchain_oss.svg?style=social&label=Follow%20%40LangChain" alt="Twitter / X"></a>
|
||||
</div>
|
||||
|
||||
<br>
|
||||
@@ -27,10 +27,10 @@ Trusted by companies shaping the future of agents – including Klarna, Replit,
|
||||
pip install -U langgraph
|
||||
```
|
||||
|
||||
If you're looking to quickly build agents with LangChain's `create_agent` (built on LangGraph), check out the [LangChain Agents documentation](https://docs.langchain.com/oss/python/langchain/agents).
|
||||
> [!TIP]
|
||||
> If you're looking to quickly build agents, check out **[Deep Agents](https://docs.langchain.com/oss/python/deepagents/overview)** — a higher-level package built on LangGraph for agents that can plan, use subagents, and leverage file systems for complex tasks.
|
||||
|
||||
> [!NOTE]
|
||||
> Looking for the JS/TS library? Check out [LangGraph.js](https://github.com/langchain-ai/langgraphjs) and the [JS docs](https://docs.langchain.com/oss/javascript/langgraph/overview).
|
||||
For an equivalent JS/TS library, check out [LangGraph.js](https://github.com/langchain-ai/langgraphjs) and the [JS docs](https://docs.langchain.com/oss/javascript/langgraph/overview).
|
||||
|
||||
## Why use LangGraph?
|
||||
|
||||
@@ -51,7 +51,7 @@ While LangGraph can be used standalone, it also integrates seamlessly with any L
|
||||
|
||||
To improve your LLM application development, pair LangGraph with:
|
||||
|
||||
- [Deep Agents](https://github.com/langchain-ai/deepagents) *(new!)* – Build agents that can plan, use subagents, and leverage file systems for complex tasks.
|
||||
- [Deep Agents](https://docs.langchain.com/oss/python/deepagents/overview) – Build agents that can plan, use subagents, and leverage file systems for complex tasks.
|
||||
- [LangChain](https://docs.langchain.com/oss/python/langchain/overview) – Provides integrations and composable components to streamline LLM application development.
|
||||
- [LangSmith](https://www.langchain.com/langsmith) – Helpful for agent evals and observability. Debug poor-performing LLM app runs, evaluate agent trajectories, gain visibility in production, and improve performance over time.
|
||||
- [LangSmith Deployment](https://docs.langchain.com/langsmith/deployments) – Deploy and scale agents effortlessly with a purpose-built deployment platform for long-running, stateful workflows. Discover, reuse, configure, and share agents across teams – and iterate quickly with visual prototyping in [LangSmith Studio](https://docs.langchain.com/langsmith/studio).
|
||||
|
||||
Generated
+40
-40
@@ -250,15 +250,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-checkpoint"
|
||||
version = "4.0.1"
|
||||
version = "4.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
{ name = "ormsgpack" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/44/a8df45d1e8b4637e29789fa8bae1db022c953cc7ac80093cfc52e923547e/langgraph_checkpoint-4.0.1.tar.gz", hash = "sha256:b433123735df11ade28829e40ce25b9be614930cd50245ff2af60629234befd9", size = 158135, upload-time = "2026-02-27T21:06:16.092Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7c/e1/885e49cdafceb4c74dae4573bc5dd6054c6c640382ee73104532f33dca46/langgraph_checkpoint-4.0.3.tar.gz", hash = "sha256:a7b5e2ca18fb79b55edf19396d4ee446f8a53dcb7a4ec62ce6f1c7e00bb5af7f", size = 174009, upload-time = "2026-04-27T14:34:02.777Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/65/4c/09a4a0c42f5d2fc38d6c4d67884788eff7fd2cfdf367fdf7033de908b4c0/langgraph_checkpoint-4.0.1-py3-none-any.whl", hash = "sha256:e3adcd7a0e0166f3b48b8cf508ce0ea366e7420b5a73aa81289888727769b034", size = 50453, upload-time = "2026-02-27T21:06:14.293Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/ee/ecd3fa2e893746dde3b768daca2a4935208bc77d09445437ccfffb4a8c9b/langgraph_checkpoint-4.0.3-py3-none-any.whl", hash = "sha256:b91b765712a2311a5b198760f714b7ab9b376d01c047ed78d9b9a3e80df802a3", size = 51682, upload-time = "2026-04-27T14:34:01.51Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -746,27 +746,27 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.8"
|
||||
version = "0.15.12"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/14/b0/73cf7550861e2b4824950b8b52eebdcc5adc792a00c514406556c5b80817/ruff-0.15.8.tar.gz", hash = "sha256:995f11f63597ee362130d1d5a327a87cb6f3f5eae3094c620bcc632329a4d26e", size = 4610921, upload-time = "2026-03-26T18:39:38.675Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/92/c445b0cd6da6e7ae51e954939cb69f97e008dbe750cfca89b8cedc081be7/ruff-0.15.8-py3-none-linux_armv6l.whl", hash = "sha256:cbe05adeba76d58162762d6b239c9056f1a15a55bd4b346cfd21e26cd6ad7bc7", size = 10527394, upload-time = "2026-03-26T18:39:41.566Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/92/f1c662784d149ad1414cae450b082cf736430c12ca78367f20f5ed569d65/ruff-0.15.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d3e3d0b6ba8dca1b7ef9ab80a28e840a20070c4b62e56d675c24f366ef330570", size = 10905693, upload-time = "2026-03-26T18:39:30.364Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/f2/7a631a8af6d88bcef997eb1bf87cc3da158294c57044aafd3e17030613de/ruff-0.15.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6ee3ae5c65a42f273f126686353f2e08ff29927b7b7e203b711514370d500de3", size = 10323044, upload-time = "2026-03-26T18:39:33.37Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/18/1bf38e20914a05e72ef3b9569b1d5c70a7ef26cd188d69e9ca8ef588d5bf/ruff-0.15.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdce027ada77baa448077ccc6ebb2fa9c3c62fd110d8659d601cf2f475858d94", size = 10629135, upload-time = "2026-03-26T18:39:44.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/e9/138c150ff9af60556121623d41aba18b7b57d95ac032e177b6a53789d279/ruff-0.15.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12e617fc01a95e5821648a6df341d80456bd627bfab8a829f7cfc26a14a4b4a3", size = 10348041, upload-time = "2026-03-26T18:39:52.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/f1/5bfb9298d9c323f842c5ddeb85f1f10ef51516ac7a34ba446c9347d898df/ruff-0.15.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:432701303b26416d22ba696c39f2c6f12499b89093b61360abc34bcc9bf07762", size = 11121987, upload-time = "2026-03-26T18:39:55.195Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/11/6da2e538704e753c04e8d86b1fc55712fdbdcc266af1a1ece7a51fff0d10/ruff-0.15.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d910ae974b7a06a33a057cb87d2a10792a3b2b3b35e33d2699fdf63ec8f6b17a", size = 11951057, upload-time = "2026-03-26T18:39:19.18Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/f0/c9208c5fd5101bf87002fed774ff25a96eea313d305f1e5d5744698dc314/ruff-0.15.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2033f963c43949d51e6fdccd3946633c6b37c484f5f98c3035f49c27395a8ab8", size = 11464613, upload-time = "2026-03-26T18:40:06.301Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/22/d7f2fabdba4fae9f3b570e5605d5eb4500dcb7b770d3217dca4428484b17/ruff-0.15.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f29b989a55572fb885b77464cf24af05500806ab4edf9a0fd8977f9759d85b1", size = 11257557, upload-time = "2026-03-26T18:39:57.972Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/8c/382a9620038cf6906446b23ce8632ab8c0811b8f9d3e764f58bedd0c9a6f/ruff-0.15.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ac51d486bf457cdc985a412fb1801b2dfd1bd8838372fc55de64b1510eff4bec", size = 11169440, upload-time = "2026-03-26T18:39:22.205Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/0d/0994c802a7eaaf99380085e4e40c845f8e32a562e20a38ec06174b52ef24/ruff-0.15.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c9861eb959edab053c10ad62c278835ee69ca527b6dcd72b47d5c1e5648964f6", size = 10605963, upload-time = "2026-03-26T18:39:46.682Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/aa/d624b86f5b0aad7cef6bbf9cd47a6a02dfdc4f72c92a337d724e39c9d14b/ruff-0.15.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8d9a5b8ea13f26ae90838afc33f91b547e61b794865374f114f349e9036835fb", size = 10357484, upload-time = "2026-03-26T18:39:49.176Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/c3/e0b7835d23001f7d999f3895c6b569927c4d39912286897f625736e1fd04/ruff-0.15.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c2a33a529fb3cbc23a7124b5c6ff121e4d6228029cba374777bd7649cc8598b8", size = 10830426, upload-time = "2026-03-26T18:40:03.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/51/ab20b322f637b369383adc341d761eaaa0f0203d6b9a7421cd6e783d81b9/ruff-0.15.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:75e5cd06b1cf3f47a3996cfc999226b19aa92e7cce682dcd62f80d7035f98f49", size = 11345125, upload-time = "2026-03-26T18:39:27.799Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/e6/90b2b33419f59d0f2c4c8a48a4b74b460709a557e8e0064cf33ad894f983/ruff-0.15.8-py3-none-win32.whl", hash = "sha256:bc1f0a51254ba21767bfa9a8b5013ca8149dcf38092e6a9eb704d876de94dc34", size = 10571959, upload-time = "2026-03-26T18:39:36.117Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/a2/ef467cb77099062317154c63f234b8a7baf7cb690b99af760c5b68b9ee7f/ruff-0.15.8-py3-none-win_amd64.whl", hash = "sha256:04f79eff02a72db209d47d665ba7ebcad609d8918a134f86cb13dd132159fc89", size = 11743893, upload-time = "2026-03-26T18:39:25.01Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/e2/77be4fff062fa78d9b2a4dea85d14785dac5f1d0c1fb58ed52331f0ebe28/ruff-0.15.8-py3-none-win_arm64.whl", hash = "sha256:cf891fa8e3bb430c0e7fac93851a5978fc99c8fa2c053b57b118972866f8e5f2", size = 11048175, upload-time = "2026-03-26T18:40:01.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -834,26 +834,26 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ty"
|
||||
version = "0.0.27"
|
||||
version = "0.0.33"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f4/de/e5cf1f151cf52fe1189e42d03d90909d7d1354fdc0c1847cbb63a0baa3da/ty-0.0.27.tar.gz", hash = "sha256:d7a8de3421d92420b40c94fe7e7d4816037560621903964dd035cf9bd0204a73", size = 5424130, upload-time = "2026-03-31T19:07:20.806Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/84/44/9478c50c266826c1bf30d1692e589755bffa8f1c0a3eb7af8a346c255991/ty-0.0.33.tar.gz", hash = "sha256:46d63bda07403322cb6c28ccfdd5536be916e13df725c29f7ccd0a21f06bd9e8", size = 5559373, upload-time = "2026-04-28T10:45:13.18Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/20/2a9ea661758bd67f2bfd54ce9daacb5a26c56c5f8b49fbd9a43b365a8a7d/ty-0.0.27-py3-none-linux_armv6l.whl", hash = "sha256:eb14456b8611c9e8287aa9b633f4d2a0d9f3082a31796969e0b50bdda8930281", size = 10571211, upload-time = "2026-03-31T19:07:23.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/b2/8887a51f705d075ddbe78ae7f0d4755ef48d0a90235f67aee289e9cee950/ty-0.0.27-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:02e662184703db7586118df611cf24a000d35dae38d950053d1dd7b6736fd2c4", size = 10427576, upload-time = "2026-03-31T19:07:15.499Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/c3/79d88163f508fb709ce19bc0b0a66c7c64b53d372d4caa56172c3d9b3ae8/ty-0.0.27-py3-none-macosx_11_0_arm64.whl", hash = "sha256:be5fc2899441f7f8f7ef40f9ffd006075a5ff6b06c44e8d2aa30e1b900c12f51", size = 9870359, upload-time = "2026-03-31T19:07:36.852Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/4d/ed1b0db0e1e46b5ed4976bbfe0d1825faf003b4e3774ef28c785ed73e4bb/ty-0.0.27-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30231e652b14742a76b64755e54bf0cb1cd4c128bcaf625222e0ca92a2094887", size = 10380488, upload-time = "2026-03-31T19:07:31.268Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/f2/20372f6d510b01570028433064880adec2f8abe68bf0c4603be61a560bef/ty-0.0.27-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5a119b1168f64261b3205a37e40b5b6c4aac8fd58e4587988f4e4b22c3c79847", size = 10390248, upload-time = "2026-03-31T19:07:28.345Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/4b/46b31a7311306be1a560f7f20fdc37b5bf718787f60626cd265d9b637554/ty-0.0.27-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e38f4e187b6975d2cbebf0f1eb1221f8f64f6e509bad14d7bb2a91afc97e4956", size = 10878479, upload-time = "2026-03-31T19:07:39.393Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/ba/5231a2a1fb1cebe053a25de8fded95e1a30a1e77d3628a9e58487297bafc/ty-0.0.27-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a07b1a8fbb23844f6d22091275430d9ac617175f34aa99159b268193de210389", size = 11461232, upload-time = "2026-03-31T19:07:02.518Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/37/558abab3e1f6670493524f61280b4dfcc3219555f13889223e733381dfab/ty-0.0.27-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d3ec4033031f240836bb0337274bac5c49dde312c7c6d7575451ed719bf8ffa3", size = 11133002, upload-time = "2026-03-31T19:07:18.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/38/188c14a57f52160407ce62c6abb556011718fd0bcbe1dca690529ce84c46/ty-0.0.27-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:924a8849afd500d260bf5b7296165a05b7424fbb6b19113f30f3b999d682873f", size = 10986624, upload-time = "2026-03-31T19:07:13.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/f1/667a71393f47d2cd6ba9ed07541b8df3eb63aab1f2ee658e77d91b8362fa/ty-0.0.27-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d8270026c07e7423a1b3a3fd065b46ed1478748f0662518b523b57744f3fa025", size = 10366721, upload-time = "2026-03-31T19:07:00.131Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/aa/8edafe41be898bda774249abc5be6edd733e53fb1777d59ea9331e38537d/ty-0.0.27-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e26e9735d3bdfd95d881111ad1cf570eab8188d8c3be36d6bcaad044d38984d8", size = 10412239, upload-time = "2026-03-31T19:07:05.297Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/ff/8bafaed4a18d38264f46bdfc427de7ea2974cf9064e4e0bdb1b6e6c724e3/ty-0.0.27-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7c09cc9a699810609acc0090af8d0db68adaee6e60a7c3e05ab80cc954a83db7", size = 10573507, upload-time = "2026-03-31T19:06:57.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/2e/63a8284a2fefd08ab56ecbad0fde7dd4b2d4045a31cf24c1d1fcd9643227/ty-0.0.27-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:2d3e02853bb037221a456e034b1898aaa573e6374fbb53884e33cb7513ccb85a", size = 11090233, upload-time = "2026-03-31T19:07:34.139Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/d3/d6fa1cafdfa2b34dbfa304fc6833af8e1669fc34e24d214fa76d2a2e5a25/ty-0.0.27-py3-none-win32.whl", hash = "sha256:34e7377f2047c14dbbb7bf5322e84114db7a5f2cb470db6bee63f8f3550cfc1e", size = 9984415, upload-time = "2026-03-31T19:07:07.98Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/e6/dd4e27da9632b3472d5711ca49dbd3709dbd3e8c73f3af6db9c254235ca9/ty-0.0.27-py3-none-win_amd64.whl", hash = "sha256:3f7e4145aad8b815ed69b324c93b5b773eb864dda366ca16ab8693ff88ce6f36", size = 10961535, upload-time = "2026-03-31T19:07:10.566Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/1a/824b3496d66852ed7d5d68d9787711131552b68dce8835ce9410db32e618/ty-0.0.27-py3-none-win_arm64.whl", hash = "sha256:95bf8d01eb96bb2ba3ffc39faff19da595176448e80871a7b362f4d2de58476c", size = 10376689, upload-time = "2026-03-31T19:07:25.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/24/e287388c63a19191be26b32ff4dbd06029834068150ebe2532939bc4c851/ty-0.0.33-py3-none-linux_armv6l.whl", hash = "sha256:94d0a9d2234261a8911396d59e506b5923fe0971dbda43b9dcea287936887fcc", size = 11021308, upload-time = "2026-04-28T10:45:43.34Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/ca/ba1eed819895bd239fba8ee35dfcd5fcb266c203b0914a17a59579096bb5/ty-0.0.33-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e4a2b5ba078f90de342f56b5f7979bb77c9b9b1d8625a041352ffc6ee93c4073", size = 10777272, upload-time = "2026-04-28T10:45:32.905Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/a8/c3131d37b44b3fea1d6654a1c929a0cd0873822f77a90482b8ec28f6fbbd/ty-0.0.33-py3-none-macosx_11_0_arm64.whl", hash = "sha256:84ff5707825e9af9668d2bcf66975f93e520a63b524ab494e3a8265735be2563", size = 10201078, upload-time = "2026-04-28T10:45:23.374Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/db/d8e37ff0045810cc65e1ff36aa0da0a2253c05659787ac987df8a16c7897/ty-0.0.33-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e375285736f57886868e7af0b11c7b0ec5b6543fa15e7ad2a714fed9f077d4e0", size = 10732347, upload-time = "2026-04-28T10:45:21.444Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/1a/20e83a412506a918e4684fc67b567cf7cc13b105470b3428cb23c3d5aa13/ty-0.0.33-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5680f6350c3b4e46b8bff6d7bb132366ea239463d6cad4892725d06046e65464", size = 10808238, upload-time = "2026-04-28T10:45:38.565Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/4b/d0a39f4464dc6cb4cc2c159473ce216bd1846bfb684c0323a3cb36dce5c6/ty-0.0.33-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5535538bad8d0f7e62bcdff02197cdb30e41451d80b35d27e17d128f2e1dc5d", size = 11288348, upload-time = "2026-04-28T10:45:08.419Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/7e/f1745e0f9583363d7a83d9a4990fc244f76ecc30840ddad83dc16a33c52d/ty-0.0.33-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:da196c42bbbc069e1e21e3e52107c061aa9660352dae57a41930690b56e2c02d", size = 11789907, upload-time = "2026-04-28T10:45:19.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/71/25f39f46a12d662859d45bc648555d0661044eb43db6b5648c9947487da9/ty-0.0.33-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9281672921ef6d4460e03146b5e6c18cb1a3e3a3b8a1a88f6f33226d05a469b7", size = 11500774, upload-time = "2026-04-28T10:45:48.012Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/ec/136959ecbb7c71cb90537f5aea441c73f4ab24612868a6ecdc9d7444d32d/ty-0.0.33-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82c1b8f303f82da64e878108e764be3ecbcd7c9903ac0a7f7031614ed00b97ab", size = 11360314, upload-time = "2026-04-28T10:45:05.402Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/95/32809575c222f00beed498cb728e9290a0f5009f930025381bb7253b2206/ty-0.0.33-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:efe3af412c9ff67bce5fa37d0a2b0d8555c24072b145a5bac6c79637f1c83abe", size = 10707785, upload-time = "2026-04-28T10:45:10.836Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/89/c8e9531f7aa4a093359e15fa32c8e1277fbbe90d16894d7c6032d29f4b34/ty-0.0.33-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:aeec29c91ea768601747da546c3efc20b72c2fb1bd52bcc786a5c6eeff51d27b", size = 10834987, upload-time = "2026-04-28T10:45:40.738Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/16/9835fbcf5338af1a1917bd28fdb8a7193c210b83f243aa286fa9f79cb3ad/ty-0.0.33-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a535977c52bbb5f7e96b8b70a6ad375ad077f4a9ff2492508ea3816a2b403819", size = 10968968, upload-time = "2026-04-28T10:45:30.26Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/69/64c76aabc1bc70c7f24b686cd93c3407f8ea430905e395f59bf9603ef571/ty-0.0.33-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1d732facf39fcb221ba279d469c5040d37883e964f123b1563888efd34818180", size = 11458077, upload-time = "2026-04-28T10:45:45.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/84/fae27b0c4718776a298690d31ca4cc1995f2e3e1c63a7b59e84c41498e9a/ty-0.0.33-py3-none-win32.whl", hash = "sha256:d90960b574428dc252f85e8598ec5fcb7f619794196b2fc95a90da075ed4681c", size = 10345364, upload-time = "2026-04-28T10:45:16.836Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/a0/a2938b23ae3e1a09a2d7c189e2ac5f7113676bae4e0e23948b568e18e5f8/ty-0.0.33-py3-none-win_amd64.whl", hash = "sha256:c1c3aec62c44de610c6e95f0a4e97ac3dbc07934bfdbf1fd90d758c9ff72f48e", size = 11342470, upload-time = "2026-04-28T10:45:26.455Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/62/7fb948aace38d2f6329261bb33c035a8484549c74f1db28649c7a4c6fed9/ty-0.0.33-py3-none-win_arm64.whl", hash = "sha256:0d44f99ba1b441e55e2aa301b2ac0a21112784931b46a5f66f4ea9efe5620d97", size = 10742673, upload-time = "2026-04-28T10:45:35.555Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -2,9 +2,9 @@ from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from collections import defaultdict
|
||||
from collections.abc import Iterator, Sequence
|
||||
from collections.abc import Iterator, Mapping, Sequence
|
||||
from contextlib import contextmanager
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from langgraph.checkpoint.base import (
|
||||
@@ -13,17 +13,25 @@ from langgraph.checkpoint.base import (
|
||||
Checkpoint,
|
||||
CheckpointMetadata,
|
||||
CheckpointTuple,
|
||||
DeltaChannelHistory,
|
||||
get_checkpoint_id,
|
||||
get_serializable_checkpoint_metadata,
|
||||
)
|
||||
from langgraph.checkpoint.serde.base import SerializerProtocol
|
||||
from langgraph.checkpoint.serde.types import _DeltaSnapshot
|
||||
from psycopg import Capabilities, Connection, Cursor, Pipeline
|
||||
from psycopg.rows import DictRow, dict_row
|
||||
from psycopg.types.json import Jsonb
|
||||
from psycopg_pool import ConnectionPool
|
||||
|
||||
from langgraph.checkpoint.postgres import _internal
|
||||
from langgraph.checkpoint.postgres.base import BasePostgresSaver
|
||||
from langgraph.checkpoint.postgres.base import (
|
||||
_DELTA_PAGE_SIZE,
|
||||
BasePostgresSaver,
|
||||
_build_delta_stage1_sql,
|
||||
_build_delta_stage2_sql,
|
||||
_DeltaStage2Row,
|
||||
)
|
||||
from langgraph.checkpoint.postgres.shallow import ShallowPostgresSaver
|
||||
|
||||
Conn = _internal.Conn # For backward compatibility
|
||||
@@ -302,7 +310,10 @@ class PostgresSaver(BasePostgresSaver):
|
||||
# others are stored in blobs table
|
||||
blob_values = {}
|
||||
for k, v in checkpoint["channel_values"].items():
|
||||
if v is None or isinstance(v, (str, int, float, bool)):
|
||||
if isinstance(v, _DeltaSnapshot):
|
||||
blob_values[k] = copy["channel_values"].pop(k)
|
||||
copy["channel_values"][k] = True
|
||||
elif v is None or isinstance(v, (str, int, float, bool)):
|
||||
pass
|
||||
else:
|
||||
blob_values[k] = copy["channel_values"].pop(k)
|
||||
@@ -430,6 +441,114 @@ class PostgresSaver(BasePostgresSaver):
|
||||
with conn.cursor(binary=True, row_factory=dict_row) as cur:
|
||||
yield cur
|
||||
|
||||
def get_delta_channel_history(
|
||||
self, *, config: RunnableConfig, channels: Sequence[str]
|
||||
) -> Mapping[str, DeltaChannelHistory]:
|
||||
"""Fast-path override of `BaseCheckpointSaver.get_delta_channel_history`.
|
||||
|
||||
Two-stage query, both stages cover ALL requested channels:
|
||||
|
||||
* Stage 1 (paged): dynamic SELECT over `checkpoints` with K parallel
|
||||
JSONB key lookups (one column pair per channel) — no subquery, no
|
||||
aggregation. Pages newest-first by `checkpoint_id` with a cursor;
|
||||
page size is `_DELTA_PAGE_SIZE`. Stops paging when every channel
|
||||
has found its seed or the chain is exhausted.
|
||||
|
||||
* 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"]
|
||||
checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
|
||||
checkpoint_id = get_checkpoint_id(config)
|
||||
if checkpoint_id is None:
|
||||
target = self.get_tuple(config)
|
||||
if target is None:
|
||||
return {ch: {"writes": []} for ch in channels}
|
||||
checkpoint_id = target.config["configurable"]["checkpoint_id"]
|
||||
|
||||
# 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)
|
||||
parent_of: dict[str, str | None] = {}
|
||||
ver_by_i_by_cid: list[dict[str, str | None]] = [{} for _ in channels]
|
||||
hs_by_i_by_cid: list[dict[str, bool]] = [{} 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}
|
||||
walk_cursor_by_ch: dict[str, str | None] = {}
|
||||
seeded: set[str] = set()
|
||||
cursor: str | None = None
|
||||
|
||||
with self._cursor() as cur:
|
||||
while True:
|
||||
stage1_params: list[Any] = []
|
||||
for ch in channels:
|
||||
stage1_params.extend([ch, ch])
|
||||
stage1_params.extend(
|
||||
[thread_id, checkpoint_ns, cursor, cursor, _DELTA_PAGE_SIZE]
|
||||
)
|
||||
cur.execute(stage1_sql, stage1_params)
|
||||
page = cur.fetchall()
|
||||
if not page:
|
||||
break
|
||||
oldest = self._ingest_stage1_page(
|
||||
cast("list[Mapping[str, Any]]", page),
|
||||
channels,
|
||||
parent_of,
|
||||
ver_by_i_by_cid,
|
||||
hs_by_i_by_cid,
|
||||
)
|
||||
self._try_advance_walks(
|
||||
checkpoint_id,
|
||||
channels,
|
||||
parent_of,
|
||||
ver_by_i_by_cid,
|
||||
hs_by_i_by_cid,
|
||||
chain_by_ch,
|
||||
seed_ver_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
|
||||
|
||||
# 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]
|
||||
stage2_sql = _build_delta_stage2_sql(
|
||||
channels_with_chain=channels_with_chain,
|
||||
channels_with_seed=channels_with_seed,
|
||||
)
|
||||
|
||||
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,
|
||||
chain_by_ch=chain_by_ch,
|
||||
seed_ver_by_ch=seed_ver_by_ch,
|
||||
stage2_rows=cast("list[_DeltaStage2Row]", stage2_rows),
|
||||
)
|
||||
|
||||
def _load_checkpoint_tuple(self, value: DictRow) -> CheckpointTuple:
|
||||
"""
|
||||
Convert a database row into a CheckpointTuple object.
|
||||
|
||||
@@ -2,9 +2,9 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections import defaultdict
|
||||
from collections.abc import AsyncIterator, Iterator, Sequence
|
||||
from collections.abc import AsyncIterator, Iterator, Mapping, Sequence
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from langgraph.checkpoint.base import (
|
||||
@@ -13,17 +13,25 @@ from langgraph.checkpoint.base import (
|
||||
Checkpoint,
|
||||
CheckpointMetadata,
|
||||
CheckpointTuple,
|
||||
DeltaChannelHistory,
|
||||
get_checkpoint_id,
|
||||
get_serializable_checkpoint_metadata,
|
||||
)
|
||||
from langgraph.checkpoint.serde.base import SerializerProtocol
|
||||
from langgraph.checkpoint.serde.types import _DeltaSnapshot
|
||||
from psycopg import AsyncConnection, AsyncCursor, AsyncPipeline, Capabilities
|
||||
from psycopg.rows import DictRow, dict_row
|
||||
from psycopg.types.json import Jsonb
|
||||
from psycopg_pool import AsyncConnectionPool
|
||||
|
||||
from langgraph.checkpoint.postgres import _ainternal
|
||||
from langgraph.checkpoint.postgres.base import BasePostgresSaver
|
||||
from langgraph.checkpoint.postgres.base import (
|
||||
_DELTA_PAGE_SIZE,
|
||||
BasePostgresSaver,
|
||||
_build_delta_stage1_sql,
|
||||
_build_delta_stage2_sql,
|
||||
_DeltaStage2Row,
|
||||
)
|
||||
from langgraph.checkpoint.postgres.shallow import AsyncShallowPostgresSaver
|
||||
|
||||
Conn = _ainternal.Conn # For backward compatibility
|
||||
@@ -261,7 +269,10 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
||||
# others are stored in blobs table
|
||||
blob_values = {}
|
||||
for k, v in checkpoint["channel_values"].items():
|
||||
if v is None or isinstance(v, (str, int, float, bool)):
|
||||
if isinstance(v, _DeltaSnapshot):
|
||||
blob_values[k] = copy["channel_values"].pop(k)
|
||||
copy["channel_values"][k] = True
|
||||
elif v is None or isinstance(v, (str, int, float, bool)):
|
||||
pass
|
||||
else:
|
||||
blob_values[k] = copy["channel_values"].pop(k)
|
||||
@@ -391,6 +402,97 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
||||
async with conn.cursor(binary=True, row_factory=dict_row) as cur:
|
||||
yield cur
|
||||
|
||||
async def aget_delta_channel_history(
|
||||
self, *, config: RunnableConfig, channels: Sequence[str]
|
||||
) -> Mapping[str, DeltaChannelHistory]:
|
||||
"""Fast-path override of `BaseCheckpointSaver.aget_delta_channel_history`.
|
||||
|
||||
See `PostgresSaver.get_delta_channel_history` for design notes; this is
|
||||
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"]
|
||||
checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
|
||||
checkpoint_id = get_checkpoint_id(config)
|
||||
if checkpoint_id is None:
|
||||
target = await self.aget_tuple(config)
|
||||
if target is None:
|
||||
return {ch: {"writes": []} for ch in channels}
|
||||
checkpoint_id = target.config["configurable"]["checkpoint_id"]
|
||||
|
||||
stage1_sql = _build_delta_stage1_sql(channels, paged=True)
|
||||
parent_of: dict[str, str | None] = {}
|
||||
ver_by_i_by_cid: list[dict[str, str | None]] = [{} for _ in channels]
|
||||
hs_by_i_by_cid: list[dict[str, bool]] = [{} 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}
|
||||
walk_cursor_by_ch: dict[str, str | None] = {}
|
||||
seeded: set[str] = set()
|
||||
cursor: str | None = None
|
||||
|
||||
async with self._cursor() as cur:
|
||||
while True:
|
||||
stage1_params: list[Any] = []
|
||||
for ch in channels:
|
||||
stage1_params.extend([ch, ch])
|
||||
stage1_params.extend(
|
||||
[thread_id, checkpoint_ns, cursor, cursor, _DELTA_PAGE_SIZE]
|
||||
)
|
||||
await cur.execute(stage1_sql, stage1_params)
|
||||
page = await cur.fetchall()
|
||||
if not page:
|
||||
break
|
||||
oldest = self._ingest_stage1_page(
|
||||
cast("list[Mapping[str, Any]]", page),
|
||||
channels,
|
||||
parent_of,
|
||||
ver_by_i_by_cid,
|
||||
hs_by_i_by_cid,
|
||||
)
|
||||
self._try_advance_walks(
|
||||
checkpoint_id,
|
||||
channels,
|
||||
parent_of,
|
||||
ver_by_i_by_cid,
|
||||
hs_by_i_by_cid,
|
||||
chain_by_ch,
|
||||
seed_ver_by_ch,
|
||||
walk_cursor_by_ch,
|
||||
seeded,
|
||||
)
|
||||
if len(seeded) == len(channels) or len(page) < _DELTA_PAGE_SIZE:
|
||||
break
|
||||
cursor = oldest
|
||||
|
||||
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]
|
||||
stage2_sql = _build_delta_stage2_sql(
|
||||
channels_with_chain=channels_with_chain,
|
||||
channels_with_seed=channels_with_seed,
|
||||
)
|
||||
|
||||
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]])
|
||||
async with self._cursor() as cur:
|
||||
await cur.execute(stage2_sql, stage2_params)
|
||||
stage2_rows = await cur.fetchall()
|
||||
else:
|
||||
stage2_rows = []
|
||||
|
||||
return self._build_delta_channels_writes_history(
|
||||
channels=channels,
|
||||
chain_by_ch=chain_by_ch,
|
||||
seed_ver_by_ch=seed_ver_by_ch,
|
||||
stage2_rows=cast("list[_DeltaStage2Row]", stage2_rows),
|
||||
)
|
||||
|
||||
async def _load_checkpoint_tuple(self, value: DictRow) -> CheckpointTuple:
|
||||
"""
|
||||
Convert a database row into a CheckpointTuple object.
|
||||
|
||||
@@ -2,20 +2,26 @@ from __future__ import annotations
|
||||
|
||||
import random
|
||||
import warnings
|
||||
from collections.abc import Sequence
|
||||
from collections.abc import Mapping, Sequence
|
||||
from importlib.metadata import version as get_version
|
||||
from typing import Any, cast
|
||||
from typing import Any, TypedDict, cast
|
||||
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from langgraph.checkpoint.base import (
|
||||
WRITES_IDX_MAP,
|
||||
BaseCheckpointSaver,
|
||||
ChannelVersions,
|
||||
DeltaChannelHistory,
|
||||
PendingWrite,
|
||||
get_checkpoint_id,
|
||||
)
|
||||
from langgraph.checkpoint.serde.types import TASKS
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
# 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
|
||||
|
||||
MetadataInput = dict[str, Any] | None
|
||||
|
||||
try:
|
||||
@@ -153,6 +159,141 @@ INSERT_CHECKPOINT_WRITES_SQL = """
|
||||
"""
|
||||
|
||||
|
||||
class _DeltaStage2Row(TypedDict, total=False):
|
||||
"""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
|
||||
channel: str | None # set on both "w" and "b" rows
|
||||
type: str | None
|
||||
blob: bytes | None
|
||||
task_id: str | None # "w" rows only
|
||||
idx: int | None # "w" rows only
|
||||
version: str | None # "b" rows only
|
||||
|
||||
|
||||
# Multi-channel two-stage DeltaChannel reconstruction.
|
||||
#
|
||||
# Stage 1 scans checkpoint metadata (no blob bytes) and emits one row per
|
||||
# checkpoint with K parallel JSONB key lookups (one column pair per
|
||||
# requested delta channel: ver_i / hs_i). No subqueries, no aggregation.
|
||||
# Python walks the parent chain once across all channels.
|
||||
#
|
||||
# Stage 2 fetches all writes and the seed blobs for ALL channels in a
|
||||
# single roundtrip via `channel = ANY(%s)` and chain/seed-version
|
||||
# filtering.
|
||||
#
|
||||
# Empirical comparison vs an alternative "ship full channel_versions /
|
||||
# channel_values JSONB and let Python pick" form (1000 checkpoints,
|
||||
# 8 total channels in graph, 3 delta channels requested):
|
||||
#
|
||||
# Postgres execution: A=0.24ms vs B=0.38ms (both negligible)
|
||||
# End-to-end latency: A=6.83ms vs B=2.28ms (B is 3.0x faster)
|
||||
# Wire payload: A=836KB vs B=330KB (61% smaller)
|
||||
# Buffer hits: identical (167 blocks)
|
||||
#
|
||||
# B (this dynamic-columns design) wins because it avoids JSONB
|
||||
# serialization on the wire and JSONB-to-dict deserialization in
|
||||
# psycopg. Even at K=8 (8 delta channels = 16 dynamic columns), B
|
||||
# still beats A end-to-end (4.2ms vs 6.8ms).
|
||||
|
||||
|
||||
def _build_delta_stage1_sql(channels: Sequence[str], *, paged: bool) -> str:
|
||||
"""Build stage 1 SQL with 2K parallel JSONB key lookups.
|
||||
|
||||
For channels=["messages", "files"] (with `paged=True`) the result is::
|
||||
|
||||
SELECT checkpoint_id, parent_checkpoint_id,
|
||||
checkpoint -> 'channel_versions' ->> %s AS ver_0,
|
||||
(checkpoint -> 'channel_values' -> %s) IS NOT NULL AS hs_0,
|
||||
checkpoint -> 'channel_versions' ->> %s AS ver_1,
|
||||
(checkpoint -> 'channel_values' -> %s) IS NOT NULL AS hs_1
|
||||
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
|
||||
|
||||
Channel names are passed as `%s` parameters (safe from SQL injection).
|
||||
Only the column aliases `ver_i` / `hs_i` are interpolated into the
|
||||
SQL string (i is bounded by len(channels) and uses safe identifiers).
|
||||
|
||||
Caller must extend params with `[ch_0, ch_0, ch_1, ch_1, ...,
|
||||
thread_id, ns, cursor, cursor, page_size]` when `paged=True`.
|
||||
|
||||
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 = []
|
||||
for i in range(len(channels)):
|
||||
cols.append(
|
||||
f"checkpoint -> 'channel_versions' ->> %s AS ver_{i}, "
|
||||
f"(checkpoint -> 'channel_values' -> %s) IS NOT NULL AS hs_{i}"
|
||||
)
|
||||
sql = (
|
||||
"SELECT checkpoint_id, parent_checkpoint_id, "
|
||||
+ ", ".join(cols)
|
||||
+ " FROM checkpoints WHERE thread_id = %s AND checkpoint_ns = %s"
|
||||
)
|
||||
if paged:
|
||||
sql += (
|
||||
" AND (%s::text IS NULL OR checkpoint_id < %s)"
|
||||
" ORDER BY checkpoint_id DESC LIMIT %s"
|
||||
)
|
||||
return sql
|
||||
|
||||
|
||||
def _build_delta_stage2_sql(
|
||||
*,
|
||||
channels_with_chain: Sequence[str],
|
||||
channels_with_seed: Sequence[str],
|
||||
) -> str:
|
||||
"""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 the prior
|
||||
`channel = ANY(channels) AND checkpoint_id = ANY(union)` form when
|
||||
channels have different chain depths.
|
||||
|
||||
The caller must pass parameters in matching order:
|
||||
|
||||
for ch in channels_with_chain:
|
||||
params += [thread_id, checkpoint_ns, ch, chain_cids[ch]]
|
||||
for ch in channels_with_seed:
|
||||
params += [thread_id, checkpoint_ns, ch, seed_version[ch]]
|
||||
|
||||
Returns an empty SQL string if both channel lists are empty (caller
|
||||
must skip executing in that case).
|
||||
"""
|
||||
branches: list[str] = []
|
||||
for _ in channels_with_chain:
|
||||
branches.append(
|
||||
"SELECT 'w'::text AS _kind, "
|
||||
"checkpoint_id, channel, "
|
||||
"type, blob, task_id, idx, NULL::text AS version "
|
||||
"FROM checkpoint_writes "
|
||||
"WHERE thread_id = %s AND checkpoint_ns = %s AND channel = %s "
|
||||
"AND checkpoint_id = ANY(%s)"
|
||||
)
|
||||
for _ in channels_with_seed:
|
||||
branches.append(
|
||||
"SELECT 'b'::text, NULL, channel, "
|
||||
"type, blob, NULL, NULL, version "
|
||||
"FROM checkpoint_blobs "
|
||||
"WHERE thread_id = %s AND checkpoint_ns = %s AND channel = %s "
|
||||
"AND version = %s"
|
||||
)
|
||||
return " UNION ALL ".join(branches)
|
||||
|
||||
|
||||
# Stage 1 rows are dynamic-shape dicts: {checkpoint_id, parent_checkpoint_id,
|
||||
# ver_0, hs_0, ver_1, hs_1, ...}. Walking is parameterized by the channel
|
||||
# list to map indices back to channel names — no static TypedDict here.
|
||||
# `dict[str, Any]` is the practical signature.
|
||||
|
||||
|
||||
class BasePostgresSaver(BaseCheckpointSaver[str]):
|
||||
SELECT_SQL = SELECT_SQL
|
||||
SELECT_PENDING_SENDS_SQL = SELECT_PENDING_SENDS_SQL
|
||||
@@ -195,6 +336,146 @@ class BasePostgresSaver(BaseCheckpointSaver[str]):
|
||||
if t.decode() != "empty"
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _ingest_stage1_page(
|
||||
stage1_rows: Sequence[Mapping[str, Any]],
|
||||
channels: Sequence[str],
|
||||
parent_of: dict[str, str | None],
|
||||
ver_by_i_by_cid: list[dict[str, str | None]],
|
||||
hs_by_i_by_cid: list[dict[str, bool]],
|
||||
) -> str | None:
|
||||
"""Fold one stage-1 page into the running walk-state mappings.
|
||||
|
||||
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 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}"))
|
||||
hs_by_i_by_cid[i][cid] = bool(r.get(f"hs_{i}"))
|
||||
# Rows are DESC; the last one is the smallest cid in the page.
|
||||
oldest = cid
|
||||
return oldest
|
||||
|
||||
@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]],
|
||||
hs_by_i_by_cid: Sequence[Mapping[str, bool]],
|
||||
chain_by_ch: dict[str, list[str]],
|
||||
seed_ver_by_ch: dict[str, str | None],
|
||||
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 snapshot for its channel (channel becomes seeded),
|
||||
(b) it reached a real root (parent_of[cid] is None — fully
|
||||
materialized at this point), or
|
||||
(c) the next ancestor cid isn't in `parent_of` yet (waiting for
|
||||
a later page; the cursor stays put).
|
||||
|
||||
Mutates `chain_by_ch`, `seed_ver_by_ch`, `walk_cursor_by_ch`, and
|
||||
`seeded` in place.
|
||||
"""
|
||||
for i, ch in enumerate(channels):
|
||||
if ch in seeded:
|
||||
continue
|
||||
# First-time entry: cursor starts at the target's parent.
|
||||
if ch not in walk_cursor_by_ch:
|
||||
walk_cursor_by_ch[ch] = parent_of.get(target_id)
|
||||
cur_cid = walk_cursor_by_ch[ch]
|
||||
ch_chain = chain_by_ch[ch]
|
||||
hs_i = hs_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)
|
||||
if hs_i.get(cur_cid, False):
|
||||
seed_ver_by_ch[ch] = ver_i.get(cur_cid)
|
||||
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],
|
||||
chain_by_ch: Mapping[str, list[str]],
|
||||
seed_ver_by_ch: Mapping[str, str | None],
|
||||
stage2_rows: Sequence[_DeltaStage2Row],
|
||||
) -> dict[str, DeltaChannelHistory]:
|
||||
"""Demux stage 2 rows per channel; produce per-channel histories.
|
||||
|
||||
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` key is omitted
|
||||
when the walk reached root with no snapshot found, 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_ver[(channel, version)] = (type, blob)
|
||||
seed_blob_by_ver: dict[tuple[str, str], tuple[str, bytes]] = {}
|
||||
|
||||
for r in stage2_rows:
|
||||
ch = cast(str, r["channel"])
|
||||
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"]),
|
||||
)
|
||||
)
|
||||
else: # kind == "b"
|
||||
ver = cast(str, r["version"])
|
||||
seed_blob_by_ver[(ch, ver)] = cast(
|
||||
"tuple[str, bytes]", (r["type"], r["blob"])
|
||||
)
|
||||
|
||||
# Sort writes per (channel, cid) newest-first by (task_id, idx)
|
||||
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]), reverse=True)
|
||||
|
||||
result: dict[str, DeltaChannelHistory] = {}
|
||||
for ch in channels:
|
||||
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, {})
|
||||
for cid in chain_cids:
|
||||
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))
|
||||
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)
|
||||
result[ch] = entry
|
||||
return result
|
||||
|
||||
def _dump_blobs(
|
||||
self,
|
||||
thread_id: str,
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "langgraph-checkpoint-postgres"
|
||||
version = "3.0.5"
|
||||
version = "3.1.0a4"
|
||||
description = "Library with a Postgres implementation of LangGraph checkpoint saver."
|
||||
authors = []
|
||||
requires-python = ">=3.10"
|
||||
@@ -12,7 +12,7 @@ readme = "README.md"
|
||||
license = "MIT"
|
||||
license-files = ['LICENSE']
|
||||
dependencies = [
|
||||
"langgraph-checkpoint>=2.1.2,<5.0.0",
|
||||
"langgraph-checkpoint>=4.1.0a4,<5.0.0",
|
||||
"orjson>=3.11.5",
|
||||
"psycopg>=3.2.0",
|
||||
"psycopg-pool>=3.2.0",
|
||||
@@ -20,7 +20,7 @@ dependencies = [
|
||||
|
||||
[project.urls]
|
||||
Source = "https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint-postgres"
|
||||
Twitter = "https://x.com/LangChain"
|
||||
Twitter = "https://x.com/langchain_oss"
|
||||
Slack = "https://www.langchain.com/join-community"
|
||||
Reddit = "https://www.reddit.com/r/LangChain/"
|
||||
|
||||
|
||||
@@ -361,9 +361,9 @@ async def test_get_checkpoint_no_channel_values(
|
||||
|
||||
load_checkpoint_tuple = saver._load_checkpoint_tuple
|
||||
|
||||
def patched_load_checkpoint_tuple(value):
|
||||
async def patched_load_checkpoint_tuple(value):
|
||||
value["checkpoint"].pop("channel_values", None)
|
||||
return load_checkpoint_tuple(value)
|
||||
return await load_checkpoint_tuple(value)
|
||||
|
||||
monkeypatch.setattr(
|
||||
saver, "_load_checkpoint_tuple", patched_load_checkpoint_tuple
|
||||
@@ -371,3 +371,47 @@ async def test_get_checkpoint_no_channel_values(
|
||||
|
||||
checkpoint = await saver.aget_tuple(config)
|
||||
assert checkpoint.checkpoint["channel_values"] == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("saver_name", ["base", "pool", "pipe"])
|
||||
async def test_delta_channel_chain_reconstruction(saver_name: str) -> None:
|
||||
"""AsyncPostgresSaver reconstructs DeltaChannel chain via point-lookup traversal."""
|
||||
pytest.importorskip(
|
||||
"langgraph.channels.delta", reason="langgraph core not installed"
|
||||
)
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
from langgraph.channels.delta import DeltaChannel
|
||||
from langgraph.graph import START, StateGraph
|
||||
from langgraph.graph.message import _messages_delta_reducer
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
class State(TypedDict):
|
||||
messages: Annotated[list, DeltaChannel(_messages_delta_reducer)]
|
||||
|
||||
def respond(state: State) -> dict:
|
||||
n = len(state["messages"])
|
||||
return {"messages": [AIMessage(content=f"reply-{n}", id=f"ai-{n}")]}
|
||||
|
||||
builder = StateGraph(State)
|
||||
builder.add_node("respond", respond)
|
||||
builder.add_edge(START, "respond")
|
||||
|
||||
async with _saver(saver_name) as saver:
|
||||
graph = builder.compile(checkpointer=saver)
|
||||
config = {"configurable": {"thread_id": "diff-channel-test-1"}}
|
||||
|
||||
await graph.ainvoke({"messages": [HumanMessage(content="hi", id="h1")]}, config)
|
||||
await graph.ainvoke(
|
||||
{"messages": [HumanMessage(content="there", id="h2")]}, config
|
||||
)
|
||||
|
||||
state = await graph.aget_state(config)
|
||||
msgs = state.values["messages"]
|
||||
assert len(msgs) == 4, f"expected 4, got {len(msgs)}: {msgs}"
|
||||
assert msgs[0].content == "hi"
|
||||
assert msgs[1].content == "reply-1"
|
||||
assert msgs[2].content == "there"
|
||||
assert msgs[3].content == "reply-3"
|
||||
|
||||
Generated
+86
-69
@@ -1,6 +1,10 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.10"
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.15'",
|
||||
"python_full_version < '3.15'",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "annotated-types"
|
||||
@@ -240,10 +244,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langchain-core"
|
||||
version = "1.2.28"
|
||||
version = "1.3.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jsonpatch" },
|
||||
{ name = "langchain-protocol" },
|
||||
{ name = "langsmith" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pydantic" },
|
||||
@@ -252,14 +257,26 @@ dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "uuid-utils" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f8/a4/317a1a3ac1df33a64adb3670bf88bbe3b3d5baa274db6863a979db472897/langchain_core-1.2.28.tar.gz", hash = "sha256:271a3d8bd618f795fdeba112b0753980457fc90537c46a0c11998516a74dc2cb", size = 846119, upload-time = "2026-04-08T18:19:34.867Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a8/03/7219502e8ca728d65eb44d7a3eb60239230742a70dbfc9241b9bfd61c4ab/langchain_core-1.3.2.tar.gz", hash = "sha256:fd7a50b2f28ba561fd9d7f5d2760bc9e06cf00cdf820a3ccafe88a94ffa8d5b7", size = 911813, upload-time = "2026-04-24T15:49:23.699Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/92/32f785f077c7e898da97064f113c73fbd9ad55d1e2169cf3a391b183dedb/langchain_core-1.2.28-py3-none-any.whl", hash = "sha256:80764232581eaf8057bcefa71dbf8adc1f6a28d257ebd8b95ba9b8b452e8c6ac", size = 508727, upload-time = "2026-04-08T18:19:32.823Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/d5/8fa4431007cbb7cfed7590f4d6a5dea3ad724f4174d248f6642ef5ce7d05/langchain_core-1.3.2-py3-none-any.whl", hash = "sha256:d44a66127f9f8db735bdfd0ab9661bccb47a97113cfd3f2d89c74864422b7274", size = 542390, upload-time = "2026-04-24T15:49:21.991Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langchain-protocol"
|
||||
version = "0.0.15"
|
||||
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" }
|
||||
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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-checkpoint"
|
||||
version = "4.0.2"
|
||||
version = "4.1.0a4"
|
||||
source = { editable = "../checkpoint" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -307,7 +324,7 @@ test = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-checkpoint-postgres"
|
||||
version = "3.0.5"
|
||||
version = "3.1.0a4"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "langgraph-checkpoint" },
|
||||
@@ -487,7 +504,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "mypy"
|
||||
version = "1.20.0"
|
||||
version = "1.20.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "librt", marker = "platform_python_implementation != 'PyPy'" },
|
||||
@@ -496,51 +513,51 @@ dependencies = [
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f8/5c/b0089fe7fef0a994ae5ee07029ced0526082c6cfaaa4c10d40a10e33b097/mypy-1.20.0.tar.gz", hash = "sha256:eb96c84efcc33f0b5e0e04beacf00129dd963b67226b01c00b9dfc8affb464c3", size = 3815028, upload-time = "2026-03-31T16:55:14.959Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/04/af/e3d4b3e9ec91a0ff9aabfdb38692952acf49bbb899c2e4c29acb3a6da3ae/mypy-1.20.2.tar.gz", hash = "sha256:e8222c26daaafd9e8626dec58ae36029f82585890589576f769a650dd20fd665", size = 3817349, upload-time = "2026-04-21T17:12:28.473Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/a2/a965c8c3fcd4fa8b84ba0d46606181b0d0a1d50f274c67877f3e9ed4882c/mypy-1.20.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d99f515f95fd03a90875fdb2cca12ff074aa04490db4d190905851bdf8a549a8", size = 14430138, upload-time = "2026-03-31T16:52:37.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/6e/043477501deeb8eabbab7f1a2f6cac62cfb631806dc1d6862a04a7f5011b/mypy-1.20.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bd0212976dc57a5bfeede7c219e7cd66568a32c05c9129686dd487c059c1b88a", size = 13311282, upload-time = "2026-03-31T16:55:11.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/aa/bd89b247b83128197a214f29f0632ff3c14f54d4cd70d144d157bd7d7d6e/mypy-1.20.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8426d4d75d68714abc17a4292d922f6ba2cfb984b72c2278c437f6dae797865", size = 13750889, upload-time = "2026-03-31T16:52:02.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/9d/2860be7355c45247ccc0be1501c91176318964c2a137bd4743f58ce6200e/mypy-1.20.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02cca0761c75b42a20a2757ae58713276605eb29a08dd8a6e092aa347c4115ca", size = 14619788, upload-time = "2026-03-31T16:50:48.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/7f/3ef3e360c91f3de120f205c8ce405e9caf9fc52ef14b65d37073e322c114/mypy-1.20.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b3a49064504be59e59da664c5e149edc1f26c67c4f8e8456f6ba6aba55033018", size = 14918849, upload-time = "2026-03-31T16:51:10.478Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/72/af970dfe167ef788df7c5e6109d2ed0229f164432ce828bc9741a4250e64/mypy-1.20.0-cp310-cp310-win_amd64.whl", hash = "sha256:ebea00201737ad4391142808ed16e875add5c17f676e0912b387739f84991e13", size = 10822007, upload-time = "2026-03-31T16:50:25.268Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/94/ba9065c2ebe5421619aff684b793d953e438a8bfe31a320dd6d1e0706e81/mypy-1.20.0-cp310-cp310-win_arm64.whl", hash = "sha256:e80cf77847d0d3e6e3111b7b25db32a7f8762fd4b9a3a72ce53fe16a2863b281", size = 9756158, upload-time = "2026-03-31T16:48:36.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/1c/74cb1d9993236910286865679d1c616b136b2eae468493aa939431eda410/mypy-1.20.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4525e7010b1b38334516181c5b81e16180b8e149e6684cee5a727c78186b4e3b", size = 14343972, upload-time = "2026-03-31T16:49:04.887Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/0d/01399515eca280386e308cf57901e68d3a52af18691941b773b3380c1df8/mypy-1.20.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a17c5d0bdcca61ce24a35beb828a2d0d323d3fcf387d7512206888c900193367", size = 13225007, upload-time = "2026-03-31T16:50:08.151Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/ac/b4ba5094fb2d7fe9d2037cd8d18bbe02bcf68fd22ab9ff013f55e57ba095/mypy-1.20.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f75ff57defcd0f1d6e006d721ccdec6c88d4f6a7816eb92f1c4890d979d9ee62", size = 13663752, upload-time = "2026-03-31T16:49:26.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/a7/460678d3cf7da252d2288dad0c602294b6ec22a91932ec368cc11e44bb6e/mypy-1.20.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b503ab55a836136b619b5fc21c8803d810c5b87551af8600b72eecafb0059cb0", size = 14532265, upload-time = "2026-03-31T16:53:55.077Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/3e/051cca8166cf0438ae3ea80e0e7c030d7a8ab98dffc93f80a1aa3f23c1a2/mypy-1.20.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1973868d2adbb4584a3835780b27436f06d1dc606af5be09f187aaa25be1070f", size = 14768476, upload-time = "2026-03-31T16:50:34.587Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/66/8e02ec184f852ed5c4abb805583305db475930854e09964b55e107cdcbc4/mypy-1.20.0-cp311-cp311-win_amd64.whl", hash = "sha256:2fcedb16d456106e545b2bfd7ef9d24e70b38ec252d2a629823a4d07ebcdb69e", size = 10818226, upload-time = "2026-03-31T16:53:15.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/4b/383ad1924b28f41e4879a74151e7a5451123330d45652da359f9183bcd45/mypy-1.20.0-cp311-cp311-win_arm64.whl", hash = "sha256:379edf079ce44ac8d2805bcf9b3dd7340d4f97aad3a5e0ebabbf9d125b84b442", size = 9750091, upload-time = "2026-03-31T16:54:12.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/dd/3afa29b58c2e57c79116ed55d700721c3c3b15955e2b6251dd165d377c0e/mypy-1.20.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:002b613ae19f4ac7d18b7e168ffe1cb9013b37c57f7411984abbd3b817b0a214", size = 14509525, upload-time = "2026-03-31T16:55:01.824Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/eb/227b516ab8cad9f2a13c5e7a98d28cd6aa75e9c83e82776ae6c1c4c046c7/mypy-1.20.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a9336b5e6712f4adaf5afc3203a99a40b379049104349d747eb3e5a3aa23ac2e", size = 13326469, upload-time = "2026-03-31T16:51:41.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/d4/1ddb799860c1b5ac6117ec307b965f65deeb47044395ff01ab793248a591/mypy-1.20.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f13b3e41bce9d257eded794c0f12878af3129d80aacd8a3ee0dee51f3a978651", size = 13705953, upload-time = "2026-03-31T16:48:55.69Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/b7/54a720f565a87b893182a2a393370289ae7149e4715859e10e1c05e49154/mypy-1.20.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9804c3ad27f78e54e58b32e7cb532d128b43dbfb9f3f9f06262b821a0f6bd3f5", size = 14710363, upload-time = "2026-03-31T16:53:26.948Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/2a/74810274848d061f8a8ea4ac23aaad43bd3d8c1882457999c2e568341c57/mypy-1.20.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:697f102c5c1d526bdd761a69f17c6070f9892eebcb94b1a5963d679288c09e78", size = 14947005, upload-time = "2026-03-31T16:50:17.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/91/21b8ba75f958bcda75690951ce6fa6b7138b03471618959529d74b8544e2/mypy-1.20.0-cp312-cp312-win_amd64.whl", hash = "sha256:0ecd63f75fdd30327e4ad8b5704bd6d91fc6c1b2e029f8ee14705e1207212489", size = 10880616, upload-time = "2026-03-31T16:52:19.986Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/15/3d8198ef97c1ca03aea010cce4f1d4f3bc5d9849e8c0140111ca2ead9fdd/mypy-1.20.0-cp312-cp312-win_arm64.whl", hash = "sha256:f194db59657c58593a3c47c6dfd7bad4ef4ac12dbc94d01b3a95521f78177e33", size = 9813091, upload-time = "2026-03-31T16:53:44.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/a7/f64ea7bd592fa431cb597418b6dec4a47f7d0c36325fec7ac67bc8402b94/mypy-1.20.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b20c8b0fd5877abdf402e79a3af987053de07e6fb208c18df6659f708b535134", size = 14485344, upload-time = "2026-03-31T16:49:16.78Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/72/8927d84cfc90c6abea6e96663576e2e417589347eb538749a464c4c218a0/mypy-1.20.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:367e5c993ba34d5054d11937d0485ad6dfc60ba760fa326c01090fc256adf15c", size = 13327400, upload-time = "2026-03-31T16:53:08.02Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/4a/11ab99f9afa41aa350178d24a7d2da17043228ea10f6456523f64b5a6cf6/mypy-1.20.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f799d9db89fc00446f03281f84a221e50018fc40113a3ba9864b132895619ebe", size = 13706384, upload-time = "2026-03-31T16:52:28.577Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/79/694ca73979cfb3535ebfe78733844cd5aff2e63304f59bf90585110d975a/mypy-1.20.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:555658c611099455b2da507582ea20d2043dfdfe7f5ad0add472b1c6238b433f", size = 14700378, upload-time = "2026-03-31T16:48:45.527Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/24/a022ccab3a46e3d2cdf2e0e260648633640eb396c7e75d5a42818a8d3971/mypy-1.20.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:efe8d70949c3023698c3fca1e94527e7e790a361ab8116f90d11221421cd8726", size = 14932170, upload-time = "2026-03-31T16:49:36.038Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/9b/549228d88f574d04117e736f55958bd4908f980f9f5700a07aeb85df005b/mypy-1.20.0-cp313-cp313-win_amd64.whl", hash = "sha256:f49590891d2c2f8a9de15614e32e459a794bcba84693c2394291a2038bbaaa69", size = 10888526, upload-time = "2026-03-31T16:50:59.827Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/17/15095c0e54a8bc04d22d4ff06b2139d5f142c2e87520b4e39010c4862771/mypy-1.20.0-cp313-cp313-win_arm64.whl", hash = "sha256:76a70bf840495729be47510856b978f1b0ec7d08f257ca38c9d932720bf6b43e", size = 9816456, upload-time = "2026-03-31T16:49:59.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/0e/6ca4a84cbed9e62384bc0b2974c90395ece5ed672393e553996501625fc5/mypy-1.20.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0f42dfaab7ec1baff3b383ad7af562ab0de573c5f6edb44b2dab016082b89948", size = 14483331, upload-time = "2026-03-31T16:52:57.999Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/c5/5fe9d8a729dd9605064691816243ae6c49fde0bd28f6e5e17f6a24203c43/mypy-1.20.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:31b5dbb55293c1bd27c0fc813a0d2bb5ceef9d65ac5afa2e58f829dab7921fd5", size = 13342047, upload-time = "2026-03-31T16:54:21.555Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/33/e18bcfa338ca4e6b2771c85d4c5203e627d0c69d9de5c1a2cf2ba13320ba/mypy-1.20.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49d11c6f573a5a08f77fad13faff2139f6d0730ebed2cfa9b3d2702671dd7188", size = 13719585, upload-time = "2026-03-31T16:51:53.89Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/8d/93491ff7b79419edc7eabf95cb3b3f7490e2e574b2855c7c7e7394ff933f/mypy-1.20.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d3243c406773185144527f83be0e0aefc7bf4601b0b2b956665608bf7c98a83", size = 14685075, upload-time = "2026-03-31T16:54:04.464Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/9d/d924b38a4923f8d164bf2b4ec98bf13beaf6e10a5348b4b137eadae40a6e/mypy-1.20.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a79c1eba7ac4209f2d850f0edd0a2f8bba88cbfdfefe6fb76a19e9d4fe5e71a2", size = 14919141, upload-time = "2026-03-31T16:54:51.785Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/98/1da9977016678c0b99d43afe52ed00bb3c1a0c4c995d3e6acca1a6ebb9b4/mypy-1.20.0-cp314-cp314-win_amd64.whl", hash = "sha256:00e047c74d3ec6e71a2eb88e9ea551a2edb90c21f993aefa9e0d2a898e0bb732", size = 11050925, upload-time = "2026-03-31T16:51:30.758Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/e3/ba0b7a3143e49a9c4f5967dde6ea4bf8e0b10ecbbcca69af84027160ee89/mypy-1.20.0-cp314-cp314-win_arm64.whl", hash = "sha256:931a7630bba591593dcf6e97224a21ff80fb357e7982628d25e3c618e7f598ef", size = 10001089, upload-time = "2026-03-31T16:49:43.632Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/28/e617e67b3be9d213cda7277913269c874eb26472489f95d09d89765ce2d8/mypy-1.20.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:26c8b52627b6552f47ff11adb4e1509605f094e29815323e487fc0053ebe93d1", size = 15534710, upload-time = "2026-03-31T16:52:12.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/0c/3b5f2d3e45dc7169b811adce8451679d9430399d03b168f9b0489f43adaa/mypy-1.20.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:39362cdb4ba5f916e7976fccecaab1ba3a83e35f60fa68b64e9a70e221bb2436", size = 14393013, upload-time = "2026-03-31T16:54:41.186Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/49/edc8b0aa145cc09c1c74f7ce2858eead9329931dcbbb26e2ad40906daa4e/mypy-1.20.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34506397dbf40c15dc567635d18a21d33827e9ab29014fb83d292a8f4f8953b6", size = 15047240, upload-time = "2026-03-31T16:54:31.955Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/37/a946bb416e37a57fa752b3100fd5ede0e28df94f92366d1716555d47c454/mypy-1.20.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:555493c44a4f5a1b58d611a43333e71a9981c6dbe26270377b6f8174126a0526", size = 15858565, upload-time = "2026-03-31T16:53:36.997Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/99/7690b5b5b552db1bd4ff362e4c0eb3107b98d680835e65823fbe888c8b78/mypy-1.20.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2721f0ce49cb74a38f00c50da67cb7d36317b5eda38877a49614dc018e91c787", size = 16087874, upload-time = "2026-03-31T16:52:48.313Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/76/53e893a498138066acd28192b77495c9357e5a58cc4be753182846b43315/mypy-1.20.0-cp314-cp314t-win_amd64.whl", hash = "sha256:47781555a7aa5fedcc2d16bcd72e0dc83eb272c10dd657f9fb3f9cc08e2e6abb", size = 12572380, upload-time = "2026-03-31T16:49:52.454Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/9c/6dbdae21f01b7aacddc2c0bbf3c5557aa547827fdf271770fe1e521e7093/mypy-1.20.0-cp314-cp314t-win_arm64.whl", hash = "sha256:c70380fe5d64010f79fb863b9081c7004dd65225d2277333c219d93a10dad4dd", size = 10381174, upload-time = "2026-03-31T16:51:20.179Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/66/4d734961ce167f0fd8380769b3b7c06dbdd6ff54c2190f3f2ecd22528158/mypy-1.20.0-py3-none-any.whl", hash = "sha256:a6e0641147cbfa7e4e94efdb95c2dab1aff8cfc159ded13e07f308ddccc8c48e", size = 2636365, upload-time = "2026-03-31T16:51:44.911Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/97/ce2502df2cecf2ef997b6c6527c4a223b92feb9e7b790cdc8dcd683f3a8a/mypy-1.20.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cf5a4db6dca263010e2c7bff081c89383c72d187ba2cf4c44759aac970e2f0c4", size = 14457059, upload-time = "2026-04-21T17:06:14.935Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/34/417ee60b822cc80c0f3dc9f495ad7fd8dbb8d8b2cf4baf22d4046d25d01d/mypy-1.20.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7b0e817b518bff7facd7f85ea05b643ad8bdcce684cf29784987b0a7c8e1f997", size = 13346816, upload-time = "2026-04-21T17:10:41.433Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/85/e20951978702df58379d0bcc2e8f7ccdca4e78cd7dc66dd3ddbf9b29d517/mypy-1.20.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97d7b9a485b40f8ca425460e89bf1da2814625b2da627c0dcc6aa46c92631d14", size = 13772593, upload-time = "2026-04-21T17:08:11.24Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/a5/5441a13259ec516c56fd5de0fd96a69a9590ae6c5e5d3e5174aa84b97973/mypy-1.20.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e1c12f6d2db3d78b909b5f77513c11eb7f2dd2782b96a3ab6dffc7d44575c99", size = 14656635, upload-time = "2026-04-21T17:09:54.042Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/51/b89c69157c5e1f19fd125a65d991166a26906e7902f026f00feebbcfa2b9/mypy-1.20.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:89dce27e142d25ffbc154c1819383b69f2e9234dc4ed4766f42e0e8cb264ab5c", size = 14943278, upload-time = "2026-04-21T17:09:15.599Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/44/6b0eeecfe96d7cce1d71c66b8e03cb304aa70ec11f1955dc1d6b46aca3c3/mypy-1.20.2-cp310-cp310-win_amd64.whl", hash = "sha256:f376e37f9bf2a946872fc5fd1199c99310748e3c26c7a26683f13f8bdb756cbd", size = 10851915, upload-time = "2026-04-21T17:06:03.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/36/6593dc88545d75fb96416184be5392da5e2a8e8c2802a8597913e16ae25c/mypy-1.20.2-cp310-cp310-win_arm64.whl", hash = "sha256:6e2b469efd811707bc530fd1effef0f5d6eebcb7fe376affae69025da4b979a2", size = 9786676, upload-time = "2026-04-21T17:07:02.035Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/4d/9ebeae211caccbdaddde7ed5e31dfcf57faac66be9b11deb1dc6526c8078/mypy-1.20.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4077797a273e56e8843d001e9dfe4ba10e33323d6ade647ff260e5cd97d9758c", size = 14371307, upload-time = "2026-04-21T17:08:56.442Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/d7/93473d34b61f04fac1aecc01368485c89c5c4af7a4b9a0cab5d77d04b63f/mypy-1.20.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cdecf62abcc4292500d7858aeae87a1f8f1150f4c4dd08fb0b336ee79b2a6df3", size = 13258917, upload-time = "2026-04-21T17:05:50.978Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/30/3dd903e8bafb7b5f7bf87fcd58f8382086dea2aa19f0a7b357f21f63071b/mypy-1.20.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c566c3a88b6ece59b3d70f65bedef17304f48eb52ff040a6a18214e1917b3254", size = 13700516, upload-time = "2026-04-21T17:11:33.161Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/05/c61a140aba4c729ac7bc99ae26fc627c78a6e08f5b9dd319244ea71a3d7e/mypy-1.20.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0deb80d062b2479f2c87ae568f89845afc71d11bc41b04179e58165fd9f31e98", size = 14562889, upload-time = "2026-04-21T17:05:27.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/87/da78243742ffa8a36d98c3010f0d829f93d5da4e6786f1a1a6f2ad616502/mypy-1.20.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bba9ad231e92a3e424b3e56b65aa17704993425bba97e302c832f9466bb85bac", size = 14803844, upload-time = "2026-04-21T17:10:06.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/52/10a1ddf91b40f843943a3c6db51e2df59c9e237f29d355e95eaab427461f/mypy-1.20.2-cp311-cp311-win_amd64.whl", hash = "sha256:baf593f2765fa3a6b1ef95807dbaa3d25b594f6a52adcc506a6b9cb115e1be67", size = 10846300, upload-time = "2026-04-21T17:12:23.886Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/02/f9a4415b664c53bd34d6709be59da303abcae986dc4ac847b402edb6fa1e/mypy-1.20.2-cp311-cp311-win_arm64.whl", hash = "sha256:20175a1c0f49863946ec20b7f63255768058ac4f07d2b9ded6a6b46cfb5a9100", size = 9779498, upload-time = "2026-04-21T17:09:23.695Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/4e/7560e4528db9e9b147e4c0f22660466bf30a0a1fe3d63d1b9d3b0fd354ee/mypy-1.20.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4dbfcf869f6b0517f70cf0030ba6ea1d6645e132337a7d5204a18d8d5636c02b", size = 14539393, upload-time = "2026-04-21T17:07:12.52Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/d9/34a5efed8124f5a9234f55ac6a4ced4201e2c5b81e1109c49ad23190ec8c/mypy-1.20.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b6481b228d072315b053210b01ac320e1be243dc17f9e5887ef167f23f5fae4", size = 13361642, upload-time = "2026-04-21T17:06:53.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/14/eb377acf78c03c92d566a1510cda8137348215b5335085ef662ab82ecd3a/mypy-1.20.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34397cdced6b90b836e38182076049fdb41424322e0b0728c946b0939ebdf9f6", size = 13740347, upload-time = "2026-04-21T17:12:04.73Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/94/7e4634a32b641aa1c112422eed1bbece61ee16205f674190e8b536f884de/mypy-1.20.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5da6976f20cae27059ea8d0c86e7cef3de720e04c4bb9ee18e3690fdb792066", size = 14734042, upload-time = "2026-04-21T17:07:43.16Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/f3/f7e62395cb7f434541b4491a01149a4439e28ace4c0c632bbf5431e92d1f/mypy-1.20.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:56908d7e08318d39f85b1f0c6cfd47b0cac1a130da677630dac0de3e0623e102", size = 14964958, upload-time = "2026-04-21T17:11:00.665Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/0d/47e3c3a0ec2a876e35aeac365df3cac7776c36bbd4ed18cc521e1b9d255b/mypy-1.20.2-cp312-cp312-win_amd64.whl", hash = "sha256:d52ad8d78522da1d308789df651ee5379088e77c76cb1994858d40a426b343b9", size = 10911340, upload-time = "2026-04-21T17:10:49.179Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/b2/6c852d72e0ea8b01f49da817fb52539993cde327e7d010e0103dc12d0dac/mypy-1.20.2-cp312-cp312-win_arm64.whl", hash = "sha256:785b08db19c9f214dc37d65f7c165d19a30fcecb48abfa30f31b01b5acaabb58", size = 9833947, upload-time = "2026-04-21T17:09:05.267Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/c4/b93812d3a192c9bcf5df405bd2f30277cd0e48106a14d1023c7f6ed6e39b/mypy-1.20.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:edfbfca868cdd6bd8d974a60f8a3682f5565d3f5c99b327640cedd24c4264026", size = 14524670, upload-time = "2026-04-21T17:10:30.737Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/47/42c122501bff18eaf1e8f457f5c017933452d8acdc52918a9f59f6812955/mypy-1.20.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e2877a02380adfcdbc69071a0f74d6e9dbbf593c0dc9d174e1f223ffd5281943", size = 13336218, upload-time = "2026-04-21T17:08:44.069Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/8f/75bbc92f41725fbd585fb17b440b1119b576105df1013622983e18640a93/mypy-1.20.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7488448de6007cd5177c6cea0517ac33b4c0f5ee9b5e9f2be51ce75511a85517", size = 13724906, upload-time = "2026-04-21T17:08:01.02Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/32/4c49da27a606167391ff0c39aa955707a00edc500572e562f7c36c08a71f/mypy-1.20.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb9c2fa06887e21d6a3a868762acb82aec34e2c6fd0174064f27c93ede68ad15", size = 14726046, upload-time = "2026-04-21T17:11:22.354Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/fc/4e354a1bd70216359deb0c9c54847ee6b32ef78dfb09f5131ff99b494078/mypy-1.20.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d56a78b646f2e3daa865bc70cd5ec5a46c50045801ca8ff17a0c43abc97e3ee", size = 14955587, upload-time = "2026-04-21T17:12:16.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/b2/c0f2056e9eb8f08c62cafd9715e4584b89132bdc832fcf85d27d07b5f3e5/mypy-1.20.2-cp313-cp313-win_amd64.whl", hash = "sha256:2a4102b03bb7481d9a91a6da8d174740c9c8c4401024684b9ca3b7cc5e49852f", size = 10922681, upload-time = "2026-04-21T17:06:35.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/14/065e333721f05de8ef683d0aa804c23026bcc287446b61cac657b902ccac/mypy-1.20.2-cp313-cp313-win_arm64.whl", hash = "sha256:a95a9248b0c6fd933a442c03c3b113c3b61320086b88e2c444676d3fd1ca3330", size = 9830560, upload-time = "2026-04-21T17:07:51.023Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/d1/b4ec96b0ecc620a4443570c6e95c867903428cfcde4206518eafdd5880c3/mypy-1.20.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:419413398fe250aae057fd2fe50166b61077083c9b82754c341cf4fd73038f30", size = 14524561, upload-time = "2026-04-21T17:06:27.325Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/63/d2c2ff4fa66bc49477d32dfa26e8a167ba803ea6a69c5efb416036909d30/mypy-1.20.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e73c07f23009962885c197ccb9b41356a30cc0e5a1d0c2ea8fd8fb1362d7f924", size = 13363883, upload-time = "2026-04-21T17:11:11.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/56/983916806bf4eddeaaa2c9230903c3669c6718552a921154e1c5182c701f/mypy-1.20.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c64e5973df366b747646fc98da921f9d6eba9716d57d1db94a83c026a08e0fb", size = 13742945, upload-time = "2026-04-21T17:08:34.181Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/65/0cd9285ab010ee8214c83d67c6b49417c40d86ce46f1aa109457b5a9b8d7/mypy-1.20.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a65aa591af023864fd08a97da9974e919452cfe19cb146c8a5dc692626445dc", size = 14706163, upload-time = "2026-04-21T17:05:15.51Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/97/48ff3b297cafcc94d185243a9190836fb1b01c1b0918fff64e941e973cc9/mypy-1.20.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4fef51b01e638974a6e69885687e9bd40c8d1e09a6cd291cca0619625cf1f558", size = 14938677, upload-time = "2026-04-21T17:05:39.562Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/a1/1b4233d255bdd0b38a1f284feeb1c143ca508c19184964e22f8d837ec851/mypy-1.20.2-cp314-cp314-win_amd64.whl", hash = "sha256:913485a03f1bcf5d279409a9d2b9ed565c151f61c09f29991e5faa14033da4c8", size = 11089322, upload-time = "2026-04-21T17:06:44.29Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/c2/ce7ee2ba36aeb954ba50f18fa25d9c1188578654b97d02a66a15b6f09531/mypy-1.20.2-cp314-cp314-win_arm64.whl", hash = "sha256:c3bae4f855d965b5453784300c12ffc63a548304ac7f99e55d4dc7c898673aa3", size = 10017775, upload-time = "2026-04-21T17:07:20.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/a1/9d93a7d0b5859af0ead82b4888b46df6c8797e1bc5e1e262a08518c6d48e/mypy-1.20.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2de3dcea53babc1c3237a19002bc3d228ce1833278f093b8d619e06e7cc79609", size = 15549002, upload-time = "2026-04-21T17:08:23.107Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/d2/09a6a10ee1bf0008f6c144d9676f2ca6a12512151b4e0ad0ff6c4fac5337/mypy-1.20.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:52b176444e2e5054dfcbcb8c75b0b719865c96247b37407184bbfca5c353f2c2", size = 14401942, upload-time = "2026-04-21T17:07:31.837Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/da/9594b75c3c019e805250bed3583bdf4443ff9e6ef08f97e39ae308cb06f2/mypy-1.20.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:688c3312e5dadb573a2c69c82af3a298d43ecf9e6d264e0f95df960b5f6ac19c", size = 15041649, upload-time = "2026-04-21T17:09:34.653Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/77/f75a65c278e6e8eba2071f7f5a90481891053ecc39878cc444634d892abe/mypy-1.20.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29752dbbf8cc53f89f6ac096d363314333045c257c9c75cbd189ca2de0455744", size = 15864588, upload-time = "2026-04-21T17:11:44.936Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/46/1a4e1c66e96c1a3246ddf5403d122ac9b0a8d2b7e65730b9d6533ba7a6d3/mypy-1.20.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:803203d2b6ea644982c644895c2f78b28d0e208bba7b27d9b921e0ec5eb207c6", size = 16093956, upload-time = "2026-04-21T17:10:17.683Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/2c/78a8851264dec38cd736ca5b8bc9380674df0dd0be7792f538916157716c/mypy-1.20.2-cp314-cp314t-win_amd64.whl", hash = "sha256:9bcb8aa397ff0093c824182fd76a935a9ba7ad097fcbef80ae89bf6c1731d8ec", size = 12568661, upload-time = "2026-04-21T17:11:54.473Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/01/cd7318aa03493322ce275a0e14f4f52b8896335e4e79d4fb8153a7ad2b77/mypy-1.20.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e061b58443f1736f8a37c48978d7ab581636d6ab03e3d4f99e3fa90463bb9382", size = 10389240, upload-time = "2026-04-21T17:09:42.719Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/9a/f23c163e25b11074188251b0b5a0342625fc1cdb6af604757174fa9acc9b/mypy-1.20.2-py3-none-any.whl", hash = "sha256:a94c5a76ab46c5e6257c7972b6c8cff0574201ca7dc05647e33e795d78680563", size = 2637314, upload-time = "2026-04-21T17:05:54.5Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1099,27 +1116,27 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.8"
|
||||
version = "0.15.12"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/14/b0/73cf7550861e2b4824950b8b52eebdcc5adc792a00c514406556c5b80817/ruff-0.15.8.tar.gz", hash = "sha256:995f11f63597ee362130d1d5a327a87cb6f3f5eae3094c620bcc632329a4d26e", size = 4610921, upload-time = "2026-03-26T18:39:38.675Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/92/c445b0cd6da6e7ae51e954939cb69f97e008dbe750cfca89b8cedc081be7/ruff-0.15.8-py3-none-linux_armv6l.whl", hash = "sha256:cbe05adeba76d58162762d6b239c9056f1a15a55bd4b346cfd21e26cd6ad7bc7", size = 10527394, upload-time = "2026-03-26T18:39:41.566Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/92/f1c662784d149ad1414cae450b082cf736430c12ca78367f20f5ed569d65/ruff-0.15.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d3e3d0b6ba8dca1b7ef9ab80a28e840a20070c4b62e56d675c24f366ef330570", size = 10905693, upload-time = "2026-03-26T18:39:30.364Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/f2/7a631a8af6d88bcef997eb1bf87cc3da158294c57044aafd3e17030613de/ruff-0.15.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6ee3ae5c65a42f273f126686353f2e08ff29927b7b7e203b711514370d500de3", size = 10323044, upload-time = "2026-03-26T18:39:33.37Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/18/1bf38e20914a05e72ef3b9569b1d5c70a7ef26cd188d69e9ca8ef588d5bf/ruff-0.15.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdce027ada77baa448077ccc6ebb2fa9c3c62fd110d8659d601cf2f475858d94", size = 10629135, upload-time = "2026-03-26T18:39:44.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/e9/138c150ff9af60556121623d41aba18b7b57d95ac032e177b6a53789d279/ruff-0.15.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12e617fc01a95e5821648a6df341d80456bd627bfab8a829f7cfc26a14a4b4a3", size = 10348041, upload-time = "2026-03-26T18:39:52.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/f1/5bfb9298d9c323f842c5ddeb85f1f10ef51516ac7a34ba446c9347d898df/ruff-0.15.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:432701303b26416d22ba696c39f2c6f12499b89093b61360abc34bcc9bf07762", size = 11121987, upload-time = "2026-03-26T18:39:55.195Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/11/6da2e538704e753c04e8d86b1fc55712fdbdcc266af1a1ece7a51fff0d10/ruff-0.15.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d910ae974b7a06a33a057cb87d2a10792a3b2b3b35e33d2699fdf63ec8f6b17a", size = 11951057, upload-time = "2026-03-26T18:39:19.18Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/f0/c9208c5fd5101bf87002fed774ff25a96eea313d305f1e5d5744698dc314/ruff-0.15.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2033f963c43949d51e6fdccd3946633c6b37c484f5f98c3035f49c27395a8ab8", size = 11464613, upload-time = "2026-03-26T18:40:06.301Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/22/d7f2fabdba4fae9f3b570e5605d5eb4500dcb7b770d3217dca4428484b17/ruff-0.15.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f29b989a55572fb885b77464cf24af05500806ab4edf9a0fd8977f9759d85b1", size = 11257557, upload-time = "2026-03-26T18:39:57.972Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/8c/382a9620038cf6906446b23ce8632ab8c0811b8f9d3e764f58bedd0c9a6f/ruff-0.15.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ac51d486bf457cdc985a412fb1801b2dfd1bd8838372fc55de64b1510eff4bec", size = 11169440, upload-time = "2026-03-26T18:39:22.205Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/0d/0994c802a7eaaf99380085e4e40c845f8e32a562e20a38ec06174b52ef24/ruff-0.15.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c9861eb959edab053c10ad62c278835ee69ca527b6dcd72b47d5c1e5648964f6", size = 10605963, upload-time = "2026-03-26T18:39:46.682Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/aa/d624b86f5b0aad7cef6bbf9cd47a6a02dfdc4f72c92a337d724e39c9d14b/ruff-0.15.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8d9a5b8ea13f26ae90838afc33f91b547e61b794865374f114f349e9036835fb", size = 10357484, upload-time = "2026-03-26T18:39:49.176Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/c3/e0b7835d23001f7d999f3895c6b569927c4d39912286897f625736e1fd04/ruff-0.15.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c2a33a529fb3cbc23a7124b5c6ff121e4d6228029cba374777bd7649cc8598b8", size = 10830426, upload-time = "2026-03-26T18:40:03.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/51/ab20b322f637b369383adc341d761eaaa0f0203d6b9a7421cd6e783d81b9/ruff-0.15.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:75e5cd06b1cf3f47a3996cfc999226b19aa92e7cce682dcd62f80d7035f98f49", size = 11345125, upload-time = "2026-03-26T18:39:27.799Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/e6/90b2b33419f59d0f2c4c8a48a4b74b460709a557e8e0064cf33ad894f983/ruff-0.15.8-py3-none-win32.whl", hash = "sha256:bc1f0a51254ba21767bfa9a8b5013ca8149dcf38092e6a9eb704d876de94dc34", size = 10571959, upload-time = "2026-03-26T18:39:36.117Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/a2/ef467cb77099062317154c63f234b8a7baf7cb690b99af760c5b68b9ee7f/ruff-0.15.8-py3-none-win_amd64.whl", hash = "sha256:04f79eff02a72db209d47d665ba7ebcad609d8918a134f86cb13dd132159fc89", size = 11743893, upload-time = "2026-03-26T18:39:25.01Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/e2/77be4fff062fa78d9b2a4dea85d14785dac5f1d0c1fb58ed52331f0ebe28/ruff-0.15.8-py3-none-win_arm64.whl", hash = "sha256:cf891fa8e3bb430c0e7fac93851a5978fc99c8fa2c053b57b118972866f8e5f2", size = 11048175, upload-time = "2026-03-26T18:40:01.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -19,7 +19,7 @@ dependencies = [
|
||||
|
||||
[project.urls]
|
||||
Source = "https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint-sqlite"
|
||||
Twitter = "https://x.com/LangChain"
|
||||
Twitter = "https://x.com/langchain_oss"
|
||||
Slack = "https://www.langchain.com/join-community"
|
||||
Reddit = "https://www.reddit.com/r/LangChain/"
|
||||
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
"""Smoke tests for `BaseCheckpointSaver.get_delta_channel_history` on sqlite.
|
||||
|
||||
`SqliteSaver` (and `AsyncSqliteSaver`) deliberately don't override the
|
||||
default implementation in `BaseCheckpointSaver` — these tests pin the
|
||||
default impl to behave correctly end-to-end against a real persistent
|
||||
saver and a real `DeltaChannel`-backed graph.
|
||||
|
||||
Scenarios covered:
|
||||
|
||||
* Empty `channels` argument returns an empty mapping (no I/O).
|
||||
* On a non-trivial multi-checkpoint thread, per-channel writes come back
|
||||
oldest→newest.
|
||||
* When the walk reaches the root without ever finding a stored value,
|
||||
`seed` is omitted from the entry (consumer treats absence as "start
|
||||
empty").
|
||||
* When a `_DeltaSnapshot` blob is present at an ancestor, it is returned
|
||||
as the `seed`.
|
||||
* The async saver returns the same shape via `aget_delta_channel_history`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import operator
|
||||
from typing import Annotated, Any
|
||||
|
||||
import pytest
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
|
||||
# `langgraph` is not a dep of `langgraph-checkpoint-sqlite`. When tests run
|
||||
# in the sqlite lib's standalone CI environment without it installed, skip
|
||||
# the whole module rather than failing at import.
|
||||
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.checkpoint.sqlite import SqliteSaver # noqa: E402
|
||||
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver # noqa: E402
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Graph helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _noop(_state: Any) -> dict[str, Any]:
|
||||
return {}
|
||||
|
||||
|
||||
class _DeltaState(TypedDict):
|
||||
items: Annotated[list, DeltaChannel(operator.add)]
|
||||
|
||||
|
||||
def _delta_graph(checkpointer: Any) -> Any:
|
||||
return (
|
||||
StateGraph(_DeltaState)
|
||||
.add_node("noop", _noop)
|
||||
.add_edge(START, "noop")
|
||||
.add_edge("noop", END)
|
||||
.compile(checkpointer=checkpointer)
|
||||
)
|
||||
|
||||
|
||||
def _drive(graph: Any, config: RunnableConfig, n: int) -> None:
|
||||
for i in range(n):
|
||||
graph.invoke({"items": [f"v{i}"]}, config)
|
||||
|
||||
|
||||
async def _adrive(graph: Any, config: RunnableConfig, n: int) -> None:
|
||||
for i in range(n):
|
||||
await graph.ainvoke({"items": [f"v{i}"]}, config)
|
||||
|
||||
|
||||
def _pick_non_root(saver: Any, config: RunnableConfig) -> RunnableConfig:
|
||||
"""Return a config pointing at a checkpoint that has at least one ancestor.
|
||||
|
||||
`get_delta_channel_history` walks the parent chain — calling it on the root
|
||||
checkpoint produces `writes=[]` and no `seed`, which is uninteresting
|
||||
for the multi-step assertions below.
|
||||
"""
|
||||
history = list(saver.list(config))
|
||||
assert history, "expected non-empty history"
|
||||
# `list` yields newest→oldest; the second entry has the first entry
|
||||
# as its parent, so its parent_config is non-None.
|
||||
for tup in history:
|
||||
if tup.parent_config is not None:
|
||||
return tup.config
|
||||
raise AssertionError("no checkpoint with a parent in history")
|
||||
|
||||
|
||||
async def _apick_non_root(saver: Any, config: RunnableConfig) -> RunnableConfig:
|
||||
history = [tup async for tup in saver.alist(config)]
|
||||
assert history, "expected non-empty history"
|
||||
for tup in history:
|
||||
if tup.parent_config is not None:
|
||||
return tup.config
|
||||
raise AssertionError("no checkpoint with a parent in history")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sync: SqliteSaver
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_empty_channels_returns_empty_mapping_sync() -> None:
|
||||
"""Empty `channels` short-circuits to `{}` without touching storage."""
|
||||
with SqliteSaver.from_conn_string(":memory:") as saver:
|
||||
config: RunnableConfig = {"configurable": {"thread_id": "empty"}}
|
||||
assert saver.get_delta_channel_history(config=config, channels=[]) == {}
|
||||
|
||||
|
||||
def test_writes_history_oldest_to_newest_sync() -> None:
|
||||
"""Per-channel writes accumulated across the walk come back oldest→newest."""
|
||||
with SqliteSaver.from_conn_string(":memory:") as saver:
|
||||
config: RunnableConfig = {"configurable": {"thread_id": "history-sync"}}
|
||||
graph = _delta_graph(saver)
|
||||
_drive(graph, config, 3)
|
||||
|
||||
target_cfg = _pick_non_root(saver, config)
|
||||
result = saver.get_delta_channel_history(config=target_cfg, channels=["items"])
|
||||
|
||||
assert "items" in result
|
||||
entry = result["items"]
|
||||
assert isinstance(entry["writes"], list)
|
||||
|
||||
# If any writes were collected, their values should be in oldest→newest
|
||||
# order — i.e. tagged 'v0', 'v1', ... matching invoke order.
|
||||
write_values: list[Any] = []
|
||||
for _task_id, channel, value in entry["writes"]:
|
||||
assert channel == "items"
|
||||
write_values.extend(value if isinstance(value, list) else [value])
|
||||
|
||||
# `_drive` invokes with payloads ['v0'], ['v1'], ['v2']. Whatever
|
||||
# subset shows up in the chain must be a contiguous prefix in order.
|
||||
for idx, val in enumerate(write_values):
|
||||
assert val == f"v{idx}", (
|
||||
f"writes not in oldest→newest order: {write_values}"
|
||||
)
|
||||
|
||||
|
||||
def test_seed_present_when_snapshot_in_ancestor_sync() -> None:
|
||||
"""Inserting a `_DeltaSnapshot` blob at an ancestor → walk returns it as `seed`."""
|
||||
with SqliteSaver.from_conn_string(":memory:") as saver:
|
||||
config: RunnableConfig = {"configurable": {"thread_id": "seed-sync"}}
|
||||
graph = _delta_graph(saver)
|
||||
_drive(graph, config, 2)
|
||||
|
||||
# Find the oldest non-root checkpoint, then walk to its parent and
|
||||
# rewrite that parent's `channel_values["items"]` to a real
|
||||
# `_DeltaSnapshot`. After this surgery, calling `get_delta_channel_history`
|
||||
# at the leaf must return the snapshot value as `seed`.
|
||||
history = list(saver.list(config))
|
||||
assert len(history) >= 2
|
||||
leaf_tup = history[0]
|
||||
# Walk to an ancestor with a parent_config (any non-root will do).
|
||||
ancestor_tup = next(
|
||||
(tup for tup in history if tup.parent_config is not None), None
|
||||
)
|
||||
assert ancestor_tup is not None
|
||||
parent_cfg = ancestor_tup.parent_config
|
||||
assert parent_cfg is not None
|
||||
parent_tup = saver.get_tuple(parent_cfg)
|
||||
assert parent_tup is not None
|
||||
|
||||
snapshot_value = ["seeded", "items"]
|
||||
parent_tup.checkpoint["channel_values"]["items"] = _DeltaSnapshot(
|
||||
snapshot_value
|
||||
)
|
||||
# Make sure the channel has a version so the optimized blob lookup
|
||||
# in any future override has something to hit.
|
||||
parent_tup.checkpoint["channel_versions"].setdefault("items", 1)
|
||||
saver.put(
|
||||
parent_tup.parent_config or {"configurable": parent_cfg["configurable"]},
|
||||
parent_tup.checkpoint,
|
||||
parent_tup.metadata,
|
||||
{},
|
||||
)
|
||||
|
||||
result = saver.get_delta_channel_history(
|
||||
config=leaf_tup.config, channels=["items"]
|
||||
)
|
||||
entry = result["items"]
|
||||
assert "seed" in entry, f"expected seed to be present, got {entry}"
|
||||
seed = entry["seed"]
|
||||
assert isinstance(seed, _DeltaSnapshot), (
|
||||
f"expected _DeltaSnapshot, got {seed!r}"
|
||||
)
|
||||
assert seed.value == snapshot_value
|
||||
|
||||
|
||||
def test_seed_omitted_when_walk_reaches_root_sync() -> None:
|
||||
"""`get_delta_channel_history` on the root checkpoint → no `seed` key, no writes."""
|
||||
with SqliteSaver.from_conn_string(":memory:") as saver:
|
||||
config: RunnableConfig = {"configurable": {"thread_id": "root-sync"}}
|
||||
graph = _delta_graph(saver)
|
||||
_drive(graph, config, 1)
|
||||
|
||||
history = list(saver.list(config))
|
||||
# Root is the oldest checkpoint (no parent_config).
|
||||
root_tup = history[-1]
|
||||
assert root_tup.parent_config is None
|
||||
|
||||
result = saver.get_delta_channel_history(
|
||||
config=root_tup.config, channels=["items"]
|
||||
)
|
||||
entry = result["items"]
|
||||
assert "seed" not in entry, f"root-walk should have no seed, got {entry}"
|
||||
assert entry["writes"] == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Async: AsyncSqliteSaver
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_empty_channels_returns_empty_mapping_async() -> None:
|
||||
"""Async equivalent of the empty-channels short-circuit."""
|
||||
async with AsyncSqliteSaver.from_conn_string(":memory:") as saver:
|
||||
config: RunnableConfig = {"configurable": {"thread_id": "empty-async"}}
|
||||
assert await saver.aget_delta_channel_history(config=config, channels=[]) == {}
|
||||
|
||||
|
||||
async def test_writes_history_oldest_to_newest_async() -> None:
|
||||
"""Async equivalent of the oldest→newest ordering check."""
|
||||
async with AsyncSqliteSaver.from_conn_string(":memory:") as saver:
|
||||
config: RunnableConfig = {"configurable": {"thread_id": "history-async"}}
|
||||
graph = _delta_graph(saver)
|
||||
await _adrive(graph, config, 3)
|
||||
|
||||
target_cfg = await _apick_non_root(saver, config)
|
||||
result = await saver.aget_delta_channel_history(
|
||||
config=target_cfg, channels=["items"]
|
||||
)
|
||||
|
||||
assert "items" in result
|
||||
entry = result["items"]
|
||||
assert isinstance(entry["writes"], list)
|
||||
|
||||
write_values: list[Any] = []
|
||||
for _task_id, channel, value in entry["writes"]:
|
||||
assert channel == "items"
|
||||
write_values.extend(value if isinstance(value, list) else [value])
|
||||
|
||||
for idx, val in enumerate(write_values):
|
||||
assert val == f"v{idx}", (
|
||||
f"writes not in oldest→newest order: {write_values}"
|
||||
)
|
||||
|
||||
|
||||
async def test_seed_omitted_when_walk_reaches_root_async() -> None:
|
||||
"""Async equivalent of the root-walk seed-absence check."""
|
||||
async with AsyncSqliteSaver.from_conn_string(":memory:") as saver:
|
||||
config: RunnableConfig = {"configurable": {"thread_id": "root-async"}}
|
||||
graph = _delta_graph(saver)
|
||||
await _adrive(graph, config, 1)
|
||||
|
||||
history = [tup async for tup in saver.alist(config)]
|
||||
root_tup = history[-1]
|
||||
assert root_tup.parent_config is None
|
||||
|
||||
result = await saver.aget_delta_channel_history(
|
||||
config=root_tup.config, channels=["items"]
|
||||
)
|
||||
entry = result["items"]
|
||||
assert "seed" not in entry, f"root-walk should have no seed, got {entry}"
|
||||
assert entry["writes"] == []
|
||||
Generated
+69
-65
@@ -1,6 +1,10 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.10"
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.15'",
|
||||
"python_full_version < '3.15'",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aiosqlite"
|
||||
@@ -268,7 +272,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-checkpoint"
|
||||
version = "4.0.2"
|
||||
version = "4.1.0a4"
|
||||
source = { editable = "../checkpoint" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -490,7 +494,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "mypy"
|
||||
version = "1.20.0"
|
||||
version = "1.20.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "librt", marker = "platform_python_implementation != 'PyPy'" },
|
||||
@@ -499,51 +503,51 @@ dependencies = [
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f8/5c/b0089fe7fef0a994ae5ee07029ced0526082c6cfaaa4c10d40a10e33b097/mypy-1.20.0.tar.gz", hash = "sha256:eb96c84efcc33f0b5e0e04beacf00129dd963b67226b01c00b9dfc8affb464c3", size = 3815028, upload-time = "2026-03-31T16:55:14.959Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/04/af/e3d4b3e9ec91a0ff9aabfdb38692952acf49bbb899c2e4c29acb3a6da3ae/mypy-1.20.2.tar.gz", hash = "sha256:e8222c26daaafd9e8626dec58ae36029f82585890589576f769a650dd20fd665", size = 3817349, upload-time = "2026-04-21T17:12:28.473Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/a2/a965c8c3fcd4fa8b84ba0d46606181b0d0a1d50f274c67877f3e9ed4882c/mypy-1.20.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d99f515f95fd03a90875fdb2cca12ff074aa04490db4d190905851bdf8a549a8", size = 14430138, upload-time = "2026-03-31T16:52:37.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/6e/043477501deeb8eabbab7f1a2f6cac62cfb631806dc1d6862a04a7f5011b/mypy-1.20.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bd0212976dc57a5bfeede7c219e7cd66568a32c05c9129686dd487c059c1b88a", size = 13311282, upload-time = "2026-03-31T16:55:11.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/aa/bd89b247b83128197a214f29f0632ff3c14f54d4cd70d144d157bd7d7d6e/mypy-1.20.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8426d4d75d68714abc17a4292d922f6ba2cfb984b72c2278c437f6dae797865", size = 13750889, upload-time = "2026-03-31T16:52:02.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/9d/2860be7355c45247ccc0be1501c91176318964c2a137bd4743f58ce6200e/mypy-1.20.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02cca0761c75b42a20a2757ae58713276605eb29a08dd8a6e092aa347c4115ca", size = 14619788, upload-time = "2026-03-31T16:50:48.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/7f/3ef3e360c91f3de120f205c8ce405e9caf9fc52ef14b65d37073e322c114/mypy-1.20.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b3a49064504be59e59da664c5e149edc1f26c67c4f8e8456f6ba6aba55033018", size = 14918849, upload-time = "2026-03-31T16:51:10.478Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/72/af970dfe167ef788df7c5e6109d2ed0229f164432ce828bc9741a4250e64/mypy-1.20.0-cp310-cp310-win_amd64.whl", hash = "sha256:ebea00201737ad4391142808ed16e875add5c17f676e0912b387739f84991e13", size = 10822007, upload-time = "2026-03-31T16:50:25.268Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/94/ba9065c2ebe5421619aff684b793d953e438a8bfe31a320dd6d1e0706e81/mypy-1.20.0-cp310-cp310-win_arm64.whl", hash = "sha256:e80cf77847d0d3e6e3111b7b25db32a7f8762fd4b9a3a72ce53fe16a2863b281", size = 9756158, upload-time = "2026-03-31T16:48:36.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/1c/74cb1d9993236910286865679d1c616b136b2eae468493aa939431eda410/mypy-1.20.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4525e7010b1b38334516181c5b81e16180b8e149e6684cee5a727c78186b4e3b", size = 14343972, upload-time = "2026-03-31T16:49:04.887Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/0d/01399515eca280386e308cf57901e68d3a52af18691941b773b3380c1df8/mypy-1.20.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a17c5d0bdcca61ce24a35beb828a2d0d323d3fcf387d7512206888c900193367", size = 13225007, upload-time = "2026-03-31T16:50:08.151Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/ac/b4ba5094fb2d7fe9d2037cd8d18bbe02bcf68fd22ab9ff013f55e57ba095/mypy-1.20.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f75ff57defcd0f1d6e006d721ccdec6c88d4f6a7816eb92f1c4890d979d9ee62", size = 13663752, upload-time = "2026-03-31T16:49:26.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/a7/460678d3cf7da252d2288dad0c602294b6ec22a91932ec368cc11e44bb6e/mypy-1.20.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b503ab55a836136b619b5fc21c8803d810c5b87551af8600b72eecafb0059cb0", size = 14532265, upload-time = "2026-03-31T16:53:55.077Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/3e/051cca8166cf0438ae3ea80e0e7c030d7a8ab98dffc93f80a1aa3f23c1a2/mypy-1.20.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1973868d2adbb4584a3835780b27436f06d1dc606af5be09f187aaa25be1070f", size = 14768476, upload-time = "2026-03-31T16:50:34.587Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/66/8e02ec184f852ed5c4abb805583305db475930854e09964b55e107cdcbc4/mypy-1.20.0-cp311-cp311-win_amd64.whl", hash = "sha256:2fcedb16d456106e545b2bfd7ef9d24e70b38ec252d2a629823a4d07ebcdb69e", size = 10818226, upload-time = "2026-03-31T16:53:15.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/4b/383ad1924b28f41e4879a74151e7a5451123330d45652da359f9183bcd45/mypy-1.20.0-cp311-cp311-win_arm64.whl", hash = "sha256:379edf079ce44ac8d2805bcf9b3dd7340d4f97aad3a5e0ebabbf9d125b84b442", size = 9750091, upload-time = "2026-03-31T16:54:12.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/dd/3afa29b58c2e57c79116ed55d700721c3c3b15955e2b6251dd165d377c0e/mypy-1.20.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:002b613ae19f4ac7d18b7e168ffe1cb9013b37c57f7411984abbd3b817b0a214", size = 14509525, upload-time = "2026-03-31T16:55:01.824Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/eb/227b516ab8cad9f2a13c5e7a98d28cd6aa75e9c83e82776ae6c1c4c046c7/mypy-1.20.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a9336b5e6712f4adaf5afc3203a99a40b379049104349d747eb3e5a3aa23ac2e", size = 13326469, upload-time = "2026-03-31T16:51:41.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/d4/1ddb799860c1b5ac6117ec307b965f65deeb47044395ff01ab793248a591/mypy-1.20.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f13b3e41bce9d257eded794c0f12878af3129d80aacd8a3ee0dee51f3a978651", size = 13705953, upload-time = "2026-03-31T16:48:55.69Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/b7/54a720f565a87b893182a2a393370289ae7149e4715859e10e1c05e49154/mypy-1.20.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9804c3ad27f78e54e58b32e7cb532d128b43dbfb9f3f9f06262b821a0f6bd3f5", size = 14710363, upload-time = "2026-03-31T16:53:26.948Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/2a/74810274848d061f8a8ea4ac23aaad43bd3d8c1882457999c2e568341c57/mypy-1.20.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:697f102c5c1d526bdd761a69f17c6070f9892eebcb94b1a5963d679288c09e78", size = 14947005, upload-time = "2026-03-31T16:50:17.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/91/21b8ba75f958bcda75690951ce6fa6b7138b03471618959529d74b8544e2/mypy-1.20.0-cp312-cp312-win_amd64.whl", hash = "sha256:0ecd63f75fdd30327e4ad8b5704bd6d91fc6c1b2e029f8ee14705e1207212489", size = 10880616, upload-time = "2026-03-31T16:52:19.986Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/15/3d8198ef97c1ca03aea010cce4f1d4f3bc5d9849e8c0140111ca2ead9fdd/mypy-1.20.0-cp312-cp312-win_arm64.whl", hash = "sha256:f194db59657c58593a3c47c6dfd7bad4ef4ac12dbc94d01b3a95521f78177e33", size = 9813091, upload-time = "2026-03-31T16:53:44.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/a7/f64ea7bd592fa431cb597418b6dec4a47f7d0c36325fec7ac67bc8402b94/mypy-1.20.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b20c8b0fd5877abdf402e79a3af987053de07e6fb208c18df6659f708b535134", size = 14485344, upload-time = "2026-03-31T16:49:16.78Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/72/8927d84cfc90c6abea6e96663576e2e417589347eb538749a464c4c218a0/mypy-1.20.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:367e5c993ba34d5054d11937d0485ad6dfc60ba760fa326c01090fc256adf15c", size = 13327400, upload-time = "2026-03-31T16:53:08.02Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/4a/11ab99f9afa41aa350178d24a7d2da17043228ea10f6456523f64b5a6cf6/mypy-1.20.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f799d9db89fc00446f03281f84a221e50018fc40113a3ba9864b132895619ebe", size = 13706384, upload-time = "2026-03-31T16:52:28.577Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/79/694ca73979cfb3535ebfe78733844cd5aff2e63304f59bf90585110d975a/mypy-1.20.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:555658c611099455b2da507582ea20d2043dfdfe7f5ad0add472b1c6238b433f", size = 14700378, upload-time = "2026-03-31T16:48:45.527Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/24/a022ccab3a46e3d2cdf2e0e260648633640eb396c7e75d5a42818a8d3971/mypy-1.20.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:efe8d70949c3023698c3fca1e94527e7e790a361ab8116f90d11221421cd8726", size = 14932170, upload-time = "2026-03-31T16:49:36.038Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/9b/549228d88f574d04117e736f55958bd4908f980f9f5700a07aeb85df005b/mypy-1.20.0-cp313-cp313-win_amd64.whl", hash = "sha256:f49590891d2c2f8a9de15614e32e459a794bcba84693c2394291a2038bbaaa69", size = 10888526, upload-time = "2026-03-31T16:50:59.827Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/17/15095c0e54a8bc04d22d4ff06b2139d5f142c2e87520b4e39010c4862771/mypy-1.20.0-cp313-cp313-win_arm64.whl", hash = "sha256:76a70bf840495729be47510856b978f1b0ec7d08f257ca38c9d932720bf6b43e", size = 9816456, upload-time = "2026-03-31T16:49:59.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/0e/6ca4a84cbed9e62384bc0b2974c90395ece5ed672393e553996501625fc5/mypy-1.20.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0f42dfaab7ec1baff3b383ad7af562ab0de573c5f6edb44b2dab016082b89948", size = 14483331, upload-time = "2026-03-31T16:52:57.999Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/c5/5fe9d8a729dd9605064691816243ae6c49fde0bd28f6e5e17f6a24203c43/mypy-1.20.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:31b5dbb55293c1bd27c0fc813a0d2bb5ceef9d65ac5afa2e58f829dab7921fd5", size = 13342047, upload-time = "2026-03-31T16:54:21.555Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/33/e18bcfa338ca4e6b2771c85d4c5203e627d0c69d9de5c1a2cf2ba13320ba/mypy-1.20.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49d11c6f573a5a08f77fad13faff2139f6d0730ebed2cfa9b3d2702671dd7188", size = 13719585, upload-time = "2026-03-31T16:51:53.89Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/8d/93491ff7b79419edc7eabf95cb3b3f7490e2e574b2855c7c7e7394ff933f/mypy-1.20.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d3243c406773185144527f83be0e0aefc7bf4601b0b2b956665608bf7c98a83", size = 14685075, upload-time = "2026-03-31T16:54:04.464Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/9d/d924b38a4923f8d164bf2b4ec98bf13beaf6e10a5348b4b137eadae40a6e/mypy-1.20.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a79c1eba7ac4209f2d850f0edd0a2f8bba88cbfdfefe6fb76a19e9d4fe5e71a2", size = 14919141, upload-time = "2026-03-31T16:54:51.785Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/98/1da9977016678c0b99d43afe52ed00bb3c1a0c4c995d3e6acca1a6ebb9b4/mypy-1.20.0-cp314-cp314-win_amd64.whl", hash = "sha256:00e047c74d3ec6e71a2eb88e9ea551a2edb90c21f993aefa9e0d2a898e0bb732", size = 11050925, upload-time = "2026-03-31T16:51:30.758Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/e3/ba0b7a3143e49a9c4f5967dde6ea4bf8e0b10ecbbcca69af84027160ee89/mypy-1.20.0-cp314-cp314-win_arm64.whl", hash = "sha256:931a7630bba591593dcf6e97224a21ff80fb357e7982628d25e3c618e7f598ef", size = 10001089, upload-time = "2026-03-31T16:49:43.632Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/28/e617e67b3be9d213cda7277913269c874eb26472489f95d09d89765ce2d8/mypy-1.20.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:26c8b52627b6552f47ff11adb4e1509605f094e29815323e487fc0053ebe93d1", size = 15534710, upload-time = "2026-03-31T16:52:12.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/0c/3b5f2d3e45dc7169b811adce8451679d9430399d03b168f9b0489f43adaa/mypy-1.20.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:39362cdb4ba5f916e7976fccecaab1ba3a83e35f60fa68b64e9a70e221bb2436", size = 14393013, upload-time = "2026-03-31T16:54:41.186Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/49/edc8b0aa145cc09c1c74f7ce2858eead9329931dcbbb26e2ad40906daa4e/mypy-1.20.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34506397dbf40c15dc567635d18a21d33827e9ab29014fb83d292a8f4f8953b6", size = 15047240, upload-time = "2026-03-31T16:54:31.955Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/37/a946bb416e37a57fa752b3100fd5ede0e28df94f92366d1716555d47c454/mypy-1.20.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:555493c44a4f5a1b58d611a43333e71a9981c6dbe26270377b6f8174126a0526", size = 15858565, upload-time = "2026-03-31T16:53:36.997Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/99/7690b5b5b552db1bd4ff362e4c0eb3107b98d680835e65823fbe888c8b78/mypy-1.20.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2721f0ce49cb74a38f00c50da67cb7d36317b5eda38877a49614dc018e91c787", size = 16087874, upload-time = "2026-03-31T16:52:48.313Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/76/53e893a498138066acd28192b77495c9357e5a58cc4be753182846b43315/mypy-1.20.0-cp314-cp314t-win_amd64.whl", hash = "sha256:47781555a7aa5fedcc2d16bcd72e0dc83eb272c10dd657f9fb3f9cc08e2e6abb", size = 12572380, upload-time = "2026-03-31T16:49:52.454Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/9c/6dbdae21f01b7aacddc2c0bbf3c5557aa547827fdf271770fe1e521e7093/mypy-1.20.0-cp314-cp314t-win_arm64.whl", hash = "sha256:c70380fe5d64010f79fb863b9081c7004dd65225d2277333c219d93a10dad4dd", size = 10381174, upload-time = "2026-03-31T16:51:20.179Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/66/4d734961ce167f0fd8380769b3b7c06dbdd6ff54c2190f3f2ecd22528158/mypy-1.20.0-py3-none-any.whl", hash = "sha256:a6e0641147cbfa7e4e94efdb95c2dab1aff8cfc159ded13e07f308ddccc8c48e", size = 2636365, upload-time = "2026-03-31T16:51:44.911Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/97/ce2502df2cecf2ef997b6c6527c4a223b92feb9e7b790cdc8dcd683f3a8a/mypy-1.20.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cf5a4db6dca263010e2c7bff081c89383c72d187ba2cf4c44759aac970e2f0c4", size = 14457059, upload-time = "2026-04-21T17:06:14.935Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/34/417ee60b822cc80c0f3dc9f495ad7fd8dbb8d8b2cf4baf22d4046d25d01d/mypy-1.20.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7b0e817b518bff7facd7f85ea05b643ad8bdcce684cf29784987b0a7c8e1f997", size = 13346816, upload-time = "2026-04-21T17:10:41.433Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/85/e20951978702df58379d0bcc2e8f7ccdca4e78cd7dc66dd3ddbf9b29d517/mypy-1.20.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97d7b9a485b40f8ca425460e89bf1da2814625b2da627c0dcc6aa46c92631d14", size = 13772593, upload-time = "2026-04-21T17:08:11.24Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/a5/5441a13259ec516c56fd5de0fd96a69a9590ae6c5e5d3e5174aa84b97973/mypy-1.20.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e1c12f6d2db3d78b909b5f77513c11eb7f2dd2782b96a3ab6dffc7d44575c99", size = 14656635, upload-time = "2026-04-21T17:09:54.042Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/51/b89c69157c5e1f19fd125a65d991166a26906e7902f026f00feebbcfa2b9/mypy-1.20.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:89dce27e142d25ffbc154c1819383b69f2e9234dc4ed4766f42e0e8cb264ab5c", size = 14943278, upload-time = "2026-04-21T17:09:15.599Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/44/6b0eeecfe96d7cce1d71c66b8e03cb304aa70ec11f1955dc1d6b46aca3c3/mypy-1.20.2-cp310-cp310-win_amd64.whl", hash = "sha256:f376e37f9bf2a946872fc5fd1199c99310748e3c26c7a26683f13f8bdb756cbd", size = 10851915, upload-time = "2026-04-21T17:06:03.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/36/6593dc88545d75fb96416184be5392da5e2a8e8c2802a8597913e16ae25c/mypy-1.20.2-cp310-cp310-win_arm64.whl", hash = "sha256:6e2b469efd811707bc530fd1effef0f5d6eebcb7fe376affae69025da4b979a2", size = 9786676, upload-time = "2026-04-21T17:07:02.035Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/4d/9ebeae211caccbdaddde7ed5e31dfcf57faac66be9b11deb1dc6526c8078/mypy-1.20.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4077797a273e56e8843d001e9dfe4ba10e33323d6ade647ff260e5cd97d9758c", size = 14371307, upload-time = "2026-04-21T17:08:56.442Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/d7/93473d34b61f04fac1aecc01368485c89c5c4af7a4b9a0cab5d77d04b63f/mypy-1.20.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cdecf62abcc4292500d7858aeae87a1f8f1150f4c4dd08fb0b336ee79b2a6df3", size = 13258917, upload-time = "2026-04-21T17:05:50.978Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/30/3dd903e8bafb7b5f7bf87fcd58f8382086dea2aa19f0a7b357f21f63071b/mypy-1.20.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c566c3a88b6ece59b3d70f65bedef17304f48eb52ff040a6a18214e1917b3254", size = 13700516, upload-time = "2026-04-21T17:11:33.161Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/05/c61a140aba4c729ac7bc99ae26fc627c78a6e08f5b9dd319244ea71a3d7e/mypy-1.20.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0deb80d062b2479f2c87ae568f89845afc71d11bc41b04179e58165fd9f31e98", size = 14562889, upload-time = "2026-04-21T17:05:27.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/87/da78243742ffa8a36d98c3010f0d829f93d5da4e6786f1a1a6f2ad616502/mypy-1.20.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bba9ad231e92a3e424b3e56b65aa17704993425bba97e302c832f9466bb85bac", size = 14803844, upload-time = "2026-04-21T17:10:06.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/52/10a1ddf91b40f843943a3c6db51e2df59c9e237f29d355e95eaab427461f/mypy-1.20.2-cp311-cp311-win_amd64.whl", hash = "sha256:baf593f2765fa3a6b1ef95807dbaa3d25b594f6a52adcc506a6b9cb115e1be67", size = 10846300, upload-time = "2026-04-21T17:12:23.886Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/02/f9a4415b664c53bd34d6709be59da303abcae986dc4ac847b402edb6fa1e/mypy-1.20.2-cp311-cp311-win_arm64.whl", hash = "sha256:20175a1c0f49863946ec20b7f63255768058ac4f07d2b9ded6a6b46cfb5a9100", size = 9779498, upload-time = "2026-04-21T17:09:23.695Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/4e/7560e4528db9e9b147e4c0f22660466bf30a0a1fe3d63d1b9d3b0fd354ee/mypy-1.20.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4dbfcf869f6b0517f70cf0030ba6ea1d6645e132337a7d5204a18d8d5636c02b", size = 14539393, upload-time = "2026-04-21T17:07:12.52Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/d9/34a5efed8124f5a9234f55ac6a4ced4201e2c5b81e1109c49ad23190ec8c/mypy-1.20.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b6481b228d072315b053210b01ac320e1be243dc17f9e5887ef167f23f5fae4", size = 13361642, upload-time = "2026-04-21T17:06:53.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/14/eb377acf78c03c92d566a1510cda8137348215b5335085ef662ab82ecd3a/mypy-1.20.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34397cdced6b90b836e38182076049fdb41424322e0b0728c946b0939ebdf9f6", size = 13740347, upload-time = "2026-04-21T17:12:04.73Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/94/7e4634a32b641aa1c112422eed1bbece61ee16205f674190e8b536f884de/mypy-1.20.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5da6976f20cae27059ea8d0c86e7cef3de720e04c4bb9ee18e3690fdb792066", size = 14734042, upload-time = "2026-04-21T17:07:43.16Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/f3/f7e62395cb7f434541b4491a01149a4439e28ace4c0c632bbf5431e92d1f/mypy-1.20.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:56908d7e08318d39f85b1f0c6cfd47b0cac1a130da677630dac0de3e0623e102", size = 14964958, upload-time = "2026-04-21T17:11:00.665Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/0d/47e3c3a0ec2a876e35aeac365df3cac7776c36bbd4ed18cc521e1b9d255b/mypy-1.20.2-cp312-cp312-win_amd64.whl", hash = "sha256:d52ad8d78522da1d308789df651ee5379088e77c76cb1994858d40a426b343b9", size = 10911340, upload-time = "2026-04-21T17:10:49.179Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/b2/6c852d72e0ea8b01f49da817fb52539993cde327e7d010e0103dc12d0dac/mypy-1.20.2-cp312-cp312-win_arm64.whl", hash = "sha256:785b08db19c9f214dc37d65f7c165d19a30fcecb48abfa30f31b01b5acaabb58", size = 9833947, upload-time = "2026-04-21T17:09:05.267Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/c4/b93812d3a192c9bcf5df405bd2f30277cd0e48106a14d1023c7f6ed6e39b/mypy-1.20.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:edfbfca868cdd6bd8d974a60f8a3682f5565d3f5c99b327640cedd24c4264026", size = 14524670, upload-time = "2026-04-21T17:10:30.737Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/47/42c122501bff18eaf1e8f457f5c017933452d8acdc52918a9f59f6812955/mypy-1.20.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e2877a02380adfcdbc69071a0f74d6e9dbbf593c0dc9d174e1f223ffd5281943", size = 13336218, upload-time = "2026-04-21T17:08:44.069Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/8f/75bbc92f41725fbd585fb17b440b1119b576105df1013622983e18640a93/mypy-1.20.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7488448de6007cd5177c6cea0517ac33b4c0f5ee9b5e9f2be51ce75511a85517", size = 13724906, upload-time = "2026-04-21T17:08:01.02Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/32/4c49da27a606167391ff0c39aa955707a00edc500572e562f7c36c08a71f/mypy-1.20.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb9c2fa06887e21d6a3a868762acb82aec34e2c6fd0174064f27c93ede68ad15", size = 14726046, upload-time = "2026-04-21T17:11:22.354Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/fc/4e354a1bd70216359deb0c9c54847ee6b32ef78dfb09f5131ff99b494078/mypy-1.20.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d56a78b646f2e3daa865bc70cd5ec5a46c50045801ca8ff17a0c43abc97e3ee", size = 14955587, upload-time = "2026-04-21T17:12:16.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/b2/c0f2056e9eb8f08c62cafd9715e4584b89132bdc832fcf85d27d07b5f3e5/mypy-1.20.2-cp313-cp313-win_amd64.whl", hash = "sha256:2a4102b03bb7481d9a91a6da8d174740c9c8c4401024684b9ca3b7cc5e49852f", size = 10922681, upload-time = "2026-04-21T17:06:35.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/14/065e333721f05de8ef683d0aa804c23026bcc287446b61cac657b902ccac/mypy-1.20.2-cp313-cp313-win_arm64.whl", hash = "sha256:a95a9248b0c6fd933a442c03c3b113c3b61320086b88e2c444676d3fd1ca3330", size = 9830560, upload-time = "2026-04-21T17:07:51.023Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/d1/b4ec96b0ecc620a4443570c6e95c867903428cfcde4206518eafdd5880c3/mypy-1.20.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:419413398fe250aae057fd2fe50166b61077083c9b82754c341cf4fd73038f30", size = 14524561, upload-time = "2026-04-21T17:06:27.325Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/63/d2c2ff4fa66bc49477d32dfa26e8a167ba803ea6a69c5efb416036909d30/mypy-1.20.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e73c07f23009962885c197ccb9b41356a30cc0e5a1d0c2ea8fd8fb1362d7f924", size = 13363883, upload-time = "2026-04-21T17:11:11.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/56/983916806bf4eddeaaa2c9230903c3669c6718552a921154e1c5182c701f/mypy-1.20.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c64e5973df366b747646fc98da921f9d6eba9716d57d1db94a83c026a08e0fb", size = 13742945, upload-time = "2026-04-21T17:08:34.181Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/65/0cd9285ab010ee8214c83d67c6b49417c40d86ce46f1aa109457b5a9b8d7/mypy-1.20.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a65aa591af023864fd08a97da9974e919452cfe19cb146c8a5dc692626445dc", size = 14706163, upload-time = "2026-04-21T17:05:15.51Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/97/48ff3b297cafcc94d185243a9190836fb1b01c1b0918fff64e941e973cc9/mypy-1.20.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4fef51b01e638974a6e69885687e9bd40c8d1e09a6cd291cca0619625cf1f558", size = 14938677, upload-time = "2026-04-21T17:05:39.562Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/a1/1b4233d255bdd0b38a1f284feeb1c143ca508c19184964e22f8d837ec851/mypy-1.20.2-cp314-cp314-win_amd64.whl", hash = "sha256:913485a03f1bcf5d279409a9d2b9ed565c151f61c09f29991e5faa14033da4c8", size = 11089322, upload-time = "2026-04-21T17:06:44.29Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/c2/ce7ee2ba36aeb954ba50f18fa25d9c1188578654b97d02a66a15b6f09531/mypy-1.20.2-cp314-cp314-win_arm64.whl", hash = "sha256:c3bae4f855d965b5453784300c12ffc63a548304ac7f99e55d4dc7c898673aa3", size = 10017775, upload-time = "2026-04-21T17:07:20.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/a1/9d93a7d0b5859af0ead82b4888b46df6c8797e1bc5e1e262a08518c6d48e/mypy-1.20.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2de3dcea53babc1c3237a19002bc3d228ce1833278f093b8d619e06e7cc79609", size = 15549002, upload-time = "2026-04-21T17:08:23.107Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/d2/09a6a10ee1bf0008f6c144d9676f2ca6a12512151b4e0ad0ff6c4fac5337/mypy-1.20.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:52b176444e2e5054dfcbcb8c75b0b719865c96247b37407184bbfca5c353f2c2", size = 14401942, upload-time = "2026-04-21T17:07:31.837Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/da/9594b75c3c019e805250bed3583bdf4443ff9e6ef08f97e39ae308cb06f2/mypy-1.20.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:688c3312e5dadb573a2c69c82af3a298d43ecf9e6d264e0f95df960b5f6ac19c", size = 15041649, upload-time = "2026-04-21T17:09:34.653Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/77/f75a65c278e6e8eba2071f7f5a90481891053ecc39878cc444634d892abe/mypy-1.20.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29752dbbf8cc53f89f6ac096d363314333045c257c9c75cbd189ca2de0455744", size = 15864588, upload-time = "2026-04-21T17:11:44.936Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/46/1a4e1c66e96c1a3246ddf5403d122ac9b0a8d2b7e65730b9d6533ba7a6d3/mypy-1.20.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:803203d2b6ea644982c644895c2f78b28d0e208bba7b27d9b921e0ec5eb207c6", size = 16093956, upload-time = "2026-04-21T17:10:17.683Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/2c/78a8851264dec38cd736ca5b8bc9380674df0dd0be7792f538916157716c/mypy-1.20.2-cp314-cp314t-win_amd64.whl", hash = "sha256:9bcb8aa397ff0093c824182fd76a935a9ba7ad097fcbef80ae89bf6c1731d8ec", size = 12568661, upload-time = "2026-04-21T17:11:54.473Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/01/cd7318aa03493322ce275a0e14f4f52b8896335e4e79d4fb8153a7ad2b77/mypy-1.20.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e061b58443f1736f8a37c48978d7ab581636d6ab03e3d4f99e3fa90463bb9382", size = 10389240, upload-time = "2026-04-21T17:09:42.719Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/9a/f23c163e25b11074188251b0b5a0342625fc1cdb6af604757174fa9acc9b/mypy-1.20.2-py3-none-any.whl", hash = "sha256:a94c5a76ab46c5e6257c7972b6c8cff0574201ca7dc05647e33e795d78680563", size = 2637314, upload-time = "2026-04-21T17:05:54.5Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1023,27 +1027,27 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.8"
|
||||
version = "0.15.12"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/14/b0/73cf7550861e2b4824950b8b52eebdcc5adc792a00c514406556c5b80817/ruff-0.15.8.tar.gz", hash = "sha256:995f11f63597ee362130d1d5a327a87cb6f3f5eae3094c620bcc632329a4d26e", size = 4610921, upload-time = "2026-03-26T18:39:38.675Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/92/c445b0cd6da6e7ae51e954939cb69f97e008dbe750cfca89b8cedc081be7/ruff-0.15.8-py3-none-linux_armv6l.whl", hash = "sha256:cbe05adeba76d58162762d6b239c9056f1a15a55bd4b346cfd21e26cd6ad7bc7", size = 10527394, upload-time = "2026-03-26T18:39:41.566Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/92/f1c662784d149ad1414cae450b082cf736430c12ca78367f20f5ed569d65/ruff-0.15.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d3e3d0b6ba8dca1b7ef9ab80a28e840a20070c4b62e56d675c24f366ef330570", size = 10905693, upload-time = "2026-03-26T18:39:30.364Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/f2/7a631a8af6d88bcef997eb1bf87cc3da158294c57044aafd3e17030613de/ruff-0.15.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6ee3ae5c65a42f273f126686353f2e08ff29927b7b7e203b711514370d500de3", size = 10323044, upload-time = "2026-03-26T18:39:33.37Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/18/1bf38e20914a05e72ef3b9569b1d5c70a7ef26cd188d69e9ca8ef588d5bf/ruff-0.15.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdce027ada77baa448077ccc6ebb2fa9c3c62fd110d8659d601cf2f475858d94", size = 10629135, upload-time = "2026-03-26T18:39:44.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/e9/138c150ff9af60556121623d41aba18b7b57d95ac032e177b6a53789d279/ruff-0.15.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12e617fc01a95e5821648a6df341d80456bd627bfab8a829f7cfc26a14a4b4a3", size = 10348041, upload-time = "2026-03-26T18:39:52.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/f1/5bfb9298d9c323f842c5ddeb85f1f10ef51516ac7a34ba446c9347d898df/ruff-0.15.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:432701303b26416d22ba696c39f2c6f12499b89093b61360abc34bcc9bf07762", size = 11121987, upload-time = "2026-03-26T18:39:55.195Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/11/6da2e538704e753c04e8d86b1fc55712fdbdcc266af1a1ece7a51fff0d10/ruff-0.15.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d910ae974b7a06a33a057cb87d2a10792a3b2b3b35e33d2699fdf63ec8f6b17a", size = 11951057, upload-time = "2026-03-26T18:39:19.18Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/f0/c9208c5fd5101bf87002fed774ff25a96eea313d305f1e5d5744698dc314/ruff-0.15.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2033f963c43949d51e6fdccd3946633c6b37c484f5f98c3035f49c27395a8ab8", size = 11464613, upload-time = "2026-03-26T18:40:06.301Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/22/d7f2fabdba4fae9f3b570e5605d5eb4500dcb7b770d3217dca4428484b17/ruff-0.15.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f29b989a55572fb885b77464cf24af05500806ab4edf9a0fd8977f9759d85b1", size = 11257557, upload-time = "2026-03-26T18:39:57.972Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/8c/382a9620038cf6906446b23ce8632ab8c0811b8f9d3e764f58bedd0c9a6f/ruff-0.15.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ac51d486bf457cdc985a412fb1801b2dfd1bd8838372fc55de64b1510eff4bec", size = 11169440, upload-time = "2026-03-26T18:39:22.205Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/0d/0994c802a7eaaf99380085e4e40c845f8e32a562e20a38ec06174b52ef24/ruff-0.15.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c9861eb959edab053c10ad62c278835ee69ca527b6dcd72b47d5c1e5648964f6", size = 10605963, upload-time = "2026-03-26T18:39:46.682Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/aa/d624b86f5b0aad7cef6bbf9cd47a6a02dfdc4f72c92a337d724e39c9d14b/ruff-0.15.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8d9a5b8ea13f26ae90838afc33f91b547e61b794865374f114f349e9036835fb", size = 10357484, upload-time = "2026-03-26T18:39:49.176Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/c3/e0b7835d23001f7d999f3895c6b569927c4d39912286897f625736e1fd04/ruff-0.15.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c2a33a529fb3cbc23a7124b5c6ff121e4d6228029cba374777bd7649cc8598b8", size = 10830426, upload-time = "2026-03-26T18:40:03.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/51/ab20b322f637b369383adc341d761eaaa0f0203d6b9a7421cd6e783d81b9/ruff-0.15.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:75e5cd06b1cf3f47a3996cfc999226b19aa92e7cce682dcd62f80d7035f98f49", size = 11345125, upload-time = "2026-03-26T18:39:27.799Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/e6/90b2b33419f59d0f2c4c8a48a4b74b460709a557e8e0064cf33ad894f983/ruff-0.15.8-py3-none-win32.whl", hash = "sha256:bc1f0a51254ba21767bfa9a8b5013ca8149dcf38092e6a9eb704d876de94dc34", size = 10571959, upload-time = "2026-03-26T18:39:36.117Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/a2/ef467cb77099062317154c63f234b8a7baf7cb690b99af760c5b68b9ee7f/ruff-0.15.8-py3-none-win_amd64.whl", hash = "sha256:04f79eff02a72db209d47d665ba7ebcad609d8918a134f86cb13dd132159fc89", size = 11743893, upload-time = "2026-03-26T18:39:25.01Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/e2/77be4fff062fa78d9b2a4dea85d14785dac5f1d0c1fb58ed52331f0ebe28/ruff-0.15.8-py3-none-win_arm64.whl", hash = "sha256:cf891fa8e3bb430c0e7fac93851a5978fc99c8fa2c053b57b118972866f8e5f2", size = 11048175, upload-time = "2026-03-26T18:40:01.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
import copy
|
||||
import logging
|
||||
from collections.abc import AsyncIterator, Collection, Iterator, Mapping, Sequence
|
||||
from typing import ( # noqa: UP035
|
||||
from typing import (
|
||||
Any,
|
||||
Generic,
|
||||
Literal,
|
||||
@@ -13,6 +13,7 @@ from typing import ( # noqa: UP035
|
||||
)
|
||||
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from typing_extensions import NotRequired
|
||||
|
||||
from langgraph.checkpoint.base.id import uuid6
|
||||
from langgraph.checkpoint.serde.base import SerializerProtocol, maybe_add_typed_methods
|
||||
@@ -28,6 +29,8 @@ from langgraph.checkpoint.serde.types import (
|
||||
|
||||
V = TypeVar("V", int, float, str)
|
||||
PendingWrite = tuple[str, str, Any]
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -57,6 +60,16 @@ class CheckpointMetadata(TypedDict, total=False):
|
||||
"""
|
||||
run_id: str
|
||||
"""The ID of the run that created this checkpoint."""
|
||||
delta_updates_since_snapshot: dict[str, int]
|
||||
"""Per-channel update count since the last `_DeltaSnapshot` was written.
|
||||
|
||||
Maps channel name → number of supersteps that wrote to this channel
|
||||
since its last snapshot blob. Used by `pregel.create_checkpoint` to
|
||||
decide when to write the next snapshot (when the count reaches the
|
||||
channel's `snapshot_frequency`, snapshot fires and the count resets
|
||||
to 0). Absent on threads that don't use delta channels. Version-format
|
||||
independent — works for int, float, and string version schemes.
|
||||
"""
|
||||
|
||||
|
||||
ChannelVersions = dict[str, str | int | float]
|
||||
@@ -119,6 +132,28 @@ class CheckpointTuple(NamedTuple):
|
||||
pending_writes: list[PendingWrite] | None = None
|
||||
|
||||
|
||||
class DeltaChannelHistory(TypedDict):
|
||||
"""Per-channel result entry from `BaseCheckpointSaver.get_delta_channel_history`.
|
||||
|
||||
Storage-level view of what one channel contributed across the ancestor
|
||||
chain of a target checkpoint:
|
||||
|
||||
* `writes` — on-path deltas oldest→newest as `PendingWrite` tuples.
|
||||
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.
|
||||
* `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
|
||||
"start empty"). Typically a `_DeltaSnapshot` for delta channels with
|
||||
finite snapshot frequency, or a plain value for threads migrated
|
||||
from a pre-delta channel type.
|
||||
"""
|
||||
|
||||
writes: list[PendingWrite]
|
||||
seed: NotRequired[Any]
|
||||
|
||||
|
||||
class BaseCheckpointSaver(Generic[V]):
|
||||
"""Base class for creating a graph checkpointer.
|
||||
|
||||
@@ -457,6 +492,102 @@ class BaseCheckpointSaver(Generic[V]):
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def get_delta_channel_history(
|
||||
self, *, config: RunnableConfig, channels: Sequence[str]
|
||||
) -> Mapping[str, DeltaChannelHistory]:
|
||||
"""Walk the parent chain returning per-channel writes + seed.
|
||||
|
||||
For each requested channel, walks ancestors of the checkpoint
|
||||
identified by `config` (following `parent_config`) and accumulates
|
||||
`pending_writes` for that channel. The walk terminates per-channel
|
||||
at the nearest ancestor whose `channel_values[ch]` is populated;
|
||||
that value is returned as `seed`. If the walk reaches the root
|
||||
without finding a stored value, `seed` is omitted from that
|
||||
channel's entry — the consumer treats the absence as "start
|
||||
empty."
|
||||
|
||||
Walks the **parent chain** (not `list(before=...)`): for forked
|
||||
threads, only on-path ancestors contribute.
|
||||
|
||||
The default implementation walks `get_tuple` + `parent_config`
|
||||
once for all channels — each ancestor visited once, not once per
|
||||
channel. Savers with direct storage access (`InMemorySaver`,
|
||||
`PostgresSaver`) override for performance; the return contract is
|
||||
fixed here.
|
||||
|
||||
Args:
|
||||
config: Configuration identifying the target checkpoint.
|
||||
channels: Channel names to walk for. Empty → empty mapping.
|
||||
|
||||
Returns:
|
||||
Per-channel `DeltaChannelHistory` for every name in `channels`.
|
||||
"""
|
||||
if not channels:
|
||||
return {}
|
||||
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)
|
||||
cursor_config: RunnableConfig | None = (
|
||||
target_tuple.parent_config if target_tuple else None
|
||||
)
|
||||
while cursor_config is not None and remaining:
|
||||
tup = self.get_tuple(cursor_config)
|
||||
if tup is None:
|
||||
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
|
||||
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]
|
||||
) -> Mapping[str, DeltaChannelHistory]:
|
||||
"""Async version of `get_delta_channel_history`."""
|
||||
if not channels:
|
||||
return {}
|
||||
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)
|
||||
cursor_config: RunnableConfig | None = (
|
||||
target_tuple.parent_config if target_tuple else None
|
||||
)
|
||||
while cursor_config is not None and remaining:
|
||||
tup = await self.aget_tuple(cursor_config)
|
||||
if tup is None:
|
||||
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
|
||||
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
|
||||
|
||||
def get_next_version(self, current: V | None, channel: None) -> V:
|
||||
"""Generate the next version ID for a channel.
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import pickle
|
||||
import random
|
||||
import shutil
|
||||
from collections import defaultdict
|
||||
from collections.abc import AsyncIterator, Iterator, Sequence
|
||||
from collections.abc import AsyncIterator, Iterator, Mapping, Sequence
|
||||
from contextlib import AbstractAsyncContextManager, AbstractContextManager, ExitStack
|
||||
from types import TracebackType
|
||||
from typing import Any
|
||||
@@ -20,6 +20,8 @@ from langgraph.checkpoint.base import (
|
||||
Checkpoint,
|
||||
CheckpointMetadata,
|
||||
CheckpointTuple,
|
||||
DeltaChannelHistory,
|
||||
PendingWrite,
|
||||
SerializerProtocol,
|
||||
get_checkpoint_id,
|
||||
get_checkpoint_metadata,
|
||||
@@ -121,16 +123,115 @@ class InMemorySaver(
|
||||
return self.stack.__exit__(__exc_type, __exc_value, __traceback)
|
||||
|
||||
def _load_blobs(
|
||||
self, thread_id: str, checkpoint_ns: str, versions: ChannelVersions
|
||||
self,
|
||||
thread_id: str,
|
||||
checkpoint_ns: str,
|
||||
versions: ChannelVersions,
|
||||
) -> dict[str, Any]:
|
||||
channel_values: dict[str, Any] = {}
|
||||
for k, v in versions.items():
|
||||
kk = (thread_id, checkpoint_ns, k, v)
|
||||
if kk in self.blobs:
|
||||
vv = self.blobs[kk]
|
||||
if vv[0] != "empty":
|
||||
channel_values[k] = self.serde.loads_typed(vv)
|
||||
return channel_values
|
||||
result: dict[str, Any] = {}
|
||||
for k, ver in versions.items():
|
||||
kk = (thread_id, checkpoint_ns, k, ver)
|
||||
if kk not in self.blobs:
|
||||
continue
|
||||
vv = self.blobs[kk]
|
||||
if vv[0] == "empty":
|
||||
continue
|
||||
result[k] = self.serde.loads_typed(vv)
|
||||
return result
|
||||
|
||||
def get_delta_channel_history(
|
||||
self, *, config: RunnableConfig, channels: Sequence[str]
|
||||
) -> Mapping[str, DeltaChannelHistory]:
|
||||
"""Override: walk the parent chain ONCE for all requested channels.
|
||||
|
||||
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 blobs subsume their ancestor's pending
|
||||
writes (the value already includes them); `_DeltaSnapshot` blobs
|
||||
do not (snapshot is the value AT that ancestor, prior to its own
|
||||
pending writes that produce the child).
|
||||
"""
|
||||
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
|
||||
|
||||
thread_id = config["configurable"]["thread_id"]
|
||||
checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
|
||||
checkpoint_id = config["configurable"].get("checkpoint_id", "")
|
||||
ns_storage = self.storage.get(thread_id, {}).get(checkpoint_ns, {})
|
||||
|
||||
chain: list[str] = []
|
||||
target_entry = ns_storage.get(checkpoint_id)
|
||||
current: str | None = target_entry[2] if target_entry is not None else None
|
||||
while current is not None:
|
||||
entry = ns_storage.get(current)
|
||||
if entry is None:
|
||||
break
|
||||
chain.append(current)
|
||||
_, _, parent = entry
|
||||
current = parent
|
||||
|
||||
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)
|
||||
|
||||
step_writes = self.writes.get((thread_id, checkpoint_ns, cp_id), {})
|
||||
for (_task_id, _idx), (tid, ch, serialized, _) in sorted(
|
||||
step_writes.items(), reverse=True
|
||||
):
|
||||
if ch not in remaining:
|
||||
continue
|
||||
blob_value = blob_value_by_ch.get(ch)
|
||||
if blob_value is not None and not isinstance(
|
||||
blob_value, _DeltaSnapshot
|
||||
):
|
||||
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": 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
|
||||
|
||||
async def aget_delta_channel_history(
|
||||
self, *, config: RunnableConfig, channels: Sequence[str]
|
||||
) -> Mapping[str, DeltaChannelHistory]:
|
||||
return self.get_delta_channel_history(config=config, channels=channels)
|
||||
|
||||
def get_tuple(self, config: RunnableConfig) -> CheckpointTuple | None:
|
||||
"""Get a checkpoint tuple from the in-memory storage.
|
||||
|
||||
@@ -73,6 +73,7 @@ SAFE_MSGPACK_TYPES: frozenset[tuple[str, ...]] = frozenset(
|
||||
("langchain_core.documents.base", "Document"),
|
||||
# langgraph
|
||||
("langgraph.types", "Send"),
|
||||
("langgraph.types", "TimeoutPolicy"),
|
||||
("langgraph.types", "Interrupt"),
|
||||
("langgraph.types", "Command"),
|
||||
("langgraph.types", "StateSnapshot"),
|
||||
|
||||
@@ -33,14 +33,16 @@ from langchain_core.load.load import Reviver
|
||||
from langgraph.checkpoint.serde import _msgpack as _lg_msgpack
|
||||
from langgraph.checkpoint.serde.base import SerializerProtocol
|
||||
from langgraph.checkpoint.serde.event_hooks import emit_serde_event
|
||||
from langgraph.checkpoint.serde.types import SendProtocol
|
||||
from langgraph.checkpoint.serde.types import (
|
||||
SendProtocol,
|
||||
_DeltaSnapshot,
|
||||
)
|
||||
from langgraph.store.base import Item
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from langgraph.checkpoint.serde._msgpack import (
|
||||
AllowedMsgpackModules,
|
||||
)
|
||||
from langgraph.checkpoint.serde.types import SendProtocol
|
||||
|
||||
LC_REVIVER = Reviver()
|
||||
EMPTY_BYTES = b""
|
||||
@@ -55,6 +57,19 @@ _warned_unregistered_types: set[tuple[str, str]] = set()
|
||||
_warned_blocked_types: set[tuple[str, str]] = set()
|
||||
|
||||
|
||||
def _is_safe_json_type(id_list: list[str]) -> bool:
|
||||
"""Return True if an lc=2 id refers to a type in SAFE_MSGPACK_TYPES.
|
||||
|
||||
Safe types bypass the ``allowed_json_modules`` gate so that old "json" format
|
||||
checkpoints (written before the msgpack migration) can be resumed without
|
||||
requiring users to configure an explicit allowlist.
|
||||
"""
|
||||
if len(id_list) < 2:
|
||||
return False
|
||||
module_name = ".".join(id_list[:-1])
|
||||
return (module_name, id_list[-1]) in _lg_msgpack.SAFE_MSGPACK_TYPES
|
||||
|
||||
|
||||
def _warn_once(
|
||||
seen: set[tuple[str, str]], key: tuple[str, str], msg: str, *args: object
|
||||
) -> None:
|
||||
@@ -164,19 +179,23 @@ class JsonPlusSerializer(SerializerProtocol):
|
||||
return out
|
||||
|
||||
def _reviver(self, value: dict[str, Any]) -> Any:
|
||||
if self._allowed_json_modules and (
|
||||
if (
|
||||
value.get("lc", None) == 2
|
||||
and value.get("type", None) == "constructor"
|
||||
and value.get("id", None) is not None
|
||||
):
|
||||
try:
|
||||
return self._revive_lc2(value)
|
||||
except InvalidModuleError as e:
|
||||
logger.warning(
|
||||
"Object %s is not in the deserialization allowlist.\n%s",
|
||||
value["id"],
|
||||
e.message,
|
||||
)
|
||||
id_list = value["id"]
|
||||
is_safe = _is_safe_json_type(id_list)
|
||||
if self._allowed_json_modules or is_safe:
|
||||
try:
|
||||
return self._revive_lc2(value)
|
||||
except InvalidModuleError as e:
|
||||
if not is_safe:
|
||||
logger.warning(
|
||||
"Object %s is not in the deserialization allowlist.\n%s",
|
||||
value["id"],
|
||||
e.message,
|
||||
)
|
||||
|
||||
return LC_REVIVER(value)
|
||||
|
||||
@@ -224,6 +243,13 @@ class JsonPlusSerializer(SerializerProtocol):
|
||||
method_display = "<init>"
|
||||
|
||||
dotted = ".".join(needed)
|
||||
# Safe types (the same set already allowed for msgpack deserialization) are
|
||||
# permitted without an explicit allowlist — they are known-safe LangGraph and
|
||||
# LangChain types. This restores backwards-compat for old "json" checkpoints
|
||||
# that pre-date the msgpack migration without reopening the broader security gate.
|
||||
if _is_safe_json_type(list(needed)):
|
||||
return
|
||||
|
||||
if not self._allowed_json_modules:
|
||||
raise InvalidModuleError(
|
||||
f"Refused to deserialize JSON constructor: {dotted} (method: {method_display}). "
|
||||
@@ -293,10 +319,13 @@ EXT_METHOD_SINGLE_ARG = 3
|
||||
EXT_PYDANTIC_V1 = 4
|
||||
EXT_PYDANTIC_V2 = 5
|
||||
EXT_NUMPY_ARRAY = 6
|
||||
EXT_DELTA_SNAPSHOT = 7
|
||||
|
||||
|
||||
def _msgpack_default(obj: Any) -> str | ormsgpack.Ext:
|
||||
if hasattr(obj, "model_dump") and callable(obj.model_dump): # pydantic v2
|
||||
if isinstance(obj, _DeltaSnapshot):
|
||||
return ormsgpack.Ext(EXT_DELTA_SNAPSHOT, _msgpack_enc(obj.value))
|
||||
elif hasattr(obj, "model_dump") and callable(obj.model_dump): # pydantic v2
|
||||
return ormsgpack.Ext(
|
||||
EXT_PYDANTIC_V2,
|
||||
_msgpack_enc(
|
||||
@@ -468,10 +497,13 @@ def _msgpack_default(obj: Any) -> str | ormsgpack.Ext:
|
||||
),
|
||||
)
|
||||
elif isinstance(obj, SendProtocol):
|
||||
args: tuple[Any, ...] = (obj.node, obj.arg)
|
||||
if (timeout := getattr(obj, "timeout", None)) is not None:
|
||||
args = (obj.node, obj.arg, timeout)
|
||||
return ormsgpack.Ext(
|
||||
EXT_CONSTRUCTOR_POS_ARGS,
|
||||
_msgpack_enc(
|
||||
(obj.__class__.__module__, obj.__class__.__name__, (obj.node, obj.arg)),
|
||||
(obj.__class__.__module__, obj.__class__.__name__, args),
|
||||
),
|
||||
)
|
||||
elif dataclasses.is_dataclass(obj):
|
||||
@@ -522,6 +554,15 @@ def _msgpack_default(obj: Any) -> str | ormsgpack.Ext:
|
||||
raise TypeError(f"Object of type {obj.__class__.__name__} is not serializable")
|
||||
|
||||
|
||||
def _send_from_args(args: Sequence[Any]) -> Any:
|
||||
# ya we have a cyclic import here ¯\_(ツ)_/¯
|
||||
from langgraph.types import Send # type: ignore
|
||||
|
||||
if len(args) == 2:
|
||||
return Send(*args)
|
||||
return Send(args[0], args[1], timeout=args[2])
|
||||
|
||||
|
||||
def _create_msgpack_ext_hook(
|
||||
allowed_modules: set[tuple[str, ...]] | Literal[True] | None,
|
||||
) -> Callable[[int, bytes], Any]:
|
||||
@@ -610,7 +651,13 @@ def _create_msgpack_ext_hook(
|
||||
return False
|
||||
|
||||
def ext_hook(code: int, data: bytes) -> Any:
|
||||
if code == EXT_CONSTRUCTOR_SINGLE_ARG:
|
||||
if code == EXT_DELTA_SNAPSHOT:
|
||||
return _DeltaSnapshot(
|
||||
ormsgpack.unpackb(
|
||||
data, ext_hook=ext_hook, option=ormsgpack.OPT_NON_STR_KEYS
|
||||
)
|
||||
)
|
||||
elif code == EXT_CONSTRUCTOR_SINGLE_ARG:
|
||||
try:
|
||||
tup = ormsgpack.unpackb(
|
||||
data, ext_hook=ext_hook, option=ormsgpack.OPT_NON_STR_KEYS
|
||||
@@ -631,6 +678,8 @@ def _create_msgpack_ext_hook(
|
||||
)
|
||||
if not _check_allowed(tup[0], tup[1]):
|
||||
return tup[2]
|
||||
if tup[0] == "langgraph.types" and tup[1] == "Send":
|
||||
return _send_from_args(tup[2])
|
||||
# module, name, args
|
||||
return getattr(importlib.import_module(tup[0]), tup[1])(*tup[2])
|
||||
except Exception:
|
||||
@@ -744,9 +793,7 @@ def _msgpack_ext_hook_to_json(code: int, data: bytes) -> Any:
|
||||
option=ormsgpack.OPT_NON_STR_KEYS,
|
||||
)
|
||||
if tup[0] == "langgraph.types" and tup[1] == "Send":
|
||||
from langgraph.types import Send # type: ignore
|
||||
|
||||
return Send(*tup[2])
|
||||
return _send_from_args(tup[2])
|
||||
# module, name, args
|
||||
return tup[2]
|
||||
except Exception:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from collections.abc import Sequence
|
||||
from typing import (
|
||||
Any,
|
||||
NamedTuple,
|
||||
Protocol,
|
||||
TypeVar,
|
||||
runtime_checkable,
|
||||
@@ -14,6 +15,22 @@ INTERRUPT = "__interrupt__"
|
||||
RESUME = "__resume__"
|
||||
TASKS = "__pregel_tasks"
|
||||
|
||||
|
||||
class _DeltaSnapshot(NamedTuple):
|
||||
"""Snapshot blob for a DeltaChannel with finite snapshot_frequency.
|
||||
|
||||
Stored in checkpoint_blobs via the `EXT_DELTA_SNAPSHOT` msgpack ext code.
|
||||
The ancestor walk in `BaseCheckpointSaver.get_delta_channel_history` terminates
|
||||
when it encounters this type (any non-empty channel_values entry stops
|
||||
the walk for that channel).
|
||||
|
||||
`from_checkpoint` reconstructs the channel value directly from `.value`
|
||||
without replaying writes — the snapshot IS the accumulated state.
|
||||
"""
|
||||
|
||||
value: Any
|
||||
|
||||
|
||||
Value = TypeVar("Value", covariant=True)
|
||||
Update = TypeVar("Update", contravariant=True)
|
||||
C = TypeVar("C")
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "langgraph-checkpoint"
|
||||
version = "4.0.2"
|
||||
version = "4.1.0a4"
|
||||
description = "Library with base interfaces for LangGraph checkpoint savers."
|
||||
authors = []
|
||||
requires-python = ">=3.10"
|
||||
@@ -18,7 +18,7 @@ dependencies = [
|
||||
|
||||
[project.urls]
|
||||
Source = "https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint"
|
||||
Twitter = "https://x.com/LangChain"
|
||||
Twitter = "https://x.com/langchain_oss"
|
||||
Slack = "https://www.langchain.com/join-community"
|
||||
Reddit = "https://www.reddit.com/r/LangChain/"
|
||||
|
||||
|
||||
@@ -333,6 +333,57 @@ def test_serde_jsonplus_bytes() -> None:
|
||||
assert serde.loads_typed(dumped) == some_bytes
|
||||
|
||||
|
||||
def test_lc2_json_safe_type_revives_without_allowlist() -> None:
|
||||
"""Old 'json' blobs with lc=2 for safe types must revive without an explicit allowlist.
|
||||
|
||||
Regression test for: https://github.com/langchain-ai/langgraph/issues/7498
|
||||
Threads checkpointed before v1.0.1 (pre-msgpack) stored messages as lc=2 JSON
|
||||
constructor dicts. Resuming those threads must reconstruct proper BaseMessage objects
|
||||
rather than returning raw dicts that cause MESSAGE_COERCION_FAILURE in add_messages.
|
||||
"""
|
||||
from langchain_core.messages import AIMessage
|
||||
|
||||
serde = JsonPlusSerializer() # default: _allowed_json_modules=None
|
||||
|
||||
human_blob = {
|
||||
"lc": 2,
|
||||
"type": "constructor",
|
||||
"id": ["langchain_core", "messages", "human", "HumanMessage"],
|
||||
"kwargs": {"content": "hello", "type": "human"},
|
||||
}
|
||||
ai_blob = {
|
||||
"lc": 2,
|
||||
"type": "constructor",
|
||||
"id": ["langchain_core", "messages", "ai", "AIMessage"],
|
||||
"kwargs": {"content": "hi there", "type": "ai"},
|
||||
}
|
||||
result = serde.loads_typed(("json", json.dumps([human_blob, ai_blob]).encode()))
|
||||
|
||||
assert len(result) == 2
|
||||
assert isinstance(result[0], HumanMessage), (
|
||||
f"Expected HumanMessage, got {type(result[0])}: {result[0]!r}\n"
|
||||
"lc=2 JSON blobs for safe types must deserialize without an explicit allowlist"
|
||||
)
|
||||
assert result[0].content == "hello"
|
||||
assert isinstance(result[1], AIMessage)
|
||||
assert result[1].content == "hi there"
|
||||
|
||||
|
||||
def test_lc2_json_unknown_type_stays_blocked_without_allowlist() -> None:
|
||||
"""lc=2 JSON blobs for types NOT in SAFE_MSGPACK_TYPES still require an allowlist."""
|
||||
serde = JsonPlusSerializer()
|
||||
load = {
|
||||
"lc": 2,
|
||||
"type": "constructor",
|
||||
"id": ["pprint", "pprint"],
|
||||
"kwargs": {"object": "HELLO"},
|
||||
}
|
||||
# No allowlist configured → raw dict returned (not raised, not reconstructed)
|
||||
result = serde.loads_typed(("json", json.dumps(load).encode()))
|
||||
assert isinstance(result, dict), "Unknown lc=2 type must stay as raw dict"
|
||||
assert result.get("lc") == 2
|
||||
|
||||
|
||||
def test_deserde_invalid_module() -> None:
|
||||
serde = JsonPlusSerializer()
|
||||
load = {
|
||||
|
||||
@@ -208,8 +208,6 @@ class TestMemorySaver:
|
||||
|
||||
|
||||
async def test_memory_saver() -> None:
|
||||
from langgraph.checkpoint.memory import InMemorySaver
|
||||
|
||||
memory_saver = InMemorySaver()
|
||||
assert isinstance(memory_saver, InMemorySaver)
|
||||
|
||||
@@ -320,3 +318,363 @@ def test_memory_saver_with_allowlist_proxy_isolated() -> None:
|
||||
assert direct is not None
|
||||
expected = obj.model_dump() if hasattr(obj, "model_dump") else obj.dict()
|
||||
assert direct.checkpoint["channel_values"]["foo"] == expected
|
||||
|
||||
|
||||
class TestInMemorySaverDeltaChannel:
|
||||
def test_load_blobs_omits_delta_channel(self) -> None:
|
||||
"""_load_blobs omits delta channels (stored as 'empty'); reconstruction deferred."""
|
||||
saver = InMemorySaver()
|
||||
|
||||
thread_id, ns, channel = "t1", "", "messages"
|
||||
v1 = "00000000000000000000000000000001.0000000000000000"
|
||||
|
||||
saver.blobs[(thread_id, ns, channel, v1)] = ("empty", b"")
|
||||
|
||||
result = saver._load_blobs(thread_id, ns, {channel: v1})
|
||||
assert channel not in result
|
||||
|
||||
def test_get_channel_writes_collects_ancestor_writes_only(self) -> None:
|
||||
"""`get_delta_channel_history` collects ancestor writes oldest→newest, and
|
||||
excludes writes stored at the target checkpoint itself (those are
|
||||
pending writes for the next step, applied separately by pregel).
|
||||
|
||||
When the walk reaches the root without finding any stored value for
|
||||
the channel, the per-channel entry has no `seed` key (TypedDict
|
||||
absence indicates "start empty").
|
||||
"""
|
||||
saver = InMemorySaver()
|
||||
serde = JsonPlusSerializer()
|
||||
|
||||
thread_id, ns, channel = "t1", "", "messages"
|
||||
|
||||
cp1 = empty_checkpoint()
|
||||
cp1["id"] = "cp1"
|
||||
cp2 = empty_checkpoint()
|
||||
cp2["id"] = "cp2"
|
||||
saver.storage[thread_id][ns] = {
|
||||
"cp1": (serde.dumps_typed(cp1), serde.dumps_typed({}), None),
|
||||
"cp2": (serde.dumps_typed(cp2), serde.dumps_typed({}), "cp1"),
|
||||
}
|
||||
# Writes stored at cp1 produced the cp1 snapshot; part of history.
|
||||
saver.writes[(thread_id, ns, "cp1")][("task1", 0)] = (
|
||||
"task1",
|
||||
channel,
|
||||
serde.dumps_typed({"content": "hi"}),
|
||||
"",
|
||||
)
|
||||
# Writes stored at cp2 are pending — they will produce cp3 when the
|
||||
# step that loaded cp2 completes. They MUST NOT appear in the
|
||||
# reconstructed snapshot value at cp2.
|
||||
saver.writes[(thread_id, ns, "cp2")][("task2", 0)] = (
|
||||
"task2",
|
||||
channel,
|
||||
serde.dumps_typed({"content": "pending"}),
|
||||
"",
|
||||
)
|
||||
|
||||
config: RunnableConfig = {
|
||||
"configurable": {
|
||||
"thread_id": thread_id,
|
||||
"checkpoint_ns": ns,
|
||||
"checkpoint_id": "cp2",
|
||||
}
|
||||
}
|
||||
result = saver.get_delta_channel_history(config=config, channels=[channel])[
|
||||
channel
|
||||
]
|
||||
# Walk reached the root without finding a stored value → no `seed` key.
|
||||
assert "seed" not in result
|
||||
values = [v for _, _, v in result["writes"]]
|
||||
assert values == [{"content": "hi"}]
|
||||
|
||||
def test_get_channel_writes_at_root_returns_empty(self) -> None:
|
||||
"""Reconstructing the root checkpoint's state: no ancestors → []."""
|
||||
saver = InMemorySaver()
|
||||
serde = JsonPlusSerializer()
|
||||
thread_id, ns, channel = "t1", "", "messages"
|
||||
|
||||
cp1 = empty_checkpoint()
|
||||
cp1["id"] = "cp1"
|
||||
saver.storage[thread_id][ns] = {
|
||||
"cp1": (serde.dumps_typed(cp1), serde.dumps_typed({}), None),
|
||||
}
|
||||
saver.writes[(thread_id, ns, "cp1")][("task1", 0)] = (
|
||||
"task1",
|
||||
channel,
|
||||
serde.dumps_typed({"content": "pending"}),
|
||||
"",
|
||||
)
|
||||
|
||||
config: RunnableConfig = {
|
||||
"configurable": {
|
||||
"thread_id": thread_id,
|
||||
"checkpoint_ns": ns,
|
||||
"checkpoint_id": "cp1",
|
||||
}
|
||||
}
|
||||
result = saver.get_delta_channel_history(config=config, channels=[channel])[
|
||||
channel
|
||||
]
|
||||
# No ancestors → no seed found, no writes accumulated.
|
||||
assert "seed" not in result
|
||||
assert result["writes"] == []
|
||||
|
||||
|
||||
class TestBaseFallbackGetChannelWrites:
|
||||
"""Exercises the `BaseCheckpointSaver.get_delta_channel_history` default
|
||||
implementation — the path third-party savers inherit when they don't
|
||||
override `get_delta_channel_history` themselves.
|
||||
|
||||
Regression guard for a bug where the fallback passed the caller's config
|
||||
(with `checkpoint_id`) straight to `self.list()`, which most savers
|
||||
collapse to a single row — causing the fallback to return `[]`.
|
||||
"""
|
||||
|
||||
def _build_saver_with_chain(self) -> tuple[InMemorySaver, str, str]:
|
||||
"""Build an InMemorySaver with a 3-checkpoint chain and per-step writes
|
||||
for a `messages` channel.
|
||||
|
||||
Returns `(saver, thread_id, namespace)`. The saver subclass deletes the
|
||||
InMemorySaver override so the base class fallback is exercised.
|
||||
"""
|
||||
|
||||
class _ThirdPartyStyleSaver(InMemorySaver):
|
||||
get_delta_channel_history = (
|
||||
InMemorySaver.__mro__[1].get_delta_channel_history # type: ignore[attr-defined]
|
||||
)
|
||||
aget_delta_channel_history = (
|
||||
InMemorySaver.__mro__[1].aget_delta_channel_history # type: ignore[attr-defined]
|
||||
)
|
||||
|
||||
saver = _ThirdPartyStyleSaver()
|
||||
serde = JsonPlusSerializer()
|
||||
thread_id, ns, channel = "t1", "", "messages"
|
||||
|
||||
cp0 = empty_checkpoint()
|
||||
cp0["id"] = "00000000000000000000000000000001.0000000000000000"
|
||||
cp1 = empty_checkpoint()
|
||||
cp1["id"] = "00000000000000000000000000000002.0000000000000000"
|
||||
cp2 = empty_checkpoint()
|
||||
cp2["id"] = "00000000000000000000000000000003.0000000000000000"
|
||||
saver.storage[thread_id][ns] = {
|
||||
cp0["id"]: (serde.dumps_typed(cp0), serde.dumps_typed({}), None),
|
||||
cp1["id"]: (serde.dumps_typed(cp1), serde.dumps_typed({}), cp0["id"]),
|
||||
cp2["id"]: (serde.dumps_typed(cp2), serde.dumps_typed({}), cp1["id"]),
|
||||
}
|
||||
# Writes under cp0 produced cp1's state; writes under cp1 produced cp2's.
|
||||
saver.writes[(thread_id, ns, cp0["id"])][("task1", 0)] = (
|
||||
"task1",
|
||||
channel,
|
||||
serde.dumps_typed({"content": "first"}),
|
||||
"",
|
||||
)
|
||||
saver.writes[(thread_id, ns, cp1["id"])][("task2", 0)] = (
|
||||
"task2",
|
||||
channel,
|
||||
serde.dumps_typed({"content": "second"}),
|
||||
"",
|
||||
)
|
||||
return saver, thread_id, ns
|
||||
|
||||
def test_fallback_returns_ancestor_writes_oldest_first(self) -> None:
|
||||
saver, thread_id, ns = self._build_saver_with_chain()
|
||||
target_id = "00000000000000000000000000000003.0000000000000000"
|
||||
config: RunnableConfig = {
|
||||
"configurable": {
|
||||
"thread_id": thread_id,
|
||||
"checkpoint_ns": ns,
|
||||
"checkpoint_id": target_id,
|
||||
}
|
||||
}
|
||||
|
||||
result = saver.get_delta_channel_history(config=config, channels=["messages"])[
|
||||
"messages"
|
||||
]
|
||||
|
||||
# Walk reached root without a stored value → `seed` key absent.
|
||||
assert "seed" not in result
|
||||
values = [v for _, _, v in result["writes"]]
|
||||
assert values == [{"content": "first"}, {"content": "second"}]
|
||||
|
||||
async def test_async_fallback_returns_ancestor_writes_oldest_first(self) -> None:
|
||||
saver, thread_id, ns = self._build_saver_with_chain()
|
||||
target_id = "00000000000000000000000000000003.0000000000000000"
|
||||
config: RunnableConfig = {
|
||||
"configurable": {
|
||||
"thread_id": thread_id,
|
||||
"checkpoint_ns": ns,
|
||||
"checkpoint_id": target_id,
|
||||
}
|
||||
}
|
||||
|
||||
result = (
|
||||
await saver.aget_delta_channel_history(config=config, channels=["messages"])
|
||||
)["messages"]
|
||||
|
||||
assert "seed" not in result
|
||||
values = [v for _, _, v in result["writes"]]
|
||||
assert values == [{"content": "first"}, {"content": "second"}]
|
||||
|
||||
async def test_async_fallback_concurrent_tasks_do_not_interfere(self) -> None:
|
||||
"""Regression: the re-entrancy guard must be task-local, not thread-local.
|
||||
|
||||
Two concurrent `aget_delta_channel_history` calls on the same event-loop
|
||||
thread must each see their full reconstructed writes. A
|
||||
`threading.local()` guard would let whichever task set it first
|
||||
short-circuit the other to `writes=[]`.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
saver, thread_id, ns = self._build_saver_with_chain()
|
||||
|
||||
# Force the two tasks to interleave across the `set(True)` boundary:
|
||||
# each `aget_tuple` yields control, so if the guard were thread-local
|
||||
# the second task would observe `active=True` set by the first.
|
||||
orig_aget_tuple = saver.aget_tuple
|
||||
|
||||
async def slow_aget_tuple(config: RunnableConfig) -> Any:
|
||||
await asyncio.sleep(0)
|
||||
return await orig_aget_tuple(config)
|
||||
|
||||
saver.aget_tuple = slow_aget_tuple # type: ignore[method-assign]
|
||||
|
||||
target_id = "00000000000000000000000000000003.0000000000000000"
|
||||
config: RunnableConfig = {
|
||||
"configurable": {
|
||||
"thread_id": thread_id,
|
||||
"checkpoint_ns": ns,
|
||||
"checkpoint_id": target_id,
|
||||
}
|
||||
}
|
||||
|
||||
results = await asyncio.gather(
|
||||
saver.aget_delta_channel_history(config=config, channels=["messages"]),
|
||||
saver.aget_delta_channel_history(config=config, channels=["messages"]),
|
||||
)
|
||||
|
||||
expected_values = [{"content": "first"}, {"content": "second"}]
|
||||
for result_map in results:
|
||||
result = result_map["messages"]
|
||||
assert "seed" not in result
|
||||
values = [v for _, _, v in result["writes"]]
|
||||
assert values == expected_values
|
||||
|
||||
|
||||
class TestPreDeltaBlobTerminator:
|
||||
"""Verify the pre-delta blob terminator: when the ancestor walk hits a
|
||||
checkpoint whose blob for the channel is a real value, reconstruction
|
||||
seeds from it and stops. This guards
|
||||
|
||||
* back-compat: a thread written by pre-delta code, then extended under
|
||||
delta — reconstruction must return the correct value without walking
|
||||
past the last pre-delta ancestor;
|
||||
* perf: without the terminator, every reconstruct-after-migration would
|
||||
walk all the way to the thread root.
|
||||
|
||||
Under the new public API a found seed populates `seed` in the
|
||||
`DeltaChannelHistory` TypedDict; absence of the `seed` key means the walk
|
||||
reached root without finding a stored value.
|
||||
"""
|
||||
|
||||
def _build_mixed_thread(self) -> tuple[InMemorySaver, str, str, str, str]:
|
||||
"""Three-checkpoint chain: cp1 (pre-delta, blob=[A]), cp2 (delta,
|
||||
write=B), cp3 (delta, write=C). Reconstructing at cp3 must yield
|
||||
seed=[A] + writes=[B, C].
|
||||
|
||||
Returns `(saver, thread_id, ns, channel, cp3_id)`.
|
||||
"""
|
||||
saver = InMemorySaver()
|
||||
serde = JsonPlusSerializer()
|
||||
thread_id, ns, channel = "t1", "", "messages"
|
||||
|
||||
v1 = "00000000000000000000000000000001.0"
|
||||
v2 = "00000000000000000000000000000002.0"
|
||||
v3 = "00000000000000000000000000000003.0"
|
||||
|
||||
# Pre-delta: cp1 stored a real blob for the channel.
|
||||
saver.blobs[(thread_id, ns, channel, v1)] = serde.dumps_typed(["A"])
|
||||
# Delta-era: cp2 and cp3 store "empty"; real writes in checkpoint_writes.
|
||||
saver.blobs[(thread_id, ns, channel, v2)] = ("empty", b"")
|
||||
saver.blobs[(thread_id, ns, channel, v3)] = ("empty", b"")
|
||||
|
||||
cp1 = empty_checkpoint()
|
||||
cp1["id"] = "cp1"
|
||||
cp1["channel_versions"][channel] = v1
|
||||
cp2 = empty_checkpoint()
|
||||
cp2["id"] = "cp2"
|
||||
cp2["channel_versions"][channel] = v2
|
||||
cp3 = empty_checkpoint()
|
||||
cp3["id"] = "cp3"
|
||||
cp3["channel_versions"][channel] = v3
|
||||
|
||||
saver.storage[thread_id][ns] = {
|
||||
"cp1": (serde.dumps_typed(cp1), serde.dumps_typed({}), None),
|
||||
"cp2": (serde.dumps_typed(cp2), serde.dumps_typed({}), "cp1"),
|
||||
"cp3": (serde.dumps_typed(cp3), serde.dumps_typed({}), "cp2"),
|
||||
}
|
||||
# Write under cp1 would be from the pre-delta era and MUST be ignored
|
||||
# (the blob already captures it). We add one and assert it is not
|
||||
# folded into the reconstructed result.
|
||||
saver.writes[(thread_id, ns, "cp1")][("task0", 0)] = (
|
||||
"task0",
|
||||
channel,
|
||||
serde.dumps_typed("PRE-DELTA-WRITE"),
|
||||
"",
|
||||
)
|
||||
saver.writes[(thread_id, ns, "cp2")][("task2", 0)] = (
|
||||
"task2",
|
||||
channel,
|
||||
serde.dumps_typed("B"),
|
||||
"",
|
||||
)
|
||||
saver.writes[(thread_id, ns, "cp3")][("task3", 0)] = (
|
||||
"task3",
|
||||
channel,
|
||||
serde.dumps_typed("PENDING-AT-TARGET"),
|
||||
"",
|
||||
)
|
||||
return saver, thread_id, ns, channel, "cp3"
|
||||
|
||||
def test_seed_from_pre_delta_ancestor_blob(self) -> None:
|
||||
saver, thread_id, ns, channel, target = self._build_mixed_thread()
|
||||
config: RunnableConfig = {
|
||||
"configurable": {
|
||||
"thread_id": thread_id,
|
||||
"checkpoint_ns": ns,
|
||||
"checkpoint_id": target,
|
||||
}
|
||||
}
|
||||
|
||||
result = saver.get_delta_channel_history(config=config, channels=[channel])[
|
||||
channel
|
||||
]
|
||||
|
||||
# Seed came from the pre-delta blob at cp1.
|
||||
assert result["seed"] == ["A"]
|
||||
# Delta-era writes from cp2 replay through the reducer on top of seed.
|
||||
# cp3 is the target — its own write is pending for the NEXT step and
|
||||
# must be excluded.
|
||||
values = [v for _, _, v in result["writes"]]
|
||||
assert values == ["B"]
|
||||
|
||||
def test_pre_delta_blob_terminates_walk_before_older_writes(self) -> None:
|
||||
"""Writes stored at the pre-delta ancestor itself must not be replayed
|
||||
(the blob subsumes them)."""
|
||||
saver, thread_id, ns, channel, target = self._build_mixed_thread()
|
||||
config: RunnableConfig = {
|
||||
"configurable": {
|
||||
"thread_id": thread_id,
|
||||
"checkpoint_ns": ns,
|
||||
"checkpoint_id": target,
|
||||
}
|
||||
}
|
||||
|
||||
result = saver.get_delta_channel_history(config=config, channels=[channel])[
|
||||
channel
|
||||
]
|
||||
|
||||
values = [v for _, _, v in result["writes"]]
|
||||
# The pre-delta write under cp1 must not appear (the blob subsumes it).
|
||||
assert "PRE-DELTA-WRITE" not in values
|
||||
# And the pending write at the target is never folded in.
|
||||
assert "PENDING-AT-TARGET" not in values
|
||||
|
||||
Generated
+85
-70
@@ -2,7 +2,8 @@ version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.10"
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.12'",
|
||||
"python_full_version >= '3.15'",
|
||||
"python_full_version >= '3.12' and python_full_version < '3.15'",
|
||||
"python_full_version == '3.11.*'",
|
||||
"python_full_version < '3.11'",
|
||||
]
|
||||
@@ -267,10 +268,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langchain-core"
|
||||
version = "1.2.28"
|
||||
version = "1.3.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jsonpatch" },
|
||||
{ name = "langchain-protocol" },
|
||||
{ name = "langsmith" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pydantic" },
|
||||
@@ -279,14 +281,26 @@ dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "uuid-utils" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f8/a4/317a1a3ac1df33a64adb3670bf88bbe3b3d5baa274db6863a979db472897/langchain_core-1.2.28.tar.gz", hash = "sha256:271a3d8bd618f795fdeba112b0753980457fc90537c46a0c11998516a74dc2cb", size = 846119, upload-time = "2026-04-08T18:19:34.867Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a8/03/7219502e8ca728d65eb44d7a3eb60239230742a70dbfc9241b9bfd61c4ab/langchain_core-1.3.2.tar.gz", hash = "sha256:fd7a50b2f28ba561fd9d7f5d2760bc9e06cf00cdf820a3ccafe88a94ffa8d5b7", size = 911813, upload-time = "2026-04-24T15:49:23.699Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/92/32f785f077c7e898da97064f113c73fbd9ad55d1e2169cf3a391b183dedb/langchain_core-1.2.28-py3-none-any.whl", hash = "sha256:80764232581eaf8057bcefa71dbf8adc1f6a28d257ebd8b95ba9b8b452e8c6ac", size = 508727, upload-time = "2026-04-08T18:19:32.823Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/d5/8fa4431007cbb7cfed7590f4d6a5dea3ad724f4174d248f6642ef5ce7d05/langchain_core-1.3.2-py3-none-any.whl", hash = "sha256:d44a66127f9f8db735bdfd0ab9661bccb47a97113cfd3f2d89c74864422b7274", size = 542390, upload-time = "2026-04-24T15:49:21.991Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langchain-protocol"
|
||||
version = "0.0.15"
|
||||
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" }
|
||||
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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-checkpoint"
|
||||
version = "4.0.2"
|
||||
version = "4.1.0a4"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -486,7 +500,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "mypy"
|
||||
version = "1.20.0"
|
||||
version = "1.20.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "librt", marker = "platform_python_implementation != 'PyPy'" },
|
||||
@@ -495,51 +509,51 @@ dependencies = [
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f8/5c/b0089fe7fef0a994ae5ee07029ced0526082c6cfaaa4c10d40a10e33b097/mypy-1.20.0.tar.gz", hash = "sha256:eb96c84efcc33f0b5e0e04beacf00129dd963b67226b01c00b9dfc8affb464c3", size = 3815028, upload-time = "2026-03-31T16:55:14.959Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/04/af/e3d4b3e9ec91a0ff9aabfdb38692952acf49bbb899c2e4c29acb3a6da3ae/mypy-1.20.2.tar.gz", hash = "sha256:e8222c26daaafd9e8626dec58ae36029f82585890589576f769a650dd20fd665", size = 3817349, upload-time = "2026-04-21T17:12:28.473Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/a2/a965c8c3fcd4fa8b84ba0d46606181b0d0a1d50f274c67877f3e9ed4882c/mypy-1.20.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d99f515f95fd03a90875fdb2cca12ff074aa04490db4d190905851bdf8a549a8", size = 14430138, upload-time = "2026-03-31T16:52:37.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/6e/043477501deeb8eabbab7f1a2f6cac62cfb631806dc1d6862a04a7f5011b/mypy-1.20.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bd0212976dc57a5bfeede7c219e7cd66568a32c05c9129686dd487c059c1b88a", size = 13311282, upload-time = "2026-03-31T16:55:11.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/aa/bd89b247b83128197a214f29f0632ff3c14f54d4cd70d144d157bd7d7d6e/mypy-1.20.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8426d4d75d68714abc17a4292d922f6ba2cfb984b72c2278c437f6dae797865", size = 13750889, upload-time = "2026-03-31T16:52:02.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/9d/2860be7355c45247ccc0be1501c91176318964c2a137bd4743f58ce6200e/mypy-1.20.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02cca0761c75b42a20a2757ae58713276605eb29a08dd8a6e092aa347c4115ca", size = 14619788, upload-time = "2026-03-31T16:50:48.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/7f/3ef3e360c91f3de120f205c8ce405e9caf9fc52ef14b65d37073e322c114/mypy-1.20.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b3a49064504be59e59da664c5e149edc1f26c67c4f8e8456f6ba6aba55033018", size = 14918849, upload-time = "2026-03-31T16:51:10.478Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/72/af970dfe167ef788df7c5e6109d2ed0229f164432ce828bc9741a4250e64/mypy-1.20.0-cp310-cp310-win_amd64.whl", hash = "sha256:ebea00201737ad4391142808ed16e875add5c17f676e0912b387739f84991e13", size = 10822007, upload-time = "2026-03-31T16:50:25.268Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/94/ba9065c2ebe5421619aff684b793d953e438a8bfe31a320dd6d1e0706e81/mypy-1.20.0-cp310-cp310-win_arm64.whl", hash = "sha256:e80cf77847d0d3e6e3111b7b25db32a7f8762fd4b9a3a72ce53fe16a2863b281", size = 9756158, upload-time = "2026-03-31T16:48:36.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/1c/74cb1d9993236910286865679d1c616b136b2eae468493aa939431eda410/mypy-1.20.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4525e7010b1b38334516181c5b81e16180b8e149e6684cee5a727c78186b4e3b", size = 14343972, upload-time = "2026-03-31T16:49:04.887Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/0d/01399515eca280386e308cf57901e68d3a52af18691941b773b3380c1df8/mypy-1.20.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a17c5d0bdcca61ce24a35beb828a2d0d323d3fcf387d7512206888c900193367", size = 13225007, upload-time = "2026-03-31T16:50:08.151Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/ac/b4ba5094fb2d7fe9d2037cd8d18bbe02bcf68fd22ab9ff013f55e57ba095/mypy-1.20.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f75ff57defcd0f1d6e006d721ccdec6c88d4f6a7816eb92f1c4890d979d9ee62", size = 13663752, upload-time = "2026-03-31T16:49:26.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/a7/460678d3cf7da252d2288dad0c602294b6ec22a91932ec368cc11e44bb6e/mypy-1.20.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b503ab55a836136b619b5fc21c8803d810c5b87551af8600b72eecafb0059cb0", size = 14532265, upload-time = "2026-03-31T16:53:55.077Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/3e/051cca8166cf0438ae3ea80e0e7c030d7a8ab98dffc93f80a1aa3f23c1a2/mypy-1.20.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1973868d2adbb4584a3835780b27436f06d1dc606af5be09f187aaa25be1070f", size = 14768476, upload-time = "2026-03-31T16:50:34.587Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/66/8e02ec184f852ed5c4abb805583305db475930854e09964b55e107cdcbc4/mypy-1.20.0-cp311-cp311-win_amd64.whl", hash = "sha256:2fcedb16d456106e545b2bfd7ef9d24e70b38ec252d2a629823a4d07ebcdb69e", size = 10818226, upload-time = "2026-03-31T16:53:15.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/4b/383ad1924b28f41e4879a74151e7a5451123330d45652da359f9183bcd45/mypy-1.20.0-cp311-cp311-win_arm64.whl", hash = "sha256:379edf079ce44ac8d2805bcf9b3dd7340d4f97aad3a5e0ebabbf9d125b84b442", size = 9750091, upload-time = "2026-03-31T16:54:12.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/dd/3afa29b58c2e57c79116ed55d700721c3c3b15955e2b6251dd165d377c0e/mypy-1.20.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:002b613ae19f4ac7d18b7e168ffe1cb9013b37c57f7411984abbd3b817b0a214", size = 14509525, upload-time = "2026-03-31T16:55:01.824Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/eb/227b516ab8cad9f2a13c5e7a98d28cd6aa75e9c83e82776ae6c1c4c046c7/mypy-1.20.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a9336b5e6712f4adaf5afc3203a99a40b379049104349d747eb3e5a3aa23ac2e", size = 13326469, upload-time = "2026-03-31T16:51:41.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/d4/1ddb799860c1b5ac6117ec307b965f65deeb47044395ff01ab793248a591/mypy-1.20.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f13b3e41bce9d257eded794c0f12878af3129d80aacd8a3ee0dee51f3a978651", size = 13705953, upload-time = "2026-03-31T16:48:55.69Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/b7/54a720f565a87b893182a2a393370289ae7149e4715859e10e1c05e49154/mypy-1.20.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9804c3ad27f78e54e58b32e7cb532d128b43dbfb9f3f9f06262b821a0f6bd3f5", size = 14710363, upload-time = "2026-03-31T16:53:26.948Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/2a/74810274848d061f8a8ea4ac23aaad43bd3d8c1882457999c2e568341c57/mypy-1.20.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:697f102c5c1d526bdd761a69f17c6070f9892eebcb94b1a5963d679288c09e78", size = 14947005, upload-time = "2026-03-31T16:50:17.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/91/21b8ba75f958bcda75690951ce6fa6b7138b03471618959529d74b8544e2/mypy-1.20.0-cp312-cp312-win_amd64.whl", hash = "sha256:0ecd63f75fdd30327e4ad8b5704bd6d91fc6c1b2e029f8ee14705e1207212489", size = 10880616, upload-time = "2026-03-31T16:52:19.986Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/15/3d8198ef97c1ca03aea010cce4f1d4f3bc5d9849e8c0140111ca2ead9fdd/mypy-1.20.0-cp312-cp312-win_arm64.whl", hash = "sha256:f194db59657c58593a3c47c6dfd7bad4ef4ac12dbc94d01b3a95521f78177e33", size = 9813091, upload-time = "2026-03-31T16:53:44.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/a7/f64ea7bd592fa431cb597418b6dec4a47f7d0c36325fec7ac67bc8402b94/mypy-1.20.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b20c8b0fd5877abdf402e79a3af987053de07e6fb208c18df6659f708b535134", size = 14485344, upload-time = "2026-03-31T16:49:16.78Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/72/8927d84cfc90c6abea6e96663576e2e417589347eb538749a464c4c218a0/mypy-1.20.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:367e5c993ba34d5054d11937d0485ad6dfc60ba760fa326c01090fc256adf15c", size = 13327400, upload-time = "2026-03-31T16:53:08.02Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/4a/11ab99f9afa41aa350178d24a7d2da17043228ea10f6456523f64b5a6cf6/mypy-1.20.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f799d9db89fc00446f03281f84a221e50018fc40113a3ba9864b132895619ebe", size = 13706384, upload-time = "2026-03-31T16:52:28.577Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/79/694ca73979cfb3535ebfe78733844cd5aff2e63304f59bf90585110d975a/mypy-1.20.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:555658c611099455b2da507582ea20d2043dfdfe7f5ad0add472b1c6238b433f", size = 14700378, upload-time = "2026-03-31T16:48:45.527Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/24/a022ccab3a46e3d2cdf2e0e260648633640eb396c7e75d5a42818a8d3971/mypy-1.20.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:efe8d70949c3023698c3fca1e94527e7e790a361ab8116f90d11221421cd8726", size = 14932170, upload-time = "2026-03-31T16:49:36.038Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/9b/549228d88f574d04117e736f55958bd4908f980f9f5700a07aeb85df005b/mypy-1.20.0-cp313-cp313-win_amd64.whl", hash = "sha256:f49590891d2c2f8a9de15614e32e459a794bcba84693c2394291a2038bbaaa69", size = 10888526, upload-time = "2026-03-31T16:50:59.827Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/17/15095c0e54a8bc04d22d4ff06b2139d5f142c2e87520b4e39010c4862771/mypy-1.20.0-cp313-cp313-win_arm64.whl", hash = "sha256:76a70bf840495729be47510856b978f1b0ec7d08f257ca38c9d932720bf6b43e", size = 9816456, upload-time = "2026-03-31T16:49:59.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/0e/6ca4a84cbed9e62384bc0b2974c90395ece5ed672393e553996501625fc5/mypy-1.20.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0f42dfaab7ec1baff3b383ad7af562ab0de573c5f6edb44b2dab016082b89948", size = 14483331, upload-time = "2026-03-31T16:52:57.999Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/c5/5fe9d8a729dd9605064691816243ae6c49fde0bd28f6e5e17f6a24203c43/mypy-1.20.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:31b5dbb55293c1bd27c0fc813a0d2bb5ceef9d65ac5afa2e58f829dab7921fd5", size = 13342047, upload-time = "2026-03-31T16:54:21.555Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/33/e18bcfa338ca4e6b2771c85d4c5203e627d0c69d9de5c1a2cf2ba13320ba/mypy-1.20.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49d11c6f573a5a08f77fad13faff2139f6d0730ebed2cfa9b3d2702671dd7188", size = 13719585, upload-time = "2026-03-31T16:51:53.89Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/8d/93491ff7b79419edc7eabf95cb3b3f7490e2e574b2855c7c7e7394ff933f/mypy-1.20.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d3243c406773185144527f83be0e0aefc7bf4601b0b2b956665608bf7c98a83", size = 14685075, upload-time = "2026-03-31T16:54:04.464Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/9d/d924b38a4923f8d164bf2b4ec98bf13beaf6e10a5348b4b137eadae40a6e/mypy-1.20.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a79c1eba7ac4209f2d850f0edd0a2f8bba88cbfdfefe6fb76a19e9d4fe5e71a2", size = 14919141, upload-time = "2026-03-31T16:54:51.785Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/98/1da9977016678c0b99d43afe52ed00bb3c1a0c4c995d3e6acca1a6ebb9b4/mypy-1.20.0-cp314-cp314-win_amd64.whl", hash = "sha256:00e047c74d3ec6e71a2eb88e9ea551a2edb90c21f993aefa9e0d2a898e0bb732", size = 11050925, upload-time = "2026-03-31T16:51:30.758Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/e3/ba0b7a3143e49a9c4f5967dde6ea4bf8e0b10ecbbcca69af84027160ee89/mypy-1.20.0-cp314-cp314-win_arm64.whl", hash = "sha256:931a7630bba591593dcf6e97224a21ff80fb357e7982628d25e3c618e7f598ef", size = 10001089, upload-time = "2026-03-31T16:49:43.632Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/28/e617e67b3be9d213cda7277913269c874eb26472489f95d09d89765ce2d8/mypy-1.20.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:26c8b52627b6552f47ff11adb4e1509605f094e29815323e487fc0053ebe93d1", size = 15534710, upload-time = "2026-03-31T16:52:12.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/0c/3b5f2d3e45dc7169b811adce8451679d9430399d03b168f9b0489f43adaa/mypy-1.20.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:39362cdb4ba5f916e7976fccecaab1ba3a83e35f60fa68b64e9a70e221bb2436", size = 14393013, upload-time = "2026-03-31T16:54:41.186Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/49/edc8b0aa145cc09c1c74f7ce2858eead9329931dcbbb26e2ad40906daa4e/mypy-1.20.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34506397dbf40c15dc567635d18a21d33827e9ab29014fb83d292a8f4f8953b6", size = 15047240, upload-time = "2026-03-31T16:54:31.955Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/37/a946bb416e37a57fa752b3100fd5ede0e28df94f92366d1716555d47c454/mypy-1.20.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:555493c44a4f5a1b58d611a43333e71a9981c6dbe26270377b6f8174126a0526", size = 15858565, upload-time = "2026-03-31T16:53:36.997Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/99/7690b5b5b552db1bd4ff362e4c0eb3107b98d680835e65823fbe888c8b78/mypy-1.20.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2721f0ce49cb74a38f00c50da67cb7d36317b5eda38877a49614dc018e91c787", size = 16087874, upload-time = "2026-03-31T16:52:48.313Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/76/53e893a498138066acd28192b77495c9357e5a58cc4be753182846b43315/mypy-1.20.0-cp314-cp314t-win_amd64.whl", hash = "sha256:47781555a7aa5fedcc2d16bcd72e0dc83eb272c10dd657f9fb3f9cc08e2e6abb", size = 12572380, upload-time = "2026-03-31T16:49:52.454Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/9c/6dbdae21f01b7aacddc2c0bbf3c5557aa547827fdf271770fe1e521e7093/mypy-1.20.0-cp314-cp314t-win_arm64.whl", hash = "sha256:c70380fe5d64010f79fb863b9081c7004dd65225d2277333c219d93a10dad4dd", size = 10381174, upload-time = "2026-03-31T16:51:20.179Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/66/4d734961ce167f0fd8380769b3b7c06dbdd6ff54c2190f3f2ecd22528158/mypy-1.20.0-py3-none-any.whl", hash = "sha256:a6e0641147cbfa7e4e94efdb95c2dab1aff8cfc159ded13e07f308ddccc8c48e", size = 2636365, upload-time = "2026-03-31T16:51:44.911Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/97/ce2502df2cecf2ef997b6c6527c4a223b92feb9e7b790cdc8dcd683f3a8a/mypy-1.20.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cf5a4db6dca263010e2c7bff081c89383c72d187ba2cf4c44759aac970e2f0c4", size = 14457059, upload-time = "2026-04-21T17:06:14.935Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/34/417ee60b822cc80c0f3dc9f495ad7fd8dbb8d8b2cf4baf22d4046d25d01d/mypy-1.20.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7b0e817b518bff7facd7f85ea05b643ad8bdcce684cf29784987b0a7c8e1f997", size = 13346816, upload-time = "2026-04-21T17:10:41.433Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/85/e20951978702df58379d0bcc2e8f7ccdca4e78cd7dc66dd3ddbf9b29d517/mypy-1.20.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97d7b9a485b40f8ca425460e89bf1da2814625b2da627c0dcc6aa46c92631d14", size = 13772593, upload-time = "2026-04-21T17:08:11.24Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/a5/5441a13259ec516c56fd5de0fd96a69a9590ae6c5e5d3e5174aa84b97973/mypy-1.20.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e1c12f6d2db3d78b909b5f77513c11eb7f2dd2782b96a3ab6dffc7d44575c99", size = 14656635, upload-time = "2026-04-21T17:09:54.042Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/51/b89c69157c5e1f19fd125a65d991166a26906e7902f026f00feebbcfa2b9/mypy-1.20.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:89dce27e142d25ffbc154c1819383b69f2e9234dc4ed4766f42e0e8cb264ab5c", size = 14943278, upload-time = "2026-04-21T17:09:15.599Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/44/6b0eeecfe96d7cce1d71c66b8e03cb304aa70ec11f1955dc1d6b46aca3c3/mypy-1.20.2-cp310-cp310-win_amd64.whl", hash = "sha256:f376e37f9bf2a946872fc5fd1199c99310748e3c26c7a26683f13f8bdb756cbd", size = 10851915, upload-time = "2026-04-21T17:06:03.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/36/6593dc88545d75fb96416184be5392da5e2a8e8c2802a8597913e16ae25c/mypy-1.20.2-cp310-cp310-win_arm64.whl", hash = "sha256:6e2b469efd811707bc530fd1effef0f5d6eebcb7fe376affae69025da4b979a2", size = 9786676, upload-time = "2026-04-21T17:07:02.035Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/4d/9ebeae211caccbdaddde7ed5e31dfcf57faac66be9b11deb1dc6526c8078/mypy-1.20.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4077797a273e56e8843d001e9dfe4ba10e33323d6ade647ff260e5cd97d9758c", size = 14371307, upload-time = "2026-04-21T17:08:56.442Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/d7/93473d34b61f04fac1aecc01368485c89c5c4af7a4b9a0cab5d77d04b63f/mypy-1.20.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cdecf62abcc4292500d7858aeae87a1f8f1150f4c4dd08fb0b336ee79b2a6df3", size = 13258917, upload-time = "2026-04-21T17:05:50.978Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/30/3dd903e8bafb7b5f7bf87fcd58f8382086dea2aa19f0a7b357f21f63071b/mypy-1.20.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c566c3a88b6ece59b3d70f65bedef17304f48eb52ff040a6a18214e1917b3254", size = 13700516, upload-time = "2026-04-21T17:11:33.161Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/05/c61a140aba4c729ac7bc99ae26fc627c78a6e08f5b9dd319244ea71a3d7e/mypy-1.20.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0deb80d062b2479f2c87ae568f89845afc71d11bc41b04179e58165fd9f31e98", size = 14562889, upload-time = "2026-04-21T17:05:27.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/87/da78243742ffa8a36d98c3010f0d829f93d5da4e6786f1a1a6f2ad616502/mypy-1.20.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bba9ad231e92a3e424b3e56b65aa17704993425bba97e302c832f9466bb85bac", size = 14803844, upload-time = "2026-04-21T17:10:06.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/52/10a1ddf91b40f843943a3c6db51e2df59c9e237f29d355e95eaab427461f/mypy-1.20.2-cp311-cp311-win_amd64.whl", hash = "sha256:baf593f2765fa3a6b1ef95807dbaa3d25b594f6a52adcc506a6b9cb115e1be67", size = 10846300, upload-time = "2026-04-21T17:12:23.886Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/02/f9a4415b664c53bd34d6709be59da303abcae986dc4ac847b402edb6fa1e/mypy-1.20.2-cp311-cp311-win_arm64.whl", hash = "sha256:20175a1c0f49863946ec20b7f63255768058ac4f07d2b9ded6a6b46cfb5a9100", size = 9779498, upload-time = "2026-04-21T17:09:23.695Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/4e/7560e4528db9e9b147e4c0f22660466bf30a0a1fe3d63d1b9d3b0fd354ee/mypy-1.20.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4dbfcf869f6b0517f70cf0030ba6ea1d6645e132337a7d5204a18d8d5636c02b", size = 14539393, upload-time = "2026-04-21T17:07:12.52Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/d9/34a5efed8124f5a9234f55ac6a4ced4201e2c5b81e1109c49ad23190ec8c/mypy-1.20.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b6481b228d072315b053210b01ac320e1be243dc17f9e5887ef167f23f5fae4", size = 13361642, upload-time = "2026-04-21T17:06:53.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/14/eb377acf78c03c92d566a1510cda8137348215b5335085ef662ab82ecd3a/mypy-1.20.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34397cdced6b90b836e38182076049fdb41424322e0b0728c946b0939ebdf9f6", size = 13740347, upload-time = "2026-04-21T17:12:04.73Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/94/7e4634a32b641aa1c112422eed1bbece61ee16205f674190e8b536f884de/mypy-1.20.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5da6976f20cae27059ea8d0c86e7cef3de720e04c4bb9ee18e3690fdb792066", size = 14734042, upload-time = "2026-04-21T17:07:43.16Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/f3/f7e62395cb7f434541b4491a01149a4439e28ace4c0c632bbf5431e92d1f/mypy-1.20.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:56908d7e08318d39f85b1f0c6cfd47b0cac1a130da677630dac0de3e0623e102", size = 14964958, upload-time = "2026-04-21T17:11:00.665Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/0d/47e3c3a0ec2a876e35aeac365df3cac7776c36bbd4ed18cc521e1b9d255b/mypy-1.20.2-cp312-cp312-win_amd64.whl", hash = "sha256:d52ad8d78522da1d308789df651ee5379088e77c76cb1994858d40a426b343b9", size = 10911340, upload-time = "2026-04-21T17:10:49.179Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/b2/6c852d72e0ea8b01f49da817fb52539993cde327e7d010e0103dc12d0dac/mypy-1.20.2-cp312-cp312-win_arm64.whl", hash = "sha256:785b08db19c9f214dc37d65f7c165d19a30fcecb48abfa30f31b01b5acaabb58", size = 9833947, upload-time = "2026-04-21T17:09:05.267Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/c4/b93812d3a192c9bcf5df405bd2f30277cd0e48106a14d1023c7f6ed6e39b/mypy-1.20.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:edfbfca868cdd6bd8d974a60f8a3682f5565d3f5c99b327640cedd24c4264026", size = 14524670, upload-time = "2026-04-21T17:10:30.737Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/47/42c122501bff18eaf1e8f457f5c017933452d8acdc52918a9f59f6812955/mypy-1.20.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e2877a02380adfcdbc69071a0f74d6e9dbbf593c0dc9d174e1f223ffd5281943", size = 13336218, upload-time = "2026-04-21T17:08:44.069Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/8f/75bbc92f41725fbd585fb17b440b1119b576105df1013622983e18640a93/mypy-1.20.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7488448de6007cd5177c6cea0517ac33b4c0f5ee9b5e9f2be51ce75511a85517", size = 13724906, upload-time = "2026-04-21T17:08:01.02Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/32/4c49da27a606167391ff0c39aa955707a00edc500572e562f7c36c08a71f/mypy-1.20.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb9c2fa06887e21d6a3a868762acb82aec34e2c6fd0174064f27c93ede68ad15", size = 14726046, upload-time = "2026-04-21T17:11:22.354Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/fc/4e354a1bd70216359deb0c9c54847ee6b32ef78dfb09f5131ff99b494078/mypy-1.20.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d56a78b646f2e3daa865bc70cd5ec5a46c50045801ca8ff17a0c43abc97e3ee", size = 14955587, upload-time = "2026-04-21T17:12:16.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/b2/c0f2056e9eb8f08c62cafd9715e4584b89132bdc832fcf85d27d07b5f3e5/mypy-1.20.2-cp313-cp313-win_amd64.whl", hash = "sha256:2a4102b03bb7481d9a91a6da8d174740c9c8c4401024684b9ca3b7cc5e49852f", size = 10922681, upload-time = "2026-04-21T17:06:35.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/14/065e333721f05de8ef683d0aa804c23026bcc287446b61cac657b902ccac/mypy-1.20.2-cp313-cp313-win_arm64.whl", hash = "sha256:a95a9248b0c6fd933a442c03c3b113c3b61320086b88e2c444676d3fd1ca3330", size = 9830560, upload-time = "2026-04-21T17:07:51.023Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/d1/b4ec96b0ecc620a4443570c6e95c867903428cfcde4206518eafdd5880c3/mypy-1.20.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:419413398fe250aae057fd2fe50166b61077083c9b82754c341cf4fd73038f30", size = 14524561, upload-time = "2026-04-21T17:06:27.325Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/63/d2c2ff4fa66bc49477d32dfa26e8a167ba803ea6a69c5efb416036909d30/mypy-1.20.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e73c07f23009962885c197ccb9b41356a30cc0e5a1d0c2ea8fd8fb1362d7f924", size = 13363883, upload-time = "2026-04-21T17:11:11.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/56/983916806bf4eddeaaa2c9230903c3669c6718552a921154e1c5182c701f/mypy-1.20.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c64e5973df366b747646fc98da921f9d6eba9716d57d1db94a83c026a08e0fb", size = 13742945, upload-time = "2026-04-21T17:08:34.181Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/65/0cd9285ab010ee8214c83d67c6b49417c40d86ce46f1aa109457b5a9b8d7/mypy-1.20.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a65aa591af023864fd08a97da9974e919452cfe19cb146c8a5dc692626445dc", size = 14706163, upload-time = "2026-04-21T17:05:15.51Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/97/48ff3b297cafcc94d185243a9190836fb1b01c1b0918fff64e941e973cc9/mypy-1.20.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4fef51b01e638974a6e69885687e9bd40c8d1e09a6cd291cca0619625cf1f558", size = 14938677, upload-time = "2026-04-21T17:05:39.562Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/a1/1b4233d255bdd0b38a1f284feeb1c143ca508c19184964e22f8d837ec851/mypy-1.20.2-cp314-cp314-win_amd64.whl", hash = "sha256:913485a03f1bcf5d279409a9d2b9ed565c151f61c09f29991e5faa14033da4c8", size = 11089322, upload-time = "2026-04-21T17:06:44.29Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/c2/ce7ee2ba36aeb954ba50f18fa25d9c1188578654b97d02a66a15b6f09531/mypy-1.20.2-cp314-cp314-win_arm64.whl", hash = "sha256:c3bae4f855d965b5453784300c12ffc63a548304ac7f99e55d4dc7c898673aa3", size = 10017775, upload-time = "2026-04-21T17:07:20.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/a1/9d93a7d0b5859af0ead82b4888b46df6c8797e1bc5e1e262a08518c6d48e/mypy-1.20.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2de3dcea53babc1c3237a19002bc3d228ce1833278f093b8d619e06e7cc79609", size = 15549002, upload-time = "2026-04-21T17:08:23.107Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/d2/09a6a10ee1bf0008f6c144d9676f2ca6a12512151b4e0ad0ff6c4fac5337/mypy-1.20.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:52b176444e2e5054dfcbcb8c75b0b719865c96247b37407184bbfca5c353f2c2", size = 14401942, upload-time = "2026-04-21T17:07:31.837Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/da/9594b75c3c019e805250bed3583bdf4443ff9e6ef08f97e39ae308cb06f2/mypy-1.20.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:688c3312e5dadb573a2c69c82af3a298d43ecf9e6d264e0f95df960b5f6ac19c", size = 15041649, upload-time = "2026-04-21T17:09:34.653Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/77/f75a65c278e6e8eba2071f7f5a90481891053ecc39878cc444634d892abe/mypy-1.20.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29752dbbf8cc53f89f6ac096d363314333045c257c9c75cbd189ca2de0455744", size = 15864588, upload-time = "2026-04-21T17:11:44.936Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/46/1a4e1c66e96c1a3246ddf5403d122ac9b0a8d2b7e65730b9d6533ba7a6d3/mypy-1.20.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:803203d2b6ea644982c644895c2f78b28d0e208bba7b27d9b921e0ec5eb207c6", size = 16093956, upload-time = "2026-04-21T17:10:17.683Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/2c/78a8851264dec38cd736ca5b8bc9380674df0dd0be7792f538916157716c/mypy-1.20.2-cp314-cp314t-win_amd64.whl", hash = "sha256:9bcb8aa397ff0093c824182fd76a935a9ba7ad097fcbef80ae89bf6c1731d8ec", size = 12568661, upload-time = "2026-04-21T17:11:54.473Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/01/cd7318aa03493322ce275a0e14f4f52b8896335e4e79d4fb8153a7ad2b77/mypy-1.20.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e061b58443f1736f8a37c48978d7ab581636d6ab03e3d4f99e3fa90463bb9382", size = 10389240, upload-time = "2026-04-21T17:09:42.719Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/9a/f23c163e25b11074188251b0b5a0342625fc1cdb6af604757174fa9acc9b/mypy-1.20.2-py3-none-any.whl", hash = "sha256:a94c5a76ab46c5e6257c7972b6c8cff0574201ca7dc05647e33e795d78680563", size = 2637314, upload-time = "2026-04-21T17:05:54.5Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -621,7 +635,8 @@ name = "numpy"
|
||||
version = "2.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.12'",
|
||||
"python_full_version >= '3.15'",
|
||||
"python_full_version >= '3.12' and python_full_version < '3.15'",
|
||||
"python_full_version == '3.11.*'",
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/24/62/ae72ff66c0f1fd959925b4c11f8c2dea61f47f6acaea75a08512cdfe3fed/numpy-2.4.1.tar.gz", hash = "sha256:a1ceafc5042451a858231588a104093474c6a5c57dcc724841f5c888d237d690", size = 20721320, upload-time = "2026-01-10T06:44:59.619Z" }
|
||||
@@ -1299,27 +1314,27 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.8"
|
||||
version = "0.15.12"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/14/b0/73cf7550861e2b4824950b8b52eebdcc5adc792a00c514406556c5b80817/ruff-0.15.8.tar.gz", hash = "sha256:995f11f63597ee362130d1d5a327a87cb6f3f5eae3094c620bcc632329a4d26e", size = 4610921, upload-time = "2026-03-26T18:39:38.675Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/92/c445b0cd6da6e7ae51e954939cb69f97e008dbe750cfca89b8cedc081be7/ruff-0.15.8-py3-none-linux_armv6l.whl", hash = "sha256:cbe05adeba76d58162762d6b239c9056f1a15a55bd4b346cfd21e26cd6ad7bc7", size = 10527394, upload-time = "2026-03-26T18:39:41.566Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/92/f1c662784d149ad1414cae450b082cf736430c12ca78367f20f5ed569d65/ruff-0.15.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d3e3d0b6ba8dca1b7ef9ab80a28e840a20070c4b62e56d675c24f366ef330570", size = 10905693, upload-time = "2026-03-26T18:39:30.364Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/f2/7a631a8af6d88bcef997eb1bf87cc3da158294c57044aafd3e17030613de/ruff-0.15.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6ee3ae5c65a42f273f126686353f2e08ff29927b7b7e203b711514370d500de3", size = 10323044, upload-time = "2026-03-26T18:39:33.37Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/18/1bf38e20914a05e72ef3b9569b1d5c70a7ef26cd188d69e9ca8ef588d5bf/ruff-0.15.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdce027ada77baa448077ccc6ebb2fa9c3c62fd110d8659d601cf2f475858d94", size = 10629135, upload-time = "2026-03-26T18:39:44.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/e9/138c150ff9af60556121623d41aba18b7b57d95ac032e177b6a53789d279/ruff-0.15.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12e617fc01a95e5821648a6df341d80456bd627bfab8a829f7cfc26a14a4b4a3", size = 10348041, upload-time = "2026-03-26T18:39:52.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/f1/5bfb9298d9c323f842c5ddeb85f1f10ef51516ac7a34ba446c9347d898df/ruff-0.15.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:432701303b26416d22ba696c39f2c6f12499b89093b61360abc34bcc9bf07762", size = 11121987, upload-time = "2026-03-26T18:39:55.195Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/11/6da2e538704e753c04e8d86b1fc55712fdbdcc266af1a1ece7a51fff0d10/ruff-0.15.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d910ae974b7a06a33a057cb87d2a10792a3b2b3b35e33d2699fdf63ec8f6b17a", size = 11951057, upload-time = "2026-03-26T18:39:19.18Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/f0/c9208c5fd5101bf87002fed774ff25a96eea313d305f1e5d5744698dc314/ruff-0.15.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2033f963c43949d51e6fdccd3946633c6b37c484f5f98c3035f49c27395a8ab8", size = 11464613, upload-time = "2026-03-26T18:40:06.301Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/22/d7f2fabdba4fae9f3b570e5605d5eb4500dcb7b770d3217dca4428484b17/ruff-0.15.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f29b989a55572fb885b77464cf24af05500806ab4edf9a0fd8977f9759d85b1", size = 11257557, upload-time = "2026-03-26T18:39:57.972Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/8c/382a9620038cf6906446b23ce8632ab8c0811b8f9d3e764f58bedd0c9a6f/ruff-0.15.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ac51d486bf457cdc985a412fb1801b2dfd1bd8838372fc55de64b1510eff4bec", size = 11169440, upload-time = "2026-03-26T18:39:22.205Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/0d/0994c802a7eaaf99380085e4e40c845f8e32a562e20a38ec06174b52ef24/ruff-0.15.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c9861eb959edab053c10ad62c278835ee69ca527b6dcd72b47d5c1e5648964f6", size = 10605963, upload-time = "2026-03-26T18:39:46.682Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/aa/d624b86f5b0aad7cef6bbf9cd47a6a02dfdc4f72c92a337d724e39c9d14b/ruff-0.15.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8d9a5b8ea13f26ae90838afc33f91b547e61b794865374f114f349e9036835fb", size = 10357484, upload-time = "2026-03-26T18:39:49.176Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/c3/e0b7835d23001f7d999f3895c6b569927c4d39912286897f625736e1fd04/ruff-0.15.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c2a33a529fb3cbc23a7124b5c6ff121e4d6228029cba374777bd7649cc8598b8", size = 10830426, upload-time = "2026-03-26T18:40:03.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/51/ab20b322f637b369383adc341d761eaaa0f0203d6b9a7421cd6e783d81b9/ruff-0.15.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:75e5cd06b1cf3f47a3996cfc999226b19aa92e7cce682dcd62f80d7035f98f49", size = 11345125, upload-time = "2026-03-26T18:39:27.799Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/e6/90b2b33419f59d0f2c4c8a48a4b74b460709a557e8e0064cf33ad894f983/ruff-0.15.8-py3-none-win32.whl", hash = "sha256:bc1f0a51254ba21767bfa9a8b5013ca8149dcf38092e6a9eb704d876de94dc34", size = 10571959, upload-time = "2026-03-26T18:39:36.117Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/a2/ef467cb77099062317154c63f234b8a7baf7cb690b99af760c5b68b9ee7f/ruff-0.15.8-py3-none-win_amd64.whl", hash = "sha256:04f79eff02a72db209d47d665ba7ebcad609d8918a134f86cb13dd132159fc89", size = 11743893, upload-time = "2026-03-26T18:39:25.01Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/e2/77be4fff062fa78d9b2a4dea85d14785dac5f1d0c1fb58ed52331f0ebe28/ruff-0.15.8-py3-none-win_arm64.whl", hash = "sha256:cf891fa8e3bb430c0e7fac93851a5978fc99c8fa2c053b57b118972866f8e5f2", size = 11048175, upload-time = "2026-03-26T18:40:01.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -21,25 +21,25 @@
|
||||
"test:all": "yarn test && yarn test:int && yarn lint:langgraph"
|
||||
},
|
||||
"dependencies": {
|
||||
"@langchain/core": "^1.1.38",
|
||||
"@langchain/langgraph": "^1.2.6"
|
||||
"@langchain/core": "^1.1.42",
|
||||
"@langchain/langgraph": "^1.2.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.5",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tsconfig/recommended": "^1.0.13",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.58.0",
|
||||
"@typescript-eslint/parser": "^8.58.0",
|
||||
"dotenv": "^17.3.1",
|
||||
"eslint": "^10.1.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.1",
|
||||
"@typescript-eslint/parser": "^8.59.1",
|
||||
"dotenv": "^17.4.2",
|
||||
"eslint": "^10.2.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.5",
|
||||
"jest": "^30.3.0",
|
||||
"prettier": "^3.8.1",
|
||||
"ts-jest": "^29.4.6",
|
||||
"prettier": "^3.8.3",
|
||||
"ts-jest": "^29.4.9",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
+123
-129
@@ -474,26 +474,26 @@
|
||||
resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz#bccdf615bcf7b6e8db830ec0b8d21c9a25de597b"
|
||||
integrity sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==
|
||||
|
||||
"@eslint/config-array@^0.23.3":
|
||||
version "0.23.3"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.23.3.tgz#3f4a93dd546169c09130cbd10f2415b13a20a219"
|
||||
integrity sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==
|
||||
"@eslint/config-array@^0.23.5":
|
||||
version "0.23.5"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.23.5.tgz#56e86d243049195d8acc0c06a1b3dfdc3fa3de95"
|
||||
integrity sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==
|
||||
dependencies:
|
||||
"@eslint/object-schema" "^3.0.3"
|
||||
"@eslint/object-schema" "^3.0.5"
|
||||
debug "^4.3.1"
|
||||
minimatch "^10.2.4"
|
||||
|
||||
"@eslint/config-helpers@^0.5.3":
|
||||
version "0.5.3"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.5.3.tgz#721fe6bbb90d74b0c80d6ff2428e5bbcb002becb"
|
||||
integrity sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==
|
||||
"@eslint/config-helpers@^0.5.5":
|
||||
version "0.5.5"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.5.5.tgz#ae16134e4792ac5fbdc533548a24ac1ea9f7f3ae"
|
||||
integrity sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==
|
||||
dependencies:
|
||||
"@eslint/core" "^1.1.1"
|
||||
"@eslint/core" "^1.2.1"
|
||||
|
||||
"@eslint/core@^1.1.1":
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/core/-/core-1.1.1.tgz#450f3d2be2d463ccd51119544092256b4e88df32"
|
||||
integrity sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==
|
||||
"@eslint/core@^1.2.1":
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/core/-/core-1.2.1.tgz#c1da7cd1b82fa8787f98b5629fb811848a1b63ce"
|
||||
integrity sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==
|
||||
dependencies:
|
||||
"@types/json-schema" "^7.0.15"
|
||||
|
||||
@@ -517,17 +517,17 @@
|
||||
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-10.0.1.tgz#1e8a876f50117af8ab67e47d5ad94d38d6622583"
|
||||
integrity sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==
|
||||
|
||||
"@eslint/object-schema@^3.0.3":
|
||||
version "3.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-3.0.3.tgz#5bf671e52e382e4adc47a9906f2699374637db6b"
|
||||
integrity sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==
|
||||
"@eslint/object-schema@^3.0.5":
|
||||
version "3.0.5"
|
||||
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.6.1":
|
||||
version "0.6.1"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz#eb9e6689b56ce8bc1855bb33090e63f3fc115e8e"
|
||||
integrity sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==
|
||||
"@eslint/plugin-kit@^0.7.1":
|
||||
version "0.7.1"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz#c4125fd015eceeb09b793109fdbcd4dd0a02d346"
|
||||
integrity sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==
|
||||
dependencies:
|
||||
"@eslint/core" "^1.1.1"
|
||||
"@eslint/core" "^1.2.1"
|
||||
levn "^0.4.1"
|
||||
|
||||
"@humanfs/core@^0.19.1":
|
||||
@@ -890,10 +890,10 @@
|
||||
"@jridgewell/resolve-uri" "^3.1.0"
|
||||
"@jridgewell/sourcemap-codec" "^1.4.14"
|
||||
|
||||
"@langchain/core@^1.1.38":
|
||||
version "1.1.38"
|
||||
resolved "https://registry.yarnpkg.com/@langchain/core/-/core-1.1.38.tgz#b5939b2e77af408771c9bb95bf56a5ad8183313e"
|
||||
integrity sha512-C340wH1YL10CiVOFlEpQMp0zQE85/eBLKX/gi1Lv7shAyUmR3CQ0t/mXlCd5RsZa6ntAN1kDJnp64ArWey9XAA==
|
||||
"@langchain/core@^1.1.42":
|
||||
version "1.1.42"
|
||||
resolved "https://registry.yarnpkg.com/@langchain/core/-/core-1.1.42.tgz#607bfd34c1414c9cbeb2859fda1b254a14c28085"
|
||||
integrity sha512-d0tN96BrwPMryYyWR9VfyAntSivn7EQrZCe5Kpxum93tcjTXbKKmKvItFec8AluQt88iTcmAJrahUZUNfzGwTA==
|
||||
dependencies:
|
||||
"@cfworker/json-schema" "^4.0.2"
|
||||
"@standard-schema/spec" "^1.1.0"
|
||||
@@ -904,7 +904,6 @@
|
||||
langsmith ">=0.5.0 <1.0.0"
|
||||
mustache "^4.2.0"
|
||||
p-queue "^6.6.2"
|
||||
uuid "^11.1.0"
|
||||
zod "^3.25.76 || ^4"
|
||||
|
||||
"@langchain/langgraph-checkpoint@^1.0.1":
|
||||
@@ -914,23 +913,23 @@
|
||||
dependencies:
|
||||
uuid "^10.0.0"
|
||||
|
||||
"@langchain/langgraph-sdk@~1.8.1":
|
||||
version "1.8.3"
|
||||
resolved "https://registry.yarnpkg.com/@langchain/langgraph-sdk/-/langgraph-sdk-1.8.3.tgz#deedc9bddd863693c6ab5f3a74058548b040377d"
|
||||
integrity sha512-Py0S5yVtlOHz410aEsSGLRKjtsK2giDvfPS1JjAjTdcs71khuJufFtUZFwmwdJCbsG4DaGurRLHOAJu9jO4a0g==
|
||||
"@langchain/langgraph-sdk@~1.8.9":
|
||||
version "1.8.10"
|
||||
resolved "https://registry.yarnpkg.com/@langchain/langgraph-sdk/-/langgraph-sdk-1.8.10.tgz#bd175cda1ab629845ef28b22b45f3bdd1f65b029"
|
||||
integrity sha512-wrB3rkRw5KAmsqezwvKP3midT4qJrV6Hj9XJMYo+cbvXC4HYpSAmyY/VriSyeTFRbLG/OP/pY2Yz+9Z54nSaXQ==
|
||||
dependencies:
|
||||
"@types/json-schema" "^7.0.15"
|
||||
p-queue "^9.0.1"
|
||||
p-retry "^7.1.1"
|
||||
uuid "^13.0.0"
|
||||
|
||||
"@langchain/langgraph@^1.2.6":
|
||||
version "1.2.6"
|
||||
resolved "https://registry.yarnpkg.com/@langchain/langgraph/-/langgraph-1.2.6.tgz#e6a578f96fbbc1c54ba78e0298884671dad5d195"
|
||||
integrity sha512-5cX402dNGN6w9+0mlMU2dgUiKx6Y1tlENp7x05e9ByDbQCHSDc0kyqRWNFLGc7vatQ92S4ylxQrcCJvi8Fr4SQ==
|
||||
"@langchain/langgraph@^1.2.9":
|
||||
version "1.2.9"
|
||||
resolved "https://registry.yarnpkg.com/@langchain/langgraph/-/langgraph-1.2.9.tgz#4f0ba159062c3649c83cf5f621ef926b96933e32"
|
||||
integrity sha512-3c7BtGycHC2v9p6w/Hv8L7kEl1YnZYOQTDJtmAp3knk6JOedO7d2bYP3y0SRyhv5orUEGf/KGvx8ZsB/ideP7g==
|
||||
dependencies:
|
||||
"@langchain/langgraph-checkpoint" "^1.0.1"
|
||||
"@langchain/langgraph-sdk" "~1.8.1"
|
||||
"@langchain/langgraph-sdk" "~1.8.9"
|
||||
"@standard-schema/spec" "1.1.0"
|
||||
uuid "^10.0.0"
|
||||
|
||||
@@ -1098,100 +1097,100 @@
|
||||
dependencies:
|
||||
"@types/yargs-parser" "*"
|
||||
|
||||
"@typescript-eslint/eslint-plugin@^8.58.0":
|
||||
version "8.58.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz#ad40e492f1931f46da1bd888e52b9e56df9063aa"
|
||||
integrity sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==
|
||||
"@typescript-eslint/eslint-plugin@^8.59.1":
|
||||
version "8.59.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz#781bc6f9002982cfaf75a185240e24ad7276628a"
|
||||
integrity sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==
|
||||
dependencies:
|
||||
"@eslint-community/regexpp" "^4.12.2"
|
||||
"@typescript-eslint/scope-manager" "8.58.0"
|
||||
"@typescript-eslint/type-utils" "8.58.0"
|
||||
"@typescript-eslint/utils" "8.58.0"
|
||||
"@typescript-eslint/visitor-keys" "8.58.0"
|
||||
"@typescript-eslint/scope-manager" "8.59.1"
|
||||
"@typescript-eslint/type-utils" "8.59.1"
|
||||
"@typescript-eslint/utils" "8.59.1"
|
||||
"@typescript-eslint/visitor-keys" "8.59.1"
|
||||
ignore "^7.0.5"
|
||||
natural-compare "^1.4.0"
|
||||
ts-api-utils "^2.5.0"
|
||||
|
||||
"@typescript-eslint/parser@^8.58.0":
|
||||
version "8.58.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.58.0.tgz#da04ece1967b6c2fe8f10c3473dabf3825795ef7"
|
||||
integrity sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==
|
||||
"@typescript-eslint/parser@^8.59.1":
|
||||
version "8.59.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.59.1.tgz#835d20a62350659a082a1ae2a60b822c40488905"
|
||||
integrity sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==
|
||||
dependencies:
|
||||
"@typescript-eslint/scope-manager" "8.58.0"
|
||||
"@typescript-eslint/types" "8.58.0"
|
||||
"@typescript-eslint/typescript-estree" "8.58.0"
|
||||
"@typescript-eslint/visitor-keys" "8.58.0"
|
||||
"@typescript-eslint/scope-manager" "8.59.1"
|
||||
"@typescript-eslint/types" "8.59.1"
|
||||
"@typescript-eslint/typescript-estree" "8.59.1"
|
||||
"@typescript-eslint/visitor-keys" "8.59.1"
|
||||
debug "^4.4.3"
|
||||
|
||||
"@typescript-eslint/project-service@8.58.0":
|
||||
version "8.58.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.58.0.tgz#66ceda0aabf7427aec3e2713fa43eb278dead2aa"
|
||||
integrity sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==
|
||||
"@typescript-eslint/project-service@8.59.1":
|
||||
version "8.59.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.59.1.tgz#49efe87c37ef84262f23df8bf62fdc56698ca6fe"
|
||||
integrity sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg==
|
||||
dependencies:
|
||||
"@typescript-eslint/tsconfig-utils" "^8.58.0"
|
||||
"@typescript-eslint/types" "^8.58.0"
|
||||
"@typescript-eslint/tsconfig-utils" "^8.59.1"
|
||||
"@typescript-eslint/types" "^8.59.1"
|
||||
debug "^4.4.3"
|
||||
|
||||
"@typescript-eslint/scope-manager@8.58.0":
|
||||
version "8.58.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.58.0.tgz#e304142775e49a1b7ac3c8bf2536714447c72cab"
|
||||
integrity sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==
|
||||
"@typescript-eslint/scope-manager@8.59.1":
|
||||
version "8.59.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.59.1.tgz#ed90d054fc3db2d0c81464db3a953a94fb85bb58"
|
||||
integrity sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.58.0"
|
||||
"@typescript-eslint/visitor-keys" "8.58.0"
|
||||
"@typescript-eslint/types" "8.59.1"
|
||||
"@typescript-eslint/visitor-keys" "8.59.1"
|
||||
|
||||
"@typescript-eslint/tsconfig-utils@8.58.0", "@typescript-eslint/tsconfig-utils@^8.58.0":
|
||||
version "8.58.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.0.tgz#c5a8edb21f31e0fdee565724e1b984171c559482"
|
||||
integrity sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==
|
||||
"@typescript-eslint/tsconfig-utils@8.59.1", "@typescript-eslint/tsconfig-utils@^8.59.1":
|
||||
version "8.59.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.1.tgz#ba2a779a444f1d5cb92a606f9b209d239fd4cab1"
|
||||
integrity sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA==
|
||||
|
||||
"@typescript-eslint/type-utils@8.58.0":
|
||||
version "8.58.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.58.0.tgz#ce0e72cd967ffbbe8de322db6089bd4374be352f"
|
||||
integrity sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg==
|
||||
"@typescript-eslint/type-utils@8.59.1":
|
||||
version "8.59.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.59.1.tgz#9c83d3f2ed9187a815e8120f72c08317e513e409"
|
||||
integrity sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.58.0"
|
||||
"@typescript-eslint/typescript-estree" "8.58.0"
|
||||
"@typescript-eslint/utils" "8.58.0"
|
||||
"@typescript-eslint/types" "8.59.1"
|
||||
"@typescript-eslint/typescript-estree" "8.59.1"
|
||||
"@typescript-eslint/utils" "8.59.1"
|
||||
debug "^4.4.3"
|
||||
ts-api-utils "^2.5.0"
|
||||
|
||||
"@typescript-eslint/types@8.58.0", "@typescript-eslint/types@^8.58.0":
|
||||
version "8.58.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.58.0.tgz#e94ae7abdc1c6530e71183c1007b61fa93112a5a"
|
||||
integrity sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==
|
||||
"@typescript-eslint/types@8.59.1", "@typescript-eslint/types@^8.59.1":
|
||||
version "8.59.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.59.1.tgz#c1d014d3f03a97e0113a8899fc9d4e45a7fb0ca9"
|
||||
integrity sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==
|
||||
|
||||
"@typescript-eslint/typescript-estree@8.58.0":
|
||||
version "8.58.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.0.tgz#ed233faa8e2f2a2e1357c3e7d553d6465a0ee59a"
|
||||
integrity sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==
|
||||
"@typescript-eslint/typescript-estree@8.59.1":
|
||||
version "8.59.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.1.tgz#4391fadf98a22c869c5b6522dbf4e491e53e351a"
|
||||
integrity sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==
|
||||
dependencies:
|
||||
"@typescript-eslint/project-service" "8.58.0"
|
||||
"@typescript-eslint/tsconfig-utils" "8.58.0"
|
||||
"@typescript-eslint/types" "8.58.0"
|
||||
"@typescript-eslint/visitor-keys" "8.58.0"
|
||||
"@typescript-eslint/project-service" "8.59.1"
|
||||
"@typescript-eslint/tsconfig-utils" "8.59.1"
|
||||
"@typescript-eslint/types" "8.59.1"
|
||||
"@typescript-eslint/visitor-keys" "8.59.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.58.0":
|
||||
version "8.58.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.58.0.tgz#21a74a7963b0d288b719a4121c7dd555adaab3c3"
|
||||
integrity sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==
|
||||
"@typescript-eslint/utils@8.59.1":
|
||||
version "8.59.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.59.1.tgz#cf6204d69701bbbc5b150f98c18aeef0a42c10bd"
|
||||
integrity sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==
|
||||
dependencies:
|
||||
"@eslint-community/eslint-utils" "^4.9.1"
|
||||
"@typescript-eslint/scope-manager" "8.58.0"
|
||||
"@typescript-eslint/types" "8.58.0"
|
||||
"@typescript-eslint/typescript-estree" "8.58.0"
|
||||
"@typescript-eslint/scope-manager" "8.59.1"
|
||||
"@typescript-eslint/types" "8.59.1"
|
||||
"@typescript-eslint/typescript-estree" "8.59.1"
|
||||
|
||||
"@typescript-eslint/visitor-keys@8.58.0":
|
||||
version "8.58.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.0.tgz#2abd55a4be70fd55967aceaba4330b9ba9f45189"
|
||||
integrity sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==
|
||||
"@typescript-eslint/visitor-keys@8.59.1":
|
||||
version "8.59.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.1.tgz#b5cba576287a3eeb0b400b62813189abcc3f976a"
|
||||
integrity sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.58.0"
|
||||
"@typescript-eslint/types" "8.59.1"
|
||||
eslint-visitor-keys "^5.0.0"
|
||||
|
||||
"@ungap/structured-clone@^1.3.0":
|
||||
@@ -1909,10 +1908,10 @@ doctrine@^2.1.0:
|
||||
dependencies:
|
||||
esutils "^2.0.2"
|
||||
|
||||
dotenv@^17.3.1:
|
||||
version "17.3.1"
|
||||
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-17.3.1.tgz#2706f5b0165e45a1503348187b8468f87fe6aae2"
|
||||
integrity sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==
|
||||
dotenv@^17.4.2:
|
||||
version "17.4.2"
|
||||
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-17.4.2.tgz#c07e54a746e11eba021dd9e1047ced5afdc1c034"
|
||||
integrity sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==
|
||||
|
||||
dunder-proto@^1.0.0, dunder-proto@^1.0.1:
|
||||
version "1.0.1"
|
||||
@@ -2263,17 +2262,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.1.0:
|
||||
version "10.1.0"
|
||||
resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.1.0.tgz#9ca98e654e642ab2e1af6d1e9d8613857ac341b4"
|
||||
integrity sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA==
|
||||
eslint@^10.2.1:
|
||||
version "10.2.1"
|
||||
resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.2.1.tgz#224b2a6caeb34473eddcf918762363e2e063222a"
|
||||
integrity sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q==
|
||||
dependencies:
|
||||
"@eslint-community/eslint-utils" "^4.8.0"
|
||||
"@eslint-community/regexpp" "^4.12.2"
|
||||
"@eslint/config-array" "^0.23.3"
|
||||
"@eslint/config-helpers" "^0.5.3"
|
||||
"@eslint/core" "^1.1.1"
|
||||
"@eslint/plugin-kit" "^0.6.1"
|
||||
"@eslint/config-array" "^0.23.5"
|
||||
"@eslint/config-helpers" "^0.5.5"
|
||||
"@eslint/core" "^1.2.1"
|
||||
"@eslint/plugin-kit" "^0.7.1"
|
||||
"@humanfs/node" "^0.16.6"
|
||||
"@humanwhocodes/module-importer" "^1.0.1"
|
||||
"@humanwhocodes/retry" "^0.4.2"
|
||||
@@ -2683,7 +2682,7 @@ graceful-fs@^4.2.11:
|
||||
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3"
|
||||
integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==
|
||||
|
||||
handlebars@^4.7.8:
|
||||
handlebars@^4.7.9:
|
||||
version "4.7.9"
|
||||
resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.9.tgz#6f139082ab58dc4e5a0e51efe7db5ae890d56a0f"
|
||||
integrity sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==
|
||||
@@ -4130,10 +4129,10 @@ prettier-linter-helpers@^1.0.1:
|
||||
dependencies:
|
||||
fast-diff "^1.1.2"
|
||||
|
||||
prettier@^3.8.1:
|
||||
version "3.8.1"
|
||||
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.1.tgz#edf48977cf991558f4fcbd8a3ba6015ba2a3a173"
|
||||
integrity sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==
|
||||
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==
|
||||
|
||||
pretty-format@30.2.0, pretty-format@^30.0.0:
|
||||
version "30.2.0"
|
||||
@@ -4287,7 +4286,7 @@ semver@^6.3.1:
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4"
|
||||
integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==
|
||||
|
||||
semver@^7.5.3, semver@^7.5.4, semver@^7.7.2, semver@^7.7.3:
|
||||
semver@^7.5.3, semver@^7.5.4, semver@^7.7.2, semver@^7.7.3, semver@^7.7.4:
|
||||
version "7.7.4"
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a"
|
||||
integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==
|
||||
@@ -4623,18 +4622,18 @@ ts-api-utils@^2.5.0:
|
||||
resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-2.5.0.tgz#4acd4a155e22734990a5ed1fe9e97f113bcb37c1"
|
||||
integrity sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==
|
||||
|
||||
ts-jest@^29.4.6:
|
||||
version "29.4.6"
|
||||
resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-29.4.6.tgz#51cb7c133f227396818b71297ad7409bb77106e9"
|
||||
integrity sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==
|
||||
ts-jest@^29.4.9:
|
||||
version "29.4.9"
|
||||
resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-29.4.9.tgz#47dc33d0f5c36bddcedd16afefae285e0b049d2d"
|
||||
integrity sha512-LTb9496gYPMCqjeDLdPrKuXtncudeV1yRZnF4Wo5l3SFi0RYEnYRNgMrFIdg+FHvfzjCyQk1cLncWVqiSX+EvQ==
|
||||
dependencies:
|
||||
bs-logger "^0.2.6"
|
||||
fast-json-stable-stringify "^2.1.0"
|
||||
handlebars "^4.7.8"
|
||||
handlebars "^4.7.9"
|
||||
json5 "^2.2.3"
|
||||
lodash.memoize "^4.1.2"
|
||||
make-error "^1.3.6"
|
||||
semver "^7.7.3"
|
||||
semver "^7.7.4"
|
||||
type-fest "^4.41.0"
|
||||
yargs-parser "^21.1.1"
|
||||
|
||||
@@ -4854,11 +4853,6 @@ uuid@10.0.0, uuid@^10.0.0:
|
||||
resolved "https://registry.yarnpkg.com/uuid/-/uuid-10.0.0.tgz#5a95aa454e6e002725c79055fd42aaba30ca6294"
|
||||
integrity sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==
|
||||
|
||||
uuid@^11.1.0:
|
||||
version "11.1.0"
|
||||
resolved "https://registry.yarnpkg.com/uuid/-/uuid-11.1.0.tgz#9549028be1753bb934fc96e2bca09bb4105ae912"
|
||||
integrity sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==
|
||||
|
||||
uuid@^13.0.0:
|
||||
version "13.0.0"
|
||||
resolved "https://registry.yarnpkg.com/uuid/-/uuid-13.0.0.tgz#263dc341b19b4d755eb8fe36b78d95a6b65707e8"
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@js-monorepo-example/shared": "*",
|
||||
"@langchain/core": "^1.1.38",
|
||||
"@langchain/langgraph": "^1.2.6"
|
||||
"@langchain/core": "^1.1.42",
|
||||
"@langchain/langgraph": "^1.2.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3"
|
||||
|
||||
@@ -17,18 +17,18 @@
|
||||
"lint": "eslint 'apps/**/*.ts' 'libs/**/*.ts'"
|
||||
},
|
||||
"devDependencies": {
|
||||
"turbo": "^2.9.3",
|
||||
"turbo": "^2.9.7",
|
||||
"typescript": "^5.9.3",
|
||||
"@tsconfig/recommended": "^1.0.13",
|
||||
"@eslint/eslintrc": "^3.3.5",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"eslint": "^10.1.0",
|
||||
"eslint": "^10.2.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-import": "^2.27.5",
|
||||
"eslint-plugin-no-instanceof": "^1.0.1",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"@typescript-eslint/eslint-plugin": "^8.58.0",
|
||||
"@typescript-eslint/parser": "^8.58.0",
|
||||
"prettier": "^3.8.1"
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.1",
|
||||
"@typescript-eslint/parser": "^8.59.1",
|
||||
"prettier": "^3.8.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,26 +19,26 @@
|
||||
resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz#bccdf615bcf7b6e8db830ec0b8d21c9a25de597b"
|
||||
integrity sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==
|
||||
|
||||
"@eslint/config-array@^0.23.3":
|
||||
version "0.23.3"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.23.3.tgz#3f4a93dd546169c09130cbd10f2415b13a20a219"
|
||||
integrity sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==
|
||||
"@eslint/config-array@^0.23.5":
|
||||
version "0.23.5"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.23.5.tgz#56e86d243049195d8acc0c06a1b3dfdc3fa3de95"
|
||||
integrity sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==
|
||||
dependencies:
|
||||
"@eslint/object-schema" "^3.0.3"
|
||||
"@eslint/object-schema" "^3.0.5"
|
||||
debug "^4.3.1"
|
||||
minimatch "^10.2.4"
|
||||
|
||||
"@eslint/config-helpers@^0.5.3":
|
||||
version "0.5.3"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.5.3.tgz#721fe6bbb90d74b0c80d6ff2428e5bbcb002becb"
|
||||
integrity sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==
|
||||
"@eslint/config-helpers@^0.5.5":
|
||||
version "0.5.5"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.5.5.tgz#ae16134e4792ac5fbdc533548a24ac1ea9f7f3ae"
|
||||
integrity sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==
|
||||
dependencies:
|
||||
"@eslint/core" "^1.1.1"
|
||||
"@eslint/core" "^1.2.1"
|
||||
|
||||
"@eslint/core@^1.1.1":
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/core/-/core-1.1.1.tgz#450f3d2be2d463ccd51119544092256b4e88df32"
|
||||
integrity sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==
|
||||
"@eslint/core@^1.2.1":
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/core/-/core-1.2.1.tgz#c1da7cd1b82fa8787f98b5629fb811848a1b63ce"
|
||||
integrity sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==
|
||||
dependencies:
|
||||
"@types/json-schema" "^7.0.15"
|
||||
|
||||
@@ -62,17 +62,17 @@
|
||||
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-10.0.1.tgz#1e8a876f50117af8ab67e47d5ad94d38d6622583"
|
||||
integrity sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==
|
||||
|
||||
"@eslint/object-schema@^3.0.3":
|
||||
version "3.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-3.0.3.tgz#5bf671e52e382e4adc47a9906f2699374637db6b"
|
||||
integrity sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==
|
||||
"@eslint/object-schema@^3.0.5":
|
||||
version "3.0.5"
|
||||
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.6.1":
|
||||
version "0.6.1"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz#eb9e6689b56ce8bc1855bb33090e63f3fc115e8e"
|
||||
integrity sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==
|
||||
"@eslint/plugin-kit@^0.7.1":
|
||||
version "0.7.1"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz#c4125fd015eceeb09b793109fdbcd4dd0a02d346"
|
||||
integrity sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==
|
||||
dependencies:
|
||||
"@eslint/core" "^1.1.1"
|
||||
"@eslint/core" "^1.2.1"
|
||||
levn "^0.4.1"
|
||||
|
||||
"@humanfs/core@^0.19.1":
|
||||
@@ -103,10 +103,10 @@
|
||||
resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-9.0.0.tgz#4d0a3f127058043bf2e7ee169eaf30ed901302f3"
|
||||
integrity sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==
|
||||
|
||||
"@langchain/core@^1.1.38":
|
||||
version "1.1.38"
|
||||
resolved "https://registry.yarnpkg.com/@langchain/core/-/core-1.1.38.tgz#b5939b2e77af408771c9bb95bf56a5ad8183313e"
|
||||
integrity sha512-C340wH1YL10CiVOFlEpQMp0zQE85/eBLKX/gi1Lv7shAyUmR3CQ0t/mXlCd5RsZa6ntAN1kDJnp64ArWey9XAA==
|
||||
"@langchain/core@^1.1.42":
|
||||
version "1.1.42"
|
||||
resolved "https://registry.yarnpkg.com/@langchain/core/-/core-1.1.42.tgz#607bfd34c1414c9cbeb2859fda1b254a14c28085"
|
||||
integrity sha512-d0tN96BrwPMryYyWR9VfyAntSivn7EQrZCe5Kpxum93tcjTXbKKmKvItFec8AluQt88iTcmAJrahUZUNfzGwTA==
|
||||
dependencies:
|
||||
"@cfworker/json-schema" "^4.0.2"
|
||||
"@standard-schema/spec" "^1.1.0"
|
||||
@@ -117,7 +117,6 @@
|
||||
langsmith ">=0.5.0 <1.0.0"
|
||||
mustache "^4.2.0"
|
||||
p-queue "^6.6.2"
|
||||
uuid "^11.1.0"
|
||||
zod "^3.25.76 || ^4"
|
||||
|
||||
"@langchain/langgraph-checkpoint@^1.0.1":
|
||||
@@ -127,23 +126,23 @@
|
||||
dependencies:
|
||||
uuid "^10.0.0"
|
||||
|
||||
"@langchain/langgraph-sdk@~1.8.1":
|
||||
version "1.8.3"
|
||||
resolved "https://registry.yarnpkg.com/@langchain/langgraph-sdk/-/langgraph-sdk-1.8.3.tgz#deedc9bddd863693c6ab5f3a74058548b040377d"
|
||||
integrity sha512-Py0S5yVtlOHz410aEsSGLRKjtsK2giDvfPS1JjAjTdcs71khuJufFtUZFwmwdJCbsG4DaGurRLHOAJu9jO4a0g==
|
||||
"@langchain/langgraph-sdk@~1.8.9":
|
||||
version "1.8.10"
|
||||
resolved "https://registry.yarnpkg.com/@langchain/langgraph-sdk/-/langgraph-sdk-1.8.10.tgz#bd175cda1ab629845ef28b22b45f3bdd1f65b029"
|
||||
integrity sha512-wrB3rkRw5KAmsqezwvKP3midT4qJrV6Hj9XJMYo+cbvXC4HYpSAmyY/VriSyeTFRbLG/OP/pY2Yz+9Z54nSaXQ==
|
||||
dependencies:
|
||||
"@types/json-schema" "^7.0.15"
|
||||
p-queue "^9.0.1"
|
||||
p-retry "^7.1.1"
|
||||
uuid "^13.0.0"
|
||||
|
||||
"@langchain/langgraph@^1.2.6":
|
||||
version "1.2.6"
|
||||
resolved "https://registry.yarnpkg.com/@langchain/langgraph/-/langgraph-1.2.6.tgz#e6a578f96fbbc1c54ba78e0298884671dad5d195"
|
||||
integrity sha512-5cX402dNGN6w9+0mlMU2dgUiKx6Y1tlENp7x05e9ByDbQCHSDc0kyqRWNFLGc7vatQ92S4ylxQrcCJvi8Fr4SQ==
|
||||
"@langchain/langgraph@^1.2.9":
|
||||
version "1.2.9"
|
||||
resolved "https://registry.yarnpkg.com/@langchain/langgraph/-/langgraph-1.2.9.tgz#4f0ba159062c3649c83cf5f621ef926b96933e32"
|
||||
integrity sha512-3c7BtGycHC2v9p6w/Hv8L7kEl1YnZYOQTDJtmAp3knk6JOedO7d2bYP3y0SRyhv5orUEGf/KGvx8ZsB/ideP7g==
|
||||
dependencies:
|
||||
"@langchain/langgraph-checkpoint" "^1.0.1"
|
||||
"@langchain/langgraph-sdk" "~1.8.1"
|
||||
"@langchain/langgraph-sdk" "~1.8.9"
|
||||
"@standard-schema/spec" "1.1.0"
|
||||
uuid "^10.0.0"
|
||||
|
||||
@@ -167,35 +166,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.3":
|
||||
version "2.9.3"
|
||||
resolved "https://registry.yarnpkg.com/@turbo/darwin-64/-/darwin-64-2.9.3.tgz#5d782d341ddebc20822d09ba1393238d4467ae7b"
|
||||
integrity sha512-P8foouaP+y/p+hhEGBoZpzMbpVvUMwPjDpcy6wN7EYfvvyISD1USuV27qWkczecihwuPJzQ1lDBuL8ERcavTyg==
|
||||
"@turbo/darwin-64@2.9.7":
|
||||
version "2.9.7"
|
||||
resolved "https://registry.yarnpkg.com/@turbo/darwin-64/-/darwin-64-2.9.7.tgz#46fddae01ea7817192dc6ff36a678cc3879b753b"
|
||||
integrity sha512-wnvOWuVWJ5EUHNKxExEWiGlTeVpLG1L0PCu5MUozyC1P2SHGiWsmpW6/yAuShH91Fa2TAHOvdCRBzriZh4j4Eg==
|
||||
|
||||
"@turbo/darwin-arm64@2.9.3":
|
||||
version "2.9.3"
|
||||
resolved "https://registry.yarnpkg.com/@turbo/darwin-arm64/-/darwin-arm64-2.9.3.tgz#75cd662d9c2a572532ce82d7cae1a30e2497d693"
|
||||
integrity sha512-SIzEkvtNdzdI50FJDaIQ6kQGqgSSdFPcdn0wqmmONN6iGKjy6hsT+EH99GP65FsfV7DLZTh2NmtTIRl2kdoz5Q==
|
||||
"@turbo/darwin-arm64@2.9.7":
|
||||
version "2.9.7"
|
||||
resolved "https://registry.yarnpkg.com/@turbo/darwin-arm64/-/darwin-arm64-2.9.7.tgz#cd7cf3a024509af0f59ae3885216d62fd090fdb6"
|
||||
integrity sha512-mA0FIPMwwN3lodDkQYaGxj6PeT7ZaN5aCEbkKn/WB+ZB9yJdVWA4J83GH7t43jqDc5dcnVluVN5UFx3plRiXhA==
|
||||
|
||||
"@turbo/linux-64@2.9.3":
|
||||
version "2.9.3"
|
||||
resolved "https://registry.yarnpkg.com/@turbo/linux-64/-/linux-64-2.9.3.tgz#de693a996071654a0bea51c7ed9dc6fdc3950ae8"
|
||||
integrity sha512-pLRwFmcHHNBvsCySLS6OFabr/07kDT2pxEt/k6eBf/3asiVQZKJ7Rk88AafQx2aYA641qek4RsXvYO3JYpiBug==
|
||||
"@turbo/linux-64@2.9.7":
|
||||
version "2.9.7"
|
||||
resolved "https://registry.yarnpkg.com/@turbo/linux-64/-/linux-64-2.9.7.tgz#25ee9a2cb3042498a3203e14653b14041105020f"
|
||||
integrity sha512-fEbUYpgb5l7P+q+5tsWF2gw+/GSjUsuUTcnfm+f0lozUjgcjLKyOat6PgtAChmIFcTPchCL/8rJ3TvkBy01gfA==
|
||||
|
||||
"@turbo/linux-arm64@2.9.3":
|
||||
version "2.9.3"
|
||||
resolved "https://registry.yarnpkg.com/@turbo/linux-arm64/-/linux-arm64-2.9.3.tgz#88fc6fe572d83c5488eeae9b564b57c335f4d856"
|
||||
integrity sha512-gy6ApUroC2Nzv+qjGtE/uPNkhHAFU4c8God+zd5Aiv9L9uBgHlxVJpHT3XWl5xwlJZ2KWuMrlHTaS5kmNB+q1Q==
|
||||
"@turbo/linux-arm64@2.9.7":
|
||||
version "2.9.7"
|
||||
resolved "https://registry.yarnpkg.com/@turbo/linux-arm64/-/linux-arm64-2.9.7.tgz#5b19d6249a50cb8153b8f2bc22ad10bbda614fbe"
|
||||
integrity sha512-VkUjulo9ytfHKUHOS5gy0XPoh4CTKPXWCL8nLdrlHVi9fSut31ECeUqnm/dAbETP5D4xo9mH9XkJ+qMzGe/zmg==
|
||||
|
||||
"@turbo/windows-64@2.9.3":
|
||||
version "2.9.3"
|
||||
resolved "https://registry.yarnpkg.com/@turbo/windows-64/-/windows-64-2.9.3.tgz#a7812e4f33972f74daed93b2ac8845f88a68c775"
|
||||
integrity sha512-d0YelTX6hAsB7kIEtGB3PzIzSfAg3yDoUlHwuwJc3adBXUsyUIs0YLG+1NNtuhcDOUGnWQeKUoJ2pGWvbpRj7w==
|
||||
"@turbo/windows-64@2.9.7":
|
||||
version "2.9.7"
|
||||
resolved "https://registry.yarnpkg.com/@turbo/windows-64/-/windows-64-2.9.7.tgz#900645776fa44ff8333e801d890992736f6dbc72"
|
||||
integrity sha512-/GWdY6/x4aIHqkYJq596Rpdk1x0MkpRPkJcLAoB3yGRwyUms0+u2F1GnV54IbyAZTeKLRWSJKzNC+QwVGdYchA==
|
||||
|
||||
"@turbo/windows-arm64@2.9.3":
|
||||
version "2.9.3"
|
||||
resolved "https://registry.yarnpkg.com/@turbo/windows-arm64/-/windows-arm64-2.9.3.tgz#1891a89fe87d501e63a7a4367082f3afec18fa6f"
|
||||
integrity sha512-/08CwpKJl3oRY8nOlh2YgilZVJDHsr60XTNxRhuDeuFXONpUZ5X+Nv65izbG/xBew9qxcJFbDX9/sAmAX+ITcQ==
|
||||
"@turbo/windows-arm64@2.9.7":
|
||||
version "2.9.7"
|
||||
resolved "https://registry.yarnpkg.com/@turbo/windows-arm64/-/windows-arm64-2.9.7.tgz#e678d73fdbcbb12a679403fe7ba070fb67f37810"
|
||||
integrity sha512-xBBgxCC5PK2+WZ1PPRZdp+aJ0bMBcEbweXWux3RUHJvX9ZodcoQySkrW6qt+ahb+uk8ZjyQodLfDwtVSoYds1w==
|
||||
|
||||
"@types/esrecurse@^4.3.1":
|
||||
version "4.3.1"
|
||||
@@ -217,100 +216,100 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
|
||||
integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==
|
||||
|
||||
"@typescript-eslint/eslint-plugin@^8.58.0":
|
||||
version "8.58.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz#ad40e492f1931f46da1bd888e52b9e56df9063aa"
|
||||
integrity sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==
|
||||
"@typescript-eslint/eslint-plugin@^8.59.1":
|
||||
version "8.59.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz#781bc6f9002982cfaf75a185240e24ad7276628a"
|
||||
integrity sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==
|
||||
dependencies:
|
||||
"@eslint-community/regexpp" "^4.12.2"
|
||||
"@typescript-eslint/scope-manager" "8.58.0"
|
||||
"@typescript-eslint/type-utils" "8.58.0"
|
||||
"@typescript-eslint/utils" "8.58.0"
|
||||
"@typescript-eslint/visitor-keys" "8.58.0"
|
||||
"@typescript-eslint/scope-manager" "8.59.1"
|
||||
"@typescript-eslint/type-utils" "8.59.1"
|
||||
"@typescript-eslint/utils" "8.59.1"
|
||||
"@typescript-eslint/visitor-keys" "8.59.1"
|
||||
ignore "^7.0.5"
|
||||
natural-compare "^1.4.0"
|
||||
ts-api-utils "^2.5.0"
|
||||
|
||||
"@typescript-eslint/parser@^8.58.0":
|
||||
version "8.58.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.58.0.tgz#da04ece1967b6c2fe8f10c3473dabf3825795ef7"
|
||||
integrity sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==
|
||||
"@typescript-eslint/parser@^8.59.1":
|
||||
version "8.59.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.59.1.tgz#835d20a62350659a082a1ae2a60b822c40488905"
|
||||
integrity sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==
|
||||
dependencies:
|
||||
"@typescript-eslint/scope-manager" "8.58.0"
|
||||
"@typescript-eslint/types" "8.58.0"
|
||||
"@typescript-eslint/typescript-estree" "8.58.0"
|
||||
"@typescript-eslint/visitor-keys" "8.58.0"
|
||||
"@typescript-eslint/scope-manager" "8.59.1"
|
||||
"@typescript-eslint/types" "8.59.1"
|
||||
"@typescript-eslint/typescript-estree" "8.59.1"
|
||||
"@typescript-eslint/visitor-keys" "8.59.1"
|
||||
debug "^4.4.3"
|
||||
|
||||
"@typescript-eslint/project-service@8.58.0":
|
||||
version "8.58.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.58.0.tgz#66ceda0aabf7427aec3e2713fa43eb278dead2aa"
|
||||
integrity sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==
|
||||
"@typescript-eslint/project-service@8.59.1":
|
||||
version "8.59.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.59.1.tgz#49efe87c37ef84262f23df8bf62fdc56698ca6fe"
|
||||
integrity sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg==
|
||||
dependencies:
|
||||
"@typescript-eslint/tsconfig-utils" "^8.58.0"
|
||||
"@typescript-eslint/types" "^8.58.0"
|
||||
"@typescript-eslint/tsconfig-utils" "^8.59.1"
|
||||
"@typescript-eslint/types" "^8.59.1"
|
||||
debug "^4.4.3"
|
||||
|
||||
"@typescript-eslint/scope-manager@8.58.0":
|
||||
version "8.58.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.58.0.tgz#e304142775e49a1b7ac3c8bf2536714447c72cab"
|
||||
integrity sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==
|
||||
"@typescript-eslint/scope-manager@8.59.1":
|
||||
version "8.59.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.59.1.tgz#ed90d054fc3db2d0c81464db3a953a94fb85bb58"
|
||||
integrity sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.58.0"
|
||||
"@typescript-eslint/visitor-keys" "8.58.0"
|
||||
"@typescript-eslint/types" "8.59.1"
|
||||
"@typescript-eslint/visitor-keys" "8.59.1"
|
||||
|
||||
"@typescript-eslint/tsconfig-utils@8.58.0", "@typescript-eslint/tsconfig-utils@^8.58.0":
|
||||
version "8.58.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.0.tgz#c5a8edb21f31e0fdee565724e1b984171c559482"
|
||||
integrity sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==
|
||||
"@typescript-eslint/tsconfig-utils@8.59.1", "@typescript-eslint/tsconfig-utils@^8.59.1":
|
||||
version "8.59.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.1.tgz#ba2a779a444f1d5cb92a606f9b209d239fd4cab1"
|
||||
integrity sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA==
|
||||
|
||||
"@typescript-eslint/type-utils@8.58.0":
|
||||
version "8.58.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.58.0.tgz#ce0e72cd967ffbbe8de322db6089bd4374be352f"
|
||||
integrity sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg==
|
||||
"@typescript-eslint/type-utils@8.59.1":
|
||||
version "8.59.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.59.1.tgz#9c83d3f2ed9187a815e8120f72c08317e513e409"
|
||||
integrity sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.58.0"
|
||||
"@typescript-eslint/typescript-estree" "8.58.0"
|
||||
"@typescript-eslint/utils" "8.58.0"
|
||||
"@typescript-eslint/types" "8.59.1"
|
||||
"@typescript-eslint/typescript-estree" "8.59.1"
|
||||
"@typescript-eslint/utils" "8.59.1"
|
||||
debug "^4.4.3"
|
||||
ts-api-utils "^2.5.0"
|
||||
|
||||
"@typescript-eslint/types@8.58.0", "@typescript-eslint/types@^8.58.0":
|
||||
version "8.58.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.58.0.tgz#e94ae7abdc1c6530e71183c1007b61fa93112a5a"
|
||||
integrity sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==
|
||||
"@typescript-eslint/types@8.59.1", "@typescript-eslint/types@^8.59.1":
|
||||
version "8.59.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.59.1.tgz#c1d014d3f03a97e0113a8899fc9d4e45a7fb0ca9"
|
||||
integrity sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==
|
||||
|
||||
"@typescript-eslint/typescript-estree@8.58.0":
|
||||
version "8.58.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.0.tgz#ed233faa8e2f2a2e1357c3e7d553d6465a0ee59a"
|
||||
integrity sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==
|
||||
"@typescript-eslint/typescript-estree@8.59.1":
|
||||
version "8.59.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.1.tgz#4391fadf98a22c869c5b6522dbf4e491e53e351a"
|
||||
integrity sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==
|
||||
dependencies:
|
||||
"@typescript-eslint/project-service" "8.58.0"
|
||||
"@typescript-eslint/tsconfig-utils" "8.58.0"
|
||||
"@typescript-eslint/types" "8.58.0"
|
||||
"@typescript-eslint/visitor-keys" "8.58.0"
|
||||
"@typescript-eslint/project-service" "8.59.1"
|
||||
"@typescript-eslint/tsconfig-utils" "8.59.1"
|
||||
"@typescript-eslint/types" "8.59.1"
|
||||
"@typescript-eslint/visitor-keys" "8.59.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.58.0":
|
||||
version "8.58.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.58.0.tgz#21a74a7963b0d288b719a4121c7dd555adaab3c3"
|
||||
integrity sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==
|
||||
"@typescript-eslint/utils@8.59.1":
|
||||
version "8.59.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.59.1.tgz#cf6204d69701bbbc5b150f98c18aeef0a42c10bd"
|
||||
integrity sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==
|
||||
dependencies:
|
||||
"@eslint-community/eslint-utils" "^4.9.1"
|
||||
"@typescript-eslint/scope-manager" "8.58.0"
|
||||
"@typescript-eslint/types" "8.58.0"
|
||||
"@typescript-eslint/typescript-estree" "8.58.0"
|
||||
"@typescript-eslint/scope-manager" "8.59.1"
|
||||
"@typescript-eslint/types" "8.59.1"
|
||||
"@typescript-eslint/typescript-estree" "8.59.1"
|
||||
|
||||
"@typescript-eslint/visitor-keys@8.58.0":
|
||||
version "8.58.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.0.tgz#2abd55a4be70fd55967aceaba4330b9ba9f45189"
|
||||
integrity sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==
|
||||
"@typescript-eslint/visitor-keys@8.59.1":
|
||||
version "8.59.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.1.tgz#b5cba576287a3eeb0b400b62813189abcc3f976a"
|
||||
integrity sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.58.0"
|
||||
"@typescript-eslint/types" "8.59.1"
|
||||
eslint-visitor-keys "^5.0.0"
|
||||
|
||||
acorn-jsx@^5.3.2:
|
||||
@@ -787,17 +786,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.1.0:
|
||||
version "10.1.0"
|
||||
resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.1.0.tgz#9ca98e654e642ab2e1af6d1e9d8613857ac341b4"
|
||||
integrity sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA==
|
||||
eslint@^10.2.1:
|
||||
version "10.2.1"
|
||||
resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.2.1.tgz#224b2a6caeb34473eddcf918762363e2e063222a"
|
||||
integrity sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q==
|
||||
dependencies:
|
||||
"@eslint-community/eslint-utils" "^4.8.0"
|
||||
"@eslint-community/regexpp" "^4.12.2"
|
||||
"@eslint/config-array" "^0.23.3"
|
||||
"@eslint/config-helpers" "^0.5.3"
|
||||
"@eslint/core" "^1.1.1"
|
||||
"@eslint/plugin-kit" "^0.6.1"
|
||||
"@eslint/config-array" "^0.23.5"
|
||||
"@eslint/config-helpers" "^0.5.5"
|
||||
"@eslint/core" "^1.2.1"
|
||||
"@eslint/plugin-kit" "^0.7.1"
|
||||
"@humanfs/node" "^0.16.6"
|
||||
"@humanwhocodes/module-importer" "^1.0.1"
|
||||
"@humanwhocodes/retry" "^0.4.2"
|
||||
@@ -1559,10 +1558,10 @@ prettier-linter-helpers@^1.0.1:
|
||||
dependencies:
|
||||
fast-diff "^1.1.2"
|
||||
|
||||
prettier@^3.8.1:
|
||||
version "3.8.1"
|
||||
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.1.tgz#edf48977cf991558f4fcbd8a3ba6015ba2a3a173"
|
||||
integrity sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==
|
||||
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==
|
||||
|
||||
punycode@^2.1.0:
|
||||
version "2.3.1"
|
||||
@@ -1815,17 +1814,17 @@ tsconfig-paths@^3.15.0:
|
||||
minimist "^1.2.6"
|
||||
strip-bom "^3.0.0"
|
||||
|
||||
turbo@^2.9.3:
|
||||
version "2.9.3"
|
||||
resolved "https://registry.yarnpkg.com/turbo/-/turbo-2.9.3.tgz#4f0adf2c8677edd7b46d400629c99e9e26e58e44"
|
||||
integrity sha512-J/VUvsGRykPb9R8Kh8dHVBOqioDexLk9BhLCU/ZybRR+HN9UR3cURdazFvNgMDt9zPP8TF6K73Z+tplfmi0PqQ==
|
||||
turbo@^2.9.7:
|
||||
version "2.9.7"
|
||||
resolved "https://registry.yarnpkg.com/turbo/-/turbo-2.9.7.tgz#da6d5a821f0bde5174e1a55af6690dad5c2fb004"
|
||||
integrity sha512-epxzqVO2s0IxcSWcgb+qKrtco8isfe7g3VtiS6hkYnEK4A9XQDZbrtavQ6MtWR1KoQn+1fUomaQth2rfRHlUlg==
|
||||
optionalDependencies:
|
||||
"@turbo/darwin-64" "2.9.3"
|
||||
"@turbo/darwin-arm64" "2.9.3"
|
||||
"@turbo/linux-64" "2.9.3"
|
||||
"@turbo/linux-arm64" "2.9.3"
|
||||
"@turbo/windows-64" "2.9.3"
|
||||
"@turbo/windows-arm64" "2.9.3"
|
||||
"@turbo/darwin-64" "2.9.7"
|
||||
"@turbo/darwin-arm64" "2.9.7"
|
||||
"@turbo/linux-64" "2.9.7"
|
||||
"@turbo/linux-arm64" "2.9.7"
|
||||
"@turbo/windows-64" "2.9.7"
|
||||
"@turbo/windows-arm64" "2.9.7"
|
||||
|
||||
type-check@^0.4.0, type-check@~0.4.0:
|
||||
version "0.4.0"
|
||||
@@ -1906,11 +1905,6 @@ uuid@10.0.0, uuid@^10.0.0:
|
||||
resolved "https://registry.yarnpkg.com/uuid/-/uuid-10.0.0.tgz#5a95aa454e6e002725c79055fd42aaba30ca6294"
|
||||
integrity sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==
|
||||
|
||||
uuid@^11.1.0:
|
||||
version "11.1.0"
|
||||
resolved "https://registry.yarnpkg.com/uuid/-/uuid-11.1.0.tgz#9549028be1753bb934fc96e2bca09bb4105ae912"
|
||||
integrity sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==
|
||||
|
||||
uuid@^13.0.0:
|
||||
version "13.0.0"
|
||||
resolved "https://registry.yarnpkg.com/uuid/-/uuid-13.0.0.tgz#263dc341b19b4d755eb8fe36b78d95a6b65707e8"
|
||||
|
||||
@@ -29,7 +29,7 @@ inmem = [
|
||||
|
||||
[project.urls]
|
||||
Source = "https://github.com/langchain-ai/langgraph/tree/main/libs/cli"
|
||||
Twitter = "https://x.com/LangChain"
|
||||
Twitter = "https://x.com/langchain_oss"
|
||||
Slack = "https://www.langchain.com/join-community"
|
||||
Reddit = "https://www.reddit.com/r/LangChain/"
|
||||
|
||||
|
||||
Generated
+72
-71
@@ -2,7 +2,8 @@ version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.10"
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.11'",
|
||||
"python_full_version >= '3.15'",
|
||||
"python_full_version >= '3.11' and python_full_version < '3.15'",
|
||||
"python_full_version < '3.11'",
|
||||
]
|
||||
|
||||
@@ -362,14 +363,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.3.2"
|
||||
version = "8.3.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1358,7 +1359,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "mypy"
|
||||
version = "1.20.1"
|
||||
version = "1.20.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "librt", marker = "platform_python_implementation != 'PyPy'" },
|
||||
@@ -1367,51 +1368,51 @@ dependencies = [
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0b/3d/5b373635b3146264eb7a68d09e5ca11c305bbb058dfffbb47c47daf4f632/mypy-1.20.1.tar.gz", hash = "sha256:6fc3f4ecd52de81648fed1945498bf42fa2993ddfad67c9056df36ae5757f804", size = 3815892, upload-time = "2026-04-13T02:46:51.474Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/04/af/e3d4b3e9ec91a0ff9aabfdb38692952acf49bbb899c2e4c29acb3a6da3ae/mypy-1.20.2.tar.gz", hash = "sha256:e8222c26daaafd9e8626dec58ae36029f82585890589576f769a650dd20fd665", size = 3817349, upload-time = "2026-04-21T17:12:28.473Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/21/4b/b1fa23297c8a5c403aabaac0649549efc5a0af7095f3dd33e7482863f973/mypy-1.20.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3ba5d1e712ada9c3b6223dcbc5a31dac334ed62991e5caa17bcf5a4ddc349af0", size = 14426426, upload-time = "2026-04-13T02:46:37.828Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/53/82923480aee5507a46df22428316e28b2b710d08506a128b2acef81ab18e/mypy-1.20.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e731284c117b0987fb1e6c5013a56f33e7faa1fce594066ab83876183ce1c66", size = 13307651, upload-time = "2026-04-13T02:46:22.676Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/0c/91905b393c790440fa273f0903ee2b07cce95bb6deccac87e6eb343d077a/mypy-1.20.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8e945b872a05f4fbefabe2249c0b07b6b194e5e11a86ebee9edf855de09806c", size = 13746066, upload-time = "2026-04-13T02:45:15.345Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/b9/8a7017270438e34544e19dd6284cad54fd65dde3c35418a2ce07a1897804/mypy-1.20.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fc88acef0dc9b15246502b418980478c1bfc9702057a0e1e7598d01a7af8937", size = 14617944, upload-time = "2026-04-13T02:45:44.954Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/cf/5a61ceec3fc133e0f559d1e1f9adf4150abdbc2ad8eb831ec26fc8459196/mypy-1.20.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:14911a115c73608f155f648b978c5055d16ff974e6b1b5512d7fedf4fa8b15c6", size = 14918205, upload-time = "2026-04-13T02:45:42.653Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/80/afb1c665e9c426c78e4711cce04e446b645867bfb97936158886103c1648/mypy-1.20.1-cp310-cp310-win_amd64.whl", hash = "sha256:76d9b4c992cca3331d9793ef197ae360ea44953cf35beb2526e95b9e074f2866", size = 10823344, upload-time = "2026-04-13T02:46:07.607Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/68/7ad64b49b7663c88fef76a2ac689ea73e17804832ac4cb5416bcff17775b/mypy-1.20.1-cp310-cp310-win_arm64.whl", hash = "sha256:b408722f80be44845da555671a5ef3a0c63f51ca5752b0c20e992dc9c0fbd3cd", size = 9760694, upload-time = "2026-04-13T02:46:49.369Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/0d/555ab7453cc4a4a8643b7f21c842b1a84c36b15392061ae7b052ee119320/mypy-1.20.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c01eb9bac2c6a962d00f9d23421cd2913840e65bba365167d057bd0b4171a92e", size = 14336012, upload-time = "2026-04-13T02:45:39.935Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/26/85a28893f7db8a16ebb41d1e9dfcb4475844d06a88480b6639e32a74d6ef/mypy-1.20.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:55d12ddbd8a9cac5b276878bd534fa39fff5bf543dc6ae18f25d30c8d7d27fca", size = 13224636, upload-time = "2026-04-13T02:45:49.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/41/bd4cd3c2caeb6c448b669222b8cfcbdee4a03b89431527b56fca9e56b6f3/mypy-1.20.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0aa322c1468b6cdfc927a44ce130f79bb44bcd34eb4a009eb9f96571fd80955", size = 13663471, upload-time = "2026-04-13T02:46:20.276Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/56/7ee8c471e10402d64b6517ae10434541baca053cffd81090e4097d5609d4/mypy-1.20.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3f8bc95899cf676b6e2285779a08a998cc3a7b26f1026752df9d2741df3c79e8", size = 14532344, upload-time = "2026-04-13T02:46:44.205Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/95/b37d1fa859a433f6156742e12f62b0bb75af658544fb6dada9363918743a/mypy-1.20.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:47c2b90191a870a04041e910277494b0d92f0711be9e524d45c074fe60c00b65", size = 14776670, upload-time = "2026-04-13T02:45:52.481Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/77/b302e4cb0b80d2bdf6bf4fce5864bb4cbfa461f7099cea544eaf2457df78/mypy-1.20.1-cp311-cp311-win_amd64.whl", hash = "sha256:9857dc8d2ec1a392ffbda518075beb00ac58859979c79f9e6bdcb7277082c2f2", size = 10816524, upload-time = "2026-04-13T02:45:37.711Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/21/d969d7a68eb964993ebcc6170d5ecaf0cf65830c58ac3344562e16dc42a9/mypy-1.20.1-cp311-cp311-win_arm64.whl", hash = "sha256:09d8df92bb25b6065ab91b178da843dda67b33eb819321679a6e98a907ce0e10", size = 9750419, upload-time = "2026-04-13T02:45:08.542Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/1b/75a7c825a02781ca10bc2f2f12fba2af5202f6d6005aad8d2d1f264d8d78/mypy-1.20.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:36ee2b9c6599c230fea89bbd79f401f9f9f8e9fcf0c777827789b19b7da90f51", size = 14494077, upload-time = "2026-04-13T02:45:55.085Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/54/5e5a569ea5c2b4d48b729fb32aa936eeb4246e4fc3e6f5b3d36a2dfbefb9/mypy-1.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fba3fb0968a7b48806b0c90f38d39296f10766885a94c83bd21399de1e14eb28", size = 13319495, upload-time = "2026-04-13T02:45:29.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/a4/a1945b19f33e91721b59deee3abb484f2fa5922adc33bb166daf5325d76d/mypy-1.20.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef1415a637cd3627d6304dfbeddbadd21079dafc2a8a753c477ce4fc0c2af54f", size = 13696948, upload-time = "2026-04-13T02:46:15.006Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/c6/75e969781c2359b2f9c15b061f28ec6d67c8b61865ceda176e85c8e7f2de/mypy-1.20.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef3461b1ad5cd446e540016e90b5984657edda39f982f4cc45ca317b628f5a37", size = 14706744, upload-time = "2026-04-13T02:46:00.482Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/6e/b221b1de981fc4262fe3e0bf9ec272d292dfe42394a689c2d49765c144c4/mypy-1.20.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:542dd63c9e1339b6092eb25bd515f3a32a1453aee8c9521d2ddb17dacd840237", size = 14949035, upload-time = "2026-04-13T02:45:06.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/4b/298ba2de0aafc0da3ff2288da06884aae7ba6489bc247c933f87847c41b3/mypy-1.20.1-cp312-cp312-win_amd64.whl", hash = "sha256:1d55c7cd8ca22e31f93af2a01160a9e95465b5878de23dba7e48116052f20a8d", size = 10883216, upload-time = "2026-04-13T02:45:47.232Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/f9/5e25b8f0b8cb92f080bfed9c21d3279b2a0b6a601cdca369a039ba84789d/mypy-1.20.1-cp312-cp312-win_arm64.whl", hash = "sha256:f5b84a79070586e0d353ee07b719d9d0a4aa7c8ee90c0ea97747e98cbe193019", size = 9814299, upload-time = "2026-04-13T02:45:21.934Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/e8/ef0991aa24c8f225df10b034f3c2681213cb54cf247623c6dec9a5744e70/mypy-1.20.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f3886c03e40afefd327bd70b3f634b39ea82e87f314edaa4d0cce4b927ddcc1", size = 14500739, upload-time = "2026-04-13T02:46:05.442Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/73/416ebec3047636ed89fa871dc8c54bf05e9e20aa9499da59790d7adb312d/mypy-1.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e860eb3904f9764e83bafd70c8250bdffdc7dde6b82f486e8156348bf7ceb184", size = 13314735, upload-time = "2026-04-13T02:46:47.154Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/1e/1505022d9c9ac2e014a384eb17638fb37bf8e9d0a833ea60605b66f8f7ba/mypy-1.20.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4b5aac6e785719da51a84f5d09e9e843d473170a9045b1ea7ea1af86225df4b", size = 13704356, upload-time = "2026-04-13T02:45:19.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/91/275b01f5eba5c467a3318ec214dd865abb66e9c811231c8587287b92876a/mypy-1.20.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f37b6cd0fe2ad3a20f05ace48ca3523fc52ff86940e34937b439613b6854472e", size = 14696420, upload-time = "2026-04-13T02:45:24.205Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/57/b3779e134e1b7250d05f874252780d0a88c068bc054bcff99ca20a3a2986/mypy-1.20.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e4bbb0f6b54ce7cc350ef4a770650d15fa70edd99ad5267e227133eda9c94218", size = 14936093, upload-time = "2026-04-13T02:45:32.087Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/33/81b64991b0f3f278c3b55c335888794af190b2d59031a5ad1401bcb69f1e/mypy-1.20.1-cp313-cp313-win_amd64.whl", hash = "sha256:c3dc20f8ec76eecd77148cdd2f1542ed496e51e185713bf488a414f862deb8f2", size = 10889659, upload-time = "2026-04-13T02:46:02.926Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/fd/7adcb8053572edf5ef8f3db59599dfeeee3be9cc4c8c97e2d28f66f42ac5/mypy-1.20.1-cp313-cp313-win_arm64.whl", hash = "sha256:a9d62bbac5d6d46718e2b0330b25e6264463ed832722b8f7d4440ff1be3ca895", size = 9815515, upload-time = "2026-04-13T02:46:32.103Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/cd/db831e84c81d57d4886d99feee14e372f64bbec6a9cb1a88a19e243f2ef5/mypy-1.20.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:12927b9c0ed794daedcf1dab055b6c613d9d5659ac511e8d936d96f19c087d12", size = 14483064, upload-time = "2026-04-13T02:45:26.901Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/82/74e62e7097fa67da328ac8ece8de09133448c04d20ddeaeba251a3000f01/mypy-1.20.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:752507dd481e958b2c08fc966d3806c962af5a9433b5bf8f3bdd7175c20e34fe", size = 13335694, upload-time = "2026-04-13T02:46:12.514Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/c4/97e9a0abe4f3cdbbf4d079cb87a03b786efeccf5bf2b89fe4f96939ab2e6/mypy-1.20.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c614655b5a065e56274c6cbbe405f7cf7e96c0654db7ba39bc680238837f7b08", size = 13726365, upload-time = "2026-04-13T02:45:17.422Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/aa/a19d884a8d28fcd3c065776323029f204dbc774e70ec9c85eba228b680de/mypy-1.20.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2c3f6221a76f34d5100c6d35b3ef6b947054123c3f8d6938a4ba00b1308aa572", size = 14693472, upload-time = "2026-04-13T02:46:41.253Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/44/cc9324bd21cf786592b44bf3b5d224b3923c1230ec9898d508d00241d465/mypy-1.20.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4bdfc06303ac06500af71ea0cdbe995c502b3c9ba32f3f8313523c137a25d1b6", size = 14919266, upload-time = "2026-04-13T02:46:28.37Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/dc/779abb25a8c63e8f44bf5a336217fa92790fa17e0c40e0c725d10cb01bbd/mypy-1.20.1-cp314-cp314-win_amd64.whl", hash = "sha256:0131edd7eba289973d1ba1003d1a37c426b85cdef76650cd02da6420898a5eb3", size = 11049713, upload-time = "2026-04-13T02:45:57.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/08/4172be2ad7de9119b5a92ca36abbf641afdc5cb1ef4ae0c3a8182f29674f/mypy-1.20.1-cp314-cp314-win_arm64.whl", hash = "sha256:33f02904feb2c07e1fdf7909026206396c9deeb9e6f34d466b4cfedb0aadbbe4", size = 9999819, upload-time = "2026-04-13T02:46:35.039Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/af/af9e46b0c8eabbce9fc04a477564170f47a1c22b308822282a59b7ff315f/mypy-1.20.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:168472149dd8cc505c98cefd21ad77e4257ed6022cd5ed2fe2999bed56977a5a", size = 15547508, upload-time = "2026-04-13T02:46:25.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/cd/39c9e4ad6ba33e069e5837d772a9e6c304b4a5452a14a975d52b36444650/mypy-1.20.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:eb674600309a8f22790cca883a97c90299f948183ebb210fbef6bcee07cb1986", size = 14399557, upload-time = "2026-04-13T02:46:10.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/c1/3fd71bdc118ffc502bf57559c909927bb7e011f327f7bb8e0488e98a5870/mypy-1.20.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef2b2e4cc464ba9795459f2586923abd58a0055487cbe558cb538ea6e6bc142a", size = 15045789, upload-time = "2026-04-13T02:45:10.81Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/73/6f07ff8b57a7d7b3e6e5bf34685d17632382395c8bb53364ec331661f83e/mypy-1.20.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dee461d396dd46b3f0ed5a098dbc9b8860c81c46ad44fa071afcfbc149f167c9", size = 15850795, upload-time = "2026-04-13T02:45:03.349Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/e2/f7dffec1c7767078f9e9adf0c786d1fe0ff30964a77eb213c09b8b58cb76/mypy-1.20.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e364926308b3e66f1361f81a566fc1b2f8cd47fc8525e8136d4058a65a4b4f02", size = 16088539, upload-time = "2026-04-13T02:46:17.841Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/76/e0dee71035316e75a69d73aec2f03c39c21c967b97e277fd0ef8fd6aec66/mypy-1.20.1-cp314-cp314t-win_amd64.whl", hash = "sha256:a0c17fbd746d38c70cbc42647cfd884f845a9708a4b160a8b4f7e70d41f4d7fa", size = 12575567, upload-time = "2026-04-13T02:45:34.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/a8/7ed43c9d9c3d1468f86605e323a5d97e411a448790a00f07e779f3211a46/mypy-1.20.1-cp314-cp314t-win_arm64.whl", hash = "sha256:db2cb89654626a912efda69c0d5c1d22d948265e2069010d3dde3abf751c7d08", size = 10378823, upload-time = "2026-04-13T02:45:13.35Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/28/926bd972388e65a39ee98e188ccf67e81beb3aacfd5d6b310051772d974b/mypy-1.20.1-py3-none-any.whl", hash = "sha256:1aae28507f253fe82d883790d1c0a0d35798a810117c88184097fe8881052f06", size = 2636553, upload-time = "2026-04-13T02:46:30.45Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/97/ce2502df2cecf2ef997b6c6527c4a223b92feb9e7b790cdc8dcd683f3a8a/mypy-1.20.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cf5a4db6dca263010e2c7bff081c89383c72d187ba2cf4c44759aac970e2f0c4", size = 14457059, upload-time = "2026-04-21T17:06:14.935Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/34/417ee60b822cc80c0f3dc9f495ad7fd8dbb8d8b2cf4baf22d4046d25d01d/mypy-1.20.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7b0e817b518bff7facd7f85ea05b643ad8bdcce684cf29784987b0a7c8e1f997", size = 13346816, upload-time = "2026-04-21T17:10:41.433Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/85/e20951978702df58379d0bcc2e8f7ccdca4e78cd7dc66dd3ddbf9b29d517/mypy-1.20.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97d7b9a485b40f8ca425460e89bf1da2814625b2da627c0dcc6aa46c92631d14", size = 13772593, upload-time = "2026-04-21T17:08:11.24Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/a5/5441a13259ec516c56fd5de0fd96a69a9590ae6c5e5d3e5174aa84b97973/mypy-1.20.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e1c12f6d2db3d78b909b5f77513c11eb7f2dd2782b96a3ab6dffc7d44575c99", size = 14656635, upload-time = "2026-04-21T17:09:54.042Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/51/b89c69157c5e1f19fd125a65d991166a26906e7902f026f00feebbcfa2b9/mypy-1.20.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:89dce27e142d25ffbc154c1819383b69f2e9234dc4ed4766f42e0e8cb264ab5c", size = 14943278, upload-time = "2026-04-21T17:09:15.599Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/44/6b0eeecfe96d7cce1d71c66b8e03cb304aa70ec11f1955dc1d6b46aca3c3/mypy-1.20.2-cp310-cp310-win_amd64.whl", hash = "sha256:f376e37f9bf2a946872fc5fd1199c99310748e3c26c7a26683f13f8bdb756cbd", size = 10851915, upload-time = "2026-04-21T17:06:03.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/36/6593dc88545d75fb96416184be5392da5e2a8e8c2802a8597913e16ae25c/mypy-1.20.2-cp310-cp310-win_arm64.whl", hash = "sha256:6e2b469efd811707bc530fd1effef0f5d6eebcb7fe376affae69025da4b979a2", size = 9786676, upload-time = "2026-04-21T17:07:02.035Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/4d/9ebeae211caccbdaddde7ed5e31dfcf57faac66be9b11deb1dc6526c8078/mypy-1.20.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4077797a273e56e8843d001e9dfe4ba10e33323d6ade647ff260e5cd97d9758c", size = 14371307, upload-time = "2026-04-21T17:08:56.442Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/d7/93473d34b61f04fac1aecc01368485c89c5c4af7a4b9a0cab5d77d04b63f/mypy-1.20.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cdecf62abcc4292500d7858aeae87a1f8f1150f4c4dd08fb0b336ee79b2a6df3", size = 13258917, upload-time = "2026-04-21T17:05:50.978Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/30/3dd903e8bafb7b5f7bf87fcd58f8382086dea2aa19f0a7b357f21f63071b/mypy-1.20.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c566c3a88b6ece59b3d70f65bedef17304f48eb52ff040a6a18214e1917b3254", size = 13700516, upload-time = "2026-04-21T17:11:33.161Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/05/c61a140aba4c729ac7bc99ae26fc627c78a6e08f5b9dd319244ea71a3d7e/mypy-1.20.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0deb80d062b2479f2c87ae568f89845afc71d11bc41b04179e58165fd9f31e98", size = 14562889, upload-time = "2026-04-21T17:05:27.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/87/da78243742ffa8a36d98c3010f0d829f93d5da4e6786f1a1a6f2ad616502/mypy-1.20.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bba9ad231e92a3e424b3e56b65aa17704993425bba97e302c832f9466bb85bac", size = 14803844, upload-time = "2026-04-21T17:10:06.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/52/10a1ddf91b40f843943a3c6db51e2df59c9e237f29d355e95eaab427461f/mypy-1.20.2-cp311-cp311-win_amd64.whl", hash = "sha256:baf593f2765fa3a6b1ef95807dbaa3d25b594f6a52adcc506a6b9cb115e1be67", size = 10846300, upload-time = "2026-04-21T17:12:23.886Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/02/f9a4415b664c53bd34d6709be59da303abcae986dc4ac847b402edb6fa1e/mypy-1.20.2-cp311-cp311-win_arm64.whl", hash = "sha256:20175a1c0f49863946ec20b7f63255768058ac4f07d2b9ded6a6b46cfb5a9100", size = 9779498, upload-time = "2026-04-21T17:09:23.695Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/4e/7560e4528db9e9b147e4c0f22660466bf30a0a1fe3d63d1b9d3b0fd354ee/mypy-1.20.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4dbfcf869f6b0517f70cf0030ba6ea1d6645e132337a7d5204a18d8d5636c02b", size = 14539393, upload-time = "2026-04-21T17:07:12.52Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/d9/34a5efed8124f5a9234f55ac6a4ced4201e2c5b81e1109c49ad23190ec8c/mypy-1.20.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b6481b228d072315b053210b01ac320e1be243dc17f9e5887ef167f23f5fae4", size = 13361642, upload-time = "2026-04-21T17:06:53.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/14/eb377acf78c03c92d566a1510cda8137348215b5335085ef662ab82ecd3a/mypy-1.20.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34397cdced6b90b836e38182076049fdb41424322e0b0728c946b0939ebdf9f6", size = 13740347, upload-time = "2026-04-21T17:12:04.73Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/94/7e4634a32b641aa1c112422eed1bbece61ee16205f674190e8b536f884de/mypy-1.20.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5da6976f20cae27059ea8d0c86e7cef3de720e04c4bb9ee18e3690fdb792066", size = 14734042, upload-time = "2026-04-21T17:07:43.16Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/f3/f7e62395cb7f434541b4491a01149a4439e28ace4c0c632bbf5431e92d1f/mypy-1.20.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:56908d7e08318d39f85b1f0c6cfd47b0cac1a130da677630dac0de3e0623e102", size = 14964958, upload-time = "2026-04-21T17:11:00.665Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/0d/47e3c3a0ec2a876e35aeac365df3cac7776c36bbd4ed18cc521e1b9d255b/mypy-1.20.2-cp312-cp312-win_amd64.whl", hash = "sha256:d52ad8d78522da1d308789df651ee5379088e77c76cb1994858d40a426b343b9", size = 10911340, upload-time = "2026-04-21T17:10:49.179Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/b2/6c852d72e0ea8b01f49da817fb52539993cde327e7d010e0103dc12d0dac/mypy-1.20.2-cp312-cp312-win_arm64.whl", hash = "sha256:785b08db19c9f214dc37d65f7c165d19a30fcecb48abfa30f31b01b5acaabb58", size = 9833947, upload-time = "2026-04-21T17:09:05.267Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/c4/b93812d3a192c9bcf5df405bd2f30277cd0e48106a14d1023c7f6ed6e39b/mypy-1.20.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:edfbfca868cdd6bd8d974a60f8a3682f5565d3f5c99b327640cedd24c4264026", size = 14524670, upload-time = "2026-04-21T17:10:30.737Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/47/42c122501bff18eaf1e8f457f5c017933452d8acdc52918a9f59f6812955/mypy-1.20.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e2877a02380adfcdbc69071a0f74d6e9dbbf593c0dc9d174e1f223ffd5281943", size = 13336218, upload-time = "2026-04-21T17:08:44.069Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/8f/75bbc92f41725fbd585fb17b440b1119b576105df1013622983e18640a93/mypy-1.20.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7488448de6007cd5177c6cea0517ac33b4c0f5ee9b5e9f2be51ce75511a85517", size = 13724906, upload-time = "2026-04-21T17:08:01.02Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/32/4c49da27a606167391ff0c39aa955707a00edc500572e562f7c36c08a71f/mypy-1.20.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb9c2fa06887e21d6a3a868762acb82aec34e2c6fd0174064f27c93ede68ad15", size = 14726046, upload-time = "2026-04-21T17:11:22.354Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/fc/4e354a1bd70216359deb0c9c54847ee6b32ef78dfb09f5131ff99b494078/mypy-1.20.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d56a78b646f2e3daa865bc70cd5ec5a46c50045801ca8ff17a0c43abc97e3ee", size = 14955587, upload-time = "2026-04-21T17:12:16.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/b2/c0f2056e9eb8f08c62cafd9715e4584b89132bdc832fcf85d27d07b5f3e5/mypy-1.20.2-cp313-cp313-win_amd64.whl", hash = "sha256:2a4102b03bb7481d9a91a6da8d174740c9c8c4401024684b9ca3b7cc5e49852f", size = 10922681, upload-time = "2026-04-21T17:06:35.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/14/065e333721f05de8ef683d0aa804c23026bcc287446b61cac657b902ccac/mypy-1.20.2-cp313-cp313-win_arm64.whl", hash = "sha256:a95a9248b0c6fd933a442c03c3b113c3b61320086b88e2c444676d3fd1ca3330", size = 9830560, upload-time = "2026-04-21T17:07:51.023Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/d1/b4ec96b0ecc620a4443570c6e95c867903428cfcde4206518eafdd5880c3/mypy-1.20.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:419413398fe250aae057fd2fe50166b61077083c9b82754c341cf4fd73038f30", size = 14524561, upload-time = "2026-04-21T17:06:27.325Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/63/d2c2ff4fa66bc49477d32dfa26e8a167ba803ea6a69c5efb416036909d30/mypy-1.20.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e73c07f23009962885c197ccb9b41356a30cc0e5a1d0c2ea8fd8fb1362d7f924", size = 13363883, upload-time = "2026-04-21T17:11:11.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/56/983916806bf4eddeaaa2c9230903c3669c6718552a921154e1c5182c701f/mypy-1.20.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c64e5973df366b747646fc98da921f9d6eba9716d57d1db94a83c026a08e0fb", size = 13742945, upload-time = "2026-04-21T17:08:34.181Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/65/0cd9285ab010ee8214c83d67c6b49417c40d86ce46f1aa109457b5a9b8d7/mypy-1.20.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a65aa591af023864fd08a97da9974e919452cfe19cb146c8a5dc692626445dc", size = 14706163, upload-time = "2026-04-21T17:05:15.51Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/97/48ff3b297cafcc94d185243a9190836fb1b01c1b0918fff64e941e973cc9/mypy-1.20.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4fef51b01e638974a6e69885687e9bd40c8d1e09a6cd291cca0619625cf1f558", size = 14938677, upload-time = "2026-04-21T17:05:39.562Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/a1/1b4233d255bdd0b38a1f284feeb1c143ca508c19184964e22f8d837ec851/mypy-1.20.2-cp314-cp314-win_amd64.whl", hash = "sha256:913485a03f1bcf5d279409a9d2b9ed565c151f61c09f29991e5faa14033da4c8", size = 11089322, upload-time = "2026-04-21T17:06:44.29Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/c2/ce7ee2ba36aeb954ba50f18fa25d9c1188578654b97d02a66a15b6f09531/mypy-1.20.2-cp314-cp314-win_arm64.whl", hash = "sha256:c3bae4f855d965b5453784300c12ffc63a548304ac7f99e55d4dc7c898673aa3", size = 10017775, upload-time = "2026-04-21T17:07:20.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/a1/9d93a7d0b5859af0ead82b4888b46df6c8797e1bc5e1e262a08518c6d48e/mypy-1.20.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2de3dcea53babc1c3237a19002bc3d228ce1833278f093b8d619e06e7cc79609", size = 15549002, upload-time = "2026-04-21T17:08:23.107Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/d2/09a6a10ee1bf0008f6c144d9676f2ca6a12512151b4e0ad0ff6c4fac5337/mypy-1.20.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:52b176444e2e5054dfcbcb8c75b0b719865c96247b37407184bbfca5c353f2c2", size = 14401942, upload-time = "2026-04-21T17:07:31.837Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/da/9594b75c3c019e805250bed3583bdf4443ff9e6ef08f97e39ae308cb06f2/mypy-1.20.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:688c3312e5dadb573a2c69c82af3a298d43ecf9e6d264e0f95df960b5f6ac19c", size = 15041649, upload-time = "2026-04-21T17:09:34.653Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/77/f75a65c278e6e8eba2071f7f5a90481891053ecc39878cc444634d892abe/mypy-1.20.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29752dbbf8cc53f89f6ac096d363314333045c257c9c75cbd189ca2de0455744", size = 15864588, upload-time = "2026-04-21T17:11:44.936Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/46/1a4e1c66e96c1a3246ddf5403d122ac9b0a8d2b7e65730b9d6533ba7a6d3/mypy-1.20.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:803203d2b6ea644982c644895c2f78b28d0e208bba7b27d9b921e0ec5eb207c6", size = 16093956, upload-time = "2026-04-21T17:10:17.683Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/2c/78a8851264dec38cd736ca5b8bc9380674df0dd0be7792f538916157716c/mypy-1.20.2-cp314-cp314t-win_amd64.whl", hash = "sha256:9bcb8aa397ff0093c824182fd76a935a9ba7ad097fcbef80ae89bf6c1731d8ec", size = 12568661, upload-time = "2026-04-21T17:11:54.473Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/01/cd7318aa03493322ce275a0e14f4f52b8896335e4e79d4fb8153a7ad2b77/mypy-1.20.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e061b58443f1736f8a37c48978d7ab581636d6ab03e3d4f99e3fa90463bb9382", size = 10389240, upload-time = "2026-04-21T17:09:42.719Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/9a/f23c163e25b11074188251b0b5a0342625fc1cdb6af604757174fa9acc9b/mypy-1.20.2-py3-none-any.whl", hash = "sha256:a94c5a76ab46c5e6257c7972b6c8cff0574201ca7dc05647e33e795d78680563", size = 2637314, upload-time = "2026-04-21T17:05:54.5Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1653,11 +1654,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pathspec"
|
||||
version = "1.0.4"
|
||||
version = "1.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2086,27 +2087,27 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.11"
|
||||
version = "0.15.12"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/8d/192f3d7103816158dfd5ea50d098ef2aec19194e6cbccd4b3485bdb2eb2d/ruff-0.15.11.tar.gz", hash = "sha256:f092b21708bf0e7437ce9ada249dfe688ff9a0954fc94abab05dcea7dcd29c33", size = 4637264, upload-time = "2026-04-16T18:46:26.58Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/02/1e/6aca3427f751295ab011828e15e9bf452200ac74484f1db4be0197b8170b/ruff-0.15.11-py3-none-linux_armv6l.whl", hash = "sha256:e927cfff503135c558eb581a0c9792264aae9507904eb27809cdcff2f2c847b7", size = 10607943, upload-time = "2026-04-16T18:46:05.967Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/26/1341c262e74f36d4e84f3d6f4df0ac68cd53331a66bfc5080daa17c84c0b/ruff-0.15.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7a1b5b2938d8f890b76084d4fa843604d787a912541eae85fd7e233398bbb73e", size = 10988592, upload-time = "2026-04-16T18:46:00.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/71/850b1d6ffa9564fbb6740429bad53df1094082fe515c8c1e74b6d8d05f18/ruff-0.15.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d4176f3d194afbdaee6e41b9ccb1a2c287dba8700047df474abfbe773825d1cb", size = 10338501, upload-time = "2026-04-16T18:46:03.723Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/11/cc1284d3e298c45a817a6aadb6c3e1d70b45c9b36d8d9cce3387b495a03a/ruff-0.15.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b17c886fb88203ced3afe7f14e8d5ae96e9d2f4ccc0ee66aa19f2c2675a27e4", size = 10670693, upload-time = "2026-04-16T18:46:41.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/9e/f8288b034ab72b371513c13f9a41d9ba3effac54e24bfb467b007daee2ca/ruff-0.15.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:49fafa220220afe7758a487b048de4c8f9f767f37dfefad46b9dd06759d003eb", size = 10416177, upload-time = "2026-04-16T18:46:21.717Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/71/504d79abfd3d92532ba6bbe3d1c19fada03e494332a59e37c7c2dabae427/ruff-0.15.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2ab8427e74a00d93b8bda1307b1e60970d40f304af38bccb218e056c220120d", size = 11221886, upload-time = "2026-04-16T18:46:15.086Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/5a/947e6ab7a5ad603d65b474be15a4cbc6d29832db5d762cd142e4e3a74164/ruff-0.15.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:195072c0c8e1fc8f940652073df082e37a5d9cb43b4ab1e4d0566ab8977a13b7", size = 12075183, upload-time = "2026-04-16T18:46:07.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/a1/0b7bb6268775fdd3a0818aee8efd8f5b4e231d24dd4d528ced2534023182/ruff-0.15.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a0996d486af3920dec930a2e7daed4847dfc12649b537a9335585ada163e9e", size = 11516575, upload-time = "2026-04-16T18:46:31.687Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/c3/bb5168fc4d233cc06e95f482770d0f3c87945a0cd9f614b90ea8dc2f2833/ruff-0.15.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bef2cb556d509259f1fe440bb9cd33c756222cf0a7afe90d15edf0866702431", size = 11306537, upload-time = "2026-04-16T18:46:36.988Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/92/4cfae6441f3967317946f3b788136eecf093729b94d6561f963ed810c82e/ruff-0.15.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:030d921a836d7d4a12cf6e8d984a88b66094ccb0e0f17ddd55067c331191bf19", size = 11296813, upload-time = "2026-04-16T18:46:24.182Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/26/972784c5dde8313acde8ac71ba8ac65475b85db4a2352a76c9934361f9bc/ruff-0.15.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0e783b599b4577788dbbb66b9addcef87e9a8832f4ce0c19e34bf55543a2f890", size = 10633136, upload-time = "2026-04-16T18:46:39.802Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/53/3985a4f185020c2f367f2e08a103032e12564829742a1b417980ce1514a0/ruff-0.15.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ae90592246625ba4a34349d68ec28d4400d75182b71baa196ddb9f82db025ef5", size = 10424701, upload-time = "2026-04-16T18:46:10.381Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/57/bf0dfb32241b56c83bb663a826133da4bf17f682ba8c096973065f6e6a68/ruff-0.15.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1f111d62e3c983ed20e0ca2e800f8d77433a5b1161947df99a5c2a3fb60514f0", size = 10873887, upload-time = "2026-04-16T18:46:29.157Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/05/e48076b2a57dc33ee8c7a957296f97c744ca891a8ffb4ffb1aaa3b3f517d/ruff-0.15.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:06f483d6646f59eaffba9ae30956370d3a886625f511a3108994000480621d1c", size = 11404316, upload-time = "2026-04-16T18:46:19.462Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/27/0195d15fe7a897cbcba0904792c4b7c9fdd958456c3a17d2ea6093716a9a/ruff-0.15.11-py3-none-win32.whl", hash = "sha256:476a2aa56b7da0b73a3ee80b6b2f0e19cce544245479adde7baa65466664d5f3", size = 10655535, upload-time = "2026-04-16T18:46:12.47Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/5e/c927b325bd4c1d3620211a4b96f47864633199feed60fa936025ab27e090/ruff-0.15.11-py3-none-win_amd64.whl", hash = "sha256:8b6756d88d7e234fb0c98c91511aae3cd519d5e3ed271cae31b20f39cb2a12a3", size = 11779692, upload-time = "2026-04-16T18:46:17.268Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/b6/aeadee5443e49baa2facd51131159fd6301cc4ccfc1541e4df7b021c37dd/ruff-0.15.11-py3-none-win_arm64.whl", hash = "sha256:063fed18cc1bbe0ee7393957284a6fe8b588c6a406a285af3ee3f46da2391ee4", size = 11032614, upload-time = "2026-04-16T18:46:34.487Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<a href="https://pypi.org/project/langgraph/" target="_blank"><img src="https://img.shields.io/pypi/v/langgraph.svg?label=%20" alt="Version"></a>
|
||||
<a href="https://github.com/langchain-ai/langgraph/issues" target="_blank"><img src="https://img.shields.io/github/issues-raw/langchain-ai/langgraph" alt="Open Issues"></a>
|
||||
<a href="https://docs.langchain.com/oss/python/langgraph/overview" target="_blank"><img src="https://img.shields.io/badge/docs-latest-blue" alt="Docs"></a>
|
||||
<a href="https://x.com/langchain" target="_blank"><img src="https://img.shields.io/twitter/url/https/twitter.com/langchain.svg?style=social&label=Follow%20%40LangChain" alt="Twitter / X"></a>
|
||||
<a href="https://x.com/langchain_oss" target="_blank"><img src="https://img.shields.io/twitter/url/https/twitter.com/langchain_oss.svg?style=social&label=Follow%20%40LangChain" alt="Twitter / X"></a>
|
||||
</div>
|
||||
|
||||
<br>
|
||||
|
||||
@@ -12,6 +12,9 @@ RESUME = sys.intern("__resume__")
|
||||
# for values passed to resume a node after an interrupt
|
||||
ERROR = sys.intern("__error__")
|
||||
# for errors raised by nodes
|
||||
ERROR_SOURCE_NODE = sys.intern("__error_source_node__")
|
||||
# failed source node name for node-level error handlers
|
||||
# value format in pending writes: `(task_id, ERROR_SOURCE_NODE, node_name: str)`
|
||||
NO_WRITES = sys.intern("__no_writes__")
|
||||
# marker to signal node didn't write anything
|
||||
TASKS = sys.intern("__pregel_tasks")
|
||||
@@ -56,6 +59,8 @@ CONFIG_KEY_CHECKPOINT_NS = sys.intern("checkpoint_ns")
|
||||
# holds the current checkpoint_ns, "" for root graph
|
||||
CONFIG_KEY_NODE_FINISHED = sys.intern("__pregel_node_finished")
|
||||
# holds a callback to be called when a node is finished
|
||||
CONFIG_KEY_TIMED_ATTEMPT_OBSERVER = sys.intern("__pregel_timed_attempt_observer")
|
||||
# holds a callback to be called when an idle-timed node attempt starts or finishes
|
||||
CONFIG_KEY_SCRATCHPAD = sys.intern("__pregel_scratchpad")
|
||||
# holds a mutable dict for temporary storage scoped to the current task
|
||||
CONFIG_KEY_RUNNER_SUBMIT = sys.intern("__pregel_runner_submit")
|
||||
@@ -66,6 +71,13 @@ CONFIG_KEY_RUNTIME = sys.intern("__pregel_runtime")
|
||||
# holds a `Runtime` instance with context, store, stream writer, etc.
|
||||
CONFIG_KEY_RESUME_MAP = sys.intern("__pregel_resume_map")
|
||||
# holds a mapping of task ns -> resume value for resuming tasks
|
||||
CONFIG_KEY_STREAM_MESSAGES_V2 = sys.intern("__pregel_stream_messages_v2")
|
||||
# when True, attach StreamMessagesHandlerV2 so content-block (v2) events
|
||||
# flow through stream_mode="messages"; set by StreamingHandler only.
|
||||
CONFIG_KEY_NODE_ERROR = sys.intern("__pregel_node_error")
|
||||
# holds a `NodeError` (failed source node + exception) for the current
|
||||
# node-level error handler invocation, injected when handler signature
|
||||
# requests `error: NodeError`
|
||||
|
||||
# --- Other constants ---
|
||||
PUSH = sys.intern("__pregel_push")
|
||||
@@ -93,6 +105,7 @@ RESERVED = {
|
||||
INTERRUPT,
|
||||
RESUME,
|
||||
ERROR,
|
||||
ERROR_SOURCE_NODE,
|
||||
NO_WRITES,
|
||||
# reserved config.configurable keys
|
||||
CONFIG_KEY_SEND,
|
||||
@@ -106,7 +119,9 @@ RESERVED = {
|
||||
CONFIG_KEY_CHECKPOINT_MAP,
|
||||
CONFIG_KEY_CHECKPOINT_ID,
|
||||
CONFIG_KEY_CHECKPOINT_NS,
|
||||
CONFIG_KEY_TIMED_ATTEMPT_OBSERVER,
|
||||
CONFIG_KEY_RESUME_MAP,
|
||||
CONFIG_KEY_STREAM_MESSAGES_V2,
|
||||
# other constants
|
||||
PUSH,
|
||||
PULL,
|
||||
|
||||
@@ -51,9 +51,11 @@ from langgraph._internal._config import (
|
||||
)
|
||||
from langgraph._internal._constants import (
|
||||
CONF,
|
||||
CONFIG_KEY_NODE_ERROR,
|
||||
CONFIG_KEY_RUNTIME,
|
||||
)
|
||||
from langgraph._internal._typing import MISSING
|
||||
from langgraph.errors import NodeError
|
||||
from langgraph.types import StreamWriter
|
||||
|
||||
try:
|
||||
@@ -117,6 +119,19 @@ def set_config_context(
|
||||
ctx.run(_unset_config_context, config_token, run)
|
||||
|
||||
|
||||
def create_task_in_config_context(
|
||||
coro_factory: Callable[[], Coroutine[Any, Any, Any]], config: RunnableConfig
|
||||
) -> asyncio.Task[Any]:
|
||||
"""Create an asyncio.Task that inherits `config` as the child runnable context.
|
||||
|
||||
`asyncio.create_task` snapshots the current contextvars onto the new task,
|
||||
so calling `create_task` while the config context is set ensures the task
|
||||
sees `config` via `var_child_runnable_config` and any tracing parent.
|
||||
"""
|
||||
with set_config_context(config) as context:
|
||||
return context.run(lambda: asyncio.create_task(coro_factory()))
|
||||
|
||||
|
||||
# Before Python 3.11 native StrEnum is not available
|
||||
class StrEnum(str, enum.Enum):
|
||||
"""A string enum."""
|
||||
@@ -181,6 +196,15 @@ KWARGS_CONFIG_KEYS: tuple[tuple[str, tuple[Any, ...], str, Any], ...] = (
|
||||
"N/A",
|
||||
inspect.Parameter.empty,
|
||||
),
|
||||
(
|
||||
"error",
|
||||
(NodeError, "NodeError"),
|
||||
# we never hit this block, we read directly from configurable
|
||||
"N/A",
|
||||
# default to None so non-handler nodes that happen to type a parameter
|
||||
# `error: NodeError` don't blow up; handlers always receive a NodeError.
|
||||
None,
|
||||
),
|
||||
)
|
||||
"""List of kwargs that can be passed to functions, and their corresponding
|
||||
config keys, default values and type annotations.
|
||||
@@ -354,6 +378,8 @@ class RunnableCallable(Runnable):
|
||||
kw_value: Any = MISSING
|
||||
if kw == "config":
|
||||
kw_value = config
|
||||
elif kw == "error":
|
||||
kw_value = config.get(CONF, {}).get(CONFIG_KEY_NODE_ERROR, MISSING)
|
||||
elif runtime:
|
||||
if kw == "runtime":
|
||||
kw_value = runtime
|
||||
@@ -426,6 +452,8 @@ class RunnableCallable(Runnable):
|
||||
kw_value: Any = MISSING
|
||||
if kw == "config":
|
||||
kw_value = config
|
||||
elif kw == "error":
|
||||
kw_value = config.get(CONF, {}).get(CONFIG_KEY_NODE_ERROR, MISSING)
|
||||
elif runtime:
|
||||
if kw == "runtime":
|
||||
kw_value = runtime
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Literal
|
||||
|
||||
from langgraph.types import TimeoutPolicy
|
||||
|
||||
_SYNC_TIMEOUT_PREFIX = (
|
||||
"Node timeouts are only supported for async nodes because sync Python "
|
||||
"execution cannot be safely cancelled in-process."
|
||||
)
|
||||
|
||||
|
||||
def coerce_timeout_policy(
|
||||
value: float | timedelta | TimeoutPolicy | None,
|
||||
) -> TimeoutPolicy | None:
|
||||
"""Normalize a timeout value to positive-second policy fields."""
|
||||
return TimeoutPolicy.coerce(value)
|
||||
|
||||
|
||||
def sync_timeout_unsupported(
|
||||
name: str, *, kind: Literal["Node", "Task"] = "Node"
|
||||
) -> ValueError:
|
||||
"""Build the canonical error for using `timeout` with a sync target."""
|
||||
return ValueError(f"{_SYNC_TIMEOUT_PREFIX} {kind} {name!r} is sync.")
|
||||
@@ -1,6 +1,7 @@
|
||||
from langgraph.channels.any_value import AnyValue
|
||||
from langgraph.channels.base import BaseChannel
|
||||
from langgraph.channels.binop import BinaryOperatorAggregate
|
||||
from langgraph.channels.delta import DeltaChannel
|
||||
from langgraph.channels.ephemeral_value import EphemeralValue
|
||||
from langgraph.channels.last_value import LastValue, LastValueAfterFinish
|
||||
from langgraph.channels.named_barrier_value import (
|
||||
@@ -20,6 +21,7 @@ __all__ = (
|
||||
"UntrackedValue",
|
||||
"EphemeralValue",
|
||||
"BinaryOperatorAggregate",
|
||||
"DeltaChannel",
|
||||
"NamedBarrierValue",
|
||||
"NamedBarrierValueAfterFinish",
|
||||
# topics
|
||||
|
||||
@@ -22,10 +22,9 @@ __all__ = ("BinaryOperatorAggregate",)
|
||||
def _strip_extras(t): # type: ignore[no-untyped-def]
|
||||
"""Strips Annotated, Required and NotRequired from a given type."""
|
||||
if hasattr(t, "__origin__"):
|
||||
if t.__origin__ in (Required, NotRequired):
|
||||
return _strip_extras(t.__args__[0])
|
||||
return _strip_extras(t.__origin__)
|
||||
if hasattr(t, "__origin__") and t.__origin__ in (Required, NotRequired):
|
||||
return _strip_extras(t.__args__[0])
|
||||
|
||||
return t
|
||||
|
||||
|
||||
@@ -33,11 +32,22 @@ def _get_overwrite(value: Any) -> tuple[bool, Any]:
|
||||
"""Inspects the given value and returns (is_overwrite, overwrite_value)."""
|
||||
if isinstance(value, Overwrite):
|
||||
return True, value.value
|
||||
if isinstance(value, dict) and set(value.keys()) == {OVERWRITE}:
|
||||
if isinstance(value, dict) and len(value) == 1 and OVERWRITE in value:
|
||||
return True, value[OVERWRITE]
|
||||
return False, None
|
||||
|
||||
|
||||
def _operators_equal(a: Callable, b: Callable) -> bool:
|
||||
"""Return True if two reducer operators should be considered equal.
|
||||
|
||||
Lambdas all share the name '<lambda>' so identity comparison is
|
||||
unreliable; treat any pairing that includes a lambda as equal.
|
||||
"""
|
||||
if a.__name__ == "<lambda>" or b.__name__ == "<lambda>":
|
||||
return True
|
||||
return a is b
|
||||
|
||||
|
||||
class BinaryOperatorAggregate(Generic[Value], BaseChannel[Value, Value, Value]):
|
||||
"""Stores the result of applying a binary operator to the current value and each new value.
|
||||
|
||||
@@ -68,11 +78,8 @@ class BinaryOperatorAggregate(Generic[Value], BaseChannel[Value, Value, Value]):
|
||||
self.value = MISSING
|
||||
|
||||
def __eq__(self, value: object) -> bool:
|
||||
return isinstance(value, BinaryOperatorAggregate) and (
|
||||
value.operator is self.operator
|
||||
if value.operator.__name__ != "<lambda>"
|
||||
and self.operator.__name__ != "<lambda>"
|
||||
else True
|
||||
return isinstance(value, BinaryOperatorAggregate) and _operators_equal(
|
||||
self.operator, value.operator
|
||||
)
|
||||
|
||||
@property
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import collections.abc
|
||||
import copy as _copy
|
||||
from collections.abc import Callable, Sequence
|
||||
from typing import Any, Generic
|
||||
|
||||
from langgraph.checkpoint.base import PendingWrite
|
||||
from langgraph.checkpoint.serde.types import _DeltaSnapshot
|
||||
from typing_extensions import Self
|
||||
|
||||
from langgraph._internal._typing import MISSING
|
||||
from langgraph.channels.base import BaseChannel, Value
|
||||
from langgraph.channels.binop import _get_overwrite, _operators_equal, _strip_extras
|
||||
from langgraph.errors import (
|
||||
EmptyChannelError,
|
||||
ErrorCode,
|
||||
InvalidUpdateError,
|
||||
create_error_message,
|
||||
)
|
||||
|
||||
__all__ = ("DeltaChannel",)
|
||||
|
||||
|
||||
class DeltaChannel(Generic[Value], BaseChannel[Any, Any, Any]):
|
||||
"""Reducer channel that stores only a sentinel in checkpoint blobs and
|
||||
reconstructs state by replaying ancestor writes through the reducer.
|
||||
|
||||
The reducer receives the current accumulated value and a batch of writes
|
||||
in one call: `reducer(state, [write1, write2, ...]) -> new_state`.
|
||||
|
||||
Reducers must be deterministic and batching-invariant (associative across
|
||||
folds): applying two consecutive write batches separately must produce the
|
||||
same state as applying their concatenation once:
|
||||
|
||||
reducer(reducer(state, xs), ys) == reducer(state, xs + ys)
|
||||
|
||||
This lets LangGraph replay checkpointed writes in larger batches than they
|
||||
were originally produced without changing reconstructed state.
|
||||
|
||||
Snapshot cadence is driven by per-channel update count. `create_checkpoint`
|
||||
writes a full `_DeltaSnapshot` blob every `snapshot_frequency` updates to
|
||||
this channel, bounding replay depth.
|
||||
|
||||
Parameters:
|
||||
reducer: `(state, list[writes]) -> new_state`. Must be deterministic
|
||||
and batching-invariant as described above.
|
||||
typ: The value type (e.g. `list`, `dict`). Inferred automatically
|
||||
from the outer type when used inside `Annotated[T, DeltaChannel(...)]`.
|
||||
snapshot_frequency: Every Nth update to this channel writes a snapshot
|
||||
blob (default `1000`). Must be a positive int.
|
||||
"""
|
||||
|
||||
__slots__ = ("value", "reducer", "snapshot_frequency")
|
||||
value: Value | Any
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
reducer: Callable[[Any, Sequence[Any]], Any],
|
||||
typ: type[Value] | None = None,
|
||||
*,
|
||||
snapshot_frequency: int = 1000,
|
||||
) -> None:
|
||||
if snapshot_frequency <= 0:
|
||||
raise ValueError(
|
||||
f"snapshot_frequency must be a positive int, got {snapshot_frequency}"
|
||||
)
|
||||
if typ is None:
|
||||
typ = list # type: ignore[assignment] # placeholder; overridden by _is_field_channel
|
||||
super().__init__(typ)
|
||||
self.reducer = reducer
|
||||
self.snapshot_frequency = snapshot_frequency
|
||||
typ = _strip_extras(typ)
|
||||
if typ in (collections.abc.Sequence, collections.abc.MutableSequence):
|
||||
typ = list
|
||||
if typ in (collections.abc.Set, collections.abc.MutableSet):
|
||||
typ = set
|
||||
if typ in (collections.abc.Mapping, collections.abc.MutableMapping):
|
||||
typ = dict
|
||||
self.typ = typ
|
||||
self.value: Any = MISSING
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
if not isinstance(other, DeltaChannel):
|
||||
return False
|
||||
if self.snapshot_frequency != other.snapshot_frequency:
|
||||
return False
|
||||
return _operators_equal(self.reducer, other.reducer)
|
||||
|
||||
@property
|
||||
def ValueType(self) -> Any:
|
||||
return self.typ
|
||||
|
||||
@property
|
||||
def UpdateType(self) -> Any:
|
||||
return self.typ
|
||||
|
||||
def copy(self) -> Self:
|
||||
new = self.__class__(
|
||||
self.reducer, self.typ, snapshot_frequency=self.snapshot_frequency
|
||||
)
|
||||
new.key = self.key
|
||||
new.value = self.value if self.value is MISSING else _copy.copy(self.value)
|
||||
return new
|
||||
|
||||
def from_checkpoint(self, checkpoint: Any) -> Self:
|
||||
"""Initialize from a stored blob.
|
||||
|
||||
Blob types:
|
||||
* `MISSING`: start empty; caller replays writes.
|
||||
* `_DeltaSnapshot(value)`: restore value directly from snapshot.
|
||||
* plain value (migration from old `BinaryOperatorAggregate` blobs):
|
||||
use directly.
|
||||
"""
|
||||
new = self.__class__(
|
||||
self.reducer, self.typ, snapshot_frequency=self.snapshot_frequency
|
||||
)
|
||||
new.key = self.key
|
||||
if checkpoint is MISSING:
|
||||
new.value = self.typ()
|
||||
elif isinstance(checkpoint, _DeltaSnapshot):
|
||||
new.value = checkpoint.value
|
||||
else:
|
||||
new.value = checkpoint
|
||||
return new
|
||||
|
||||
def replay_writes(self, writes: Sequence[PendingWrite]) -> None:
|
||||
"""Apply ancestor writes oldest-to-newest via a single reducer call.
|
||||
|
||||
If any write is an Overwrite, the last one in the sequence acts as
|
||||
the reset point: its value becomes the new base and only writes
|
||||
after it are passed to the reducer.
|
||||
"""
|
||||
values = [v for _, _, v in writes]
|
||||
if not values:
|
||||
return
|
||||
base = self.value
|
||||
start = 0
|
||||
for i, v in enumerate(values):
|
||||
is_ow, ow_value = _get_overwrite(v)
|
||||
if is_ow:
|
||||
base = _copy.copy(ow_value) if ow_value is not None else self.typ()
|
||||
start = i + 1
|
||||
remaining = values[start:]
|
||||
self.value = self.reducer(base, remaining) if remaining else base
|
||||
|
||||
def update(self, values: Sequence[Any]) -> bool:
|
||||
if not values:
|
||||
return False
|
||||
overwrite_idx: int | None = None
|
||||
for i, v in enumerate(values):
|
||||
is_ow, _ = _get_overwrite(v)
|
||||
if is_ow:
|
||||
if overwrite_idx is not None:
|
||||
msg = create_error_message(
|
||||
message="Can receive only one Overwrite value per super-step.",
|
||||
error_code=ErrorCode.INVALID_CONCURRENT_GRAPH_UPDATE,
|
||||
)
|
||||
raise InvalidUpdateError(msg)
|
||||
overwrite_idx = i
|
||||
if overwrite_idx is not None:
|
||||
_, overwrite_value = _get_overwrite(values[overwrite_idx])
|
||||
base = (
|
||||
_copy.copy(overwrite_value)
|
||||
if overwrite_value is not None
|
||||
else self.typ()
|
||||
)
|
||||
remaining = [v for i, v in enumerate(values) if i != overwrite_idx]
|
||||
self.value = self.reducer(base, remaining) if remaining else base
|
||||
return True
|
||||
base = self.typ() if self.value is MISSING else self.value
|
||||
self.value = self.reducer(base, list(values))
|
||||
return True
|
||||
|
||||
def get(self) -> Any:
|
||||
if self.value is MISSING:
|
||||
raise EmptyChannelError()
|
||||
return self.value
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return self.value is not MISSING
|
||||
|
||||
def checkpoint(self) -> Any:
|
||||
"""Return stored representation: always `MISSING`.
|
||||
|
||||
Snapshot decisions live in `create_checkpoint` (which has the channel
|
||||
version) and write `_DeltaSnapshot(ch.get())` directly into
|
||||
`channel_values`. For non-snapshot steps the channel does not appear
|
||||
in `channel_values`; reconstruction walks ancestor writes via the
|
||||
saver's `get_delta_channel_history`.
|
||||
"""
|
||||
return MISSING
|
||||
@@ -1,8 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
from warnings import warn
|
||||
|
||||
# EmptyChannelError is re-exported from langgraph.channels.base
|
||||
@@ -15,11 +16,14 @@ from langgraph.warnings import LangGraphDeprecatedSinceV10
|
||||
__all__ = (
|
||||
"EmptyChannelError",
|
||||
"ErrorCode",
|
||||
"GraphDrained",
|
||||
"GraphRecursionError",
|
||||
"InvalidUpdateError",
|
||||
"GraphBubbleUp",
|
||||
"GraphInterrupt",
|
||||
"NodeError",
|
||||
"NodeInterrupt",
|
||||
"NodeTimeoutError",
|
||||
"ParentCommand",
|
||||
"EmptyInputError",
|
||||
"TaskNotFound",
|
||||
@@ -42,6 +46,23 @@ def create_error_message(*, message: str, error_code: ErrorCode) -> str:
|
||||
)
|
||||
|
||||
|
||||
class GraphBubbleUp(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class GraphDrained(GraphBubbleUp):
|
||||
"""Raised when a graph run exits early due to a drain request.
|
||||
|
||||
This indicates the graph stopped cooperatively at a superstep boundary
|
||||
because `RunControl.request_drain()` was called (e.g., in response to
|
||||
SIGTERM). The checkpoint is saved and the run can be resumed later.
|
||||
"""
|
||||
|
||||
def __init__(self, reason: str = "shutdown") -> None:
|
||||
self.reason = reason
|
||||
super().__init__(f"Graph drained: {reason}")
|
||||
|
||||
|
||||
class GraphRecursionError(RecursionError):
|
||||
"""Raised when the graph has exhausted the maximum number of steps.
|
||||
|
||||
@@ -77,10 +98,6 @@ class InvalidUpdateError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class GraphBubbleUp(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class GraphInterrupt(GraphBubbleUp):
|
||||
"""Raised when a subgraph is interrupted, suppressed by the root graph.
|
||||
Never raised directly, or surfaced to the user."""
|
||||
@@ -125,3 +142,77 @@ class TaskNotFound(Exception):
|
||||
"""Raised when the executor is unable to find a task (for distributed mode)."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class NodeError:
|
||||
"""Failure context passed to a node-level error handler.
|
||||
|
||||
Inject by adding a parameter typed `NodeError` to a handler registered via
|
||||
`StateGraph.add_node(..., error_handler=...)`:
|
||||
|
||||
```python
|
||||
def handler(state: State, error: NodeError) -> Command:
|
||||
return Command(update={"status": f"recovered from {error.node}: {error.error}"})
|
||||
```
|
||||
"""
|
||||
|
||||
node: str
|
||||
"""Name of the node whose execution failed."""
|
||||
|
||||
error: BaseException
|
||||
"""Exception raised by the failed node."""
|
||||
|
||||
|
||||
class NodeTimeoutError(Exception):
|
||||
"""Raised when a node invocation exceeds one of its configured timeouts.
|
||||
|
||||
Does **not** inherit from the built-in `TimeoutError` (a subclass of
|
||||
`OSError`) so that the default `RetryPolicy` treats it as retryable.
|
||||
|
||||
Both `idle_timeout` and `run_timeout` reflect the configured policy at the
|
||||
time of the failure (each is `None` if not configured). `kind` and
|
||||
`timeout` identify which one fired.
|
||||
"""
|
||||
|
||||
node: str
|
||||
timeout: float
|
||||
run_timeout: float | None
|
||||
idle_timeout: float | None
|
||||
elapsed: float
|
||||
kind: Literal["idle", "run"]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
node: str,
|
||||
elapsed: float,
|
||||
*,
|
||||
kind: Literal["idle", "run"],
|
||||
idle_timeout: float | None = None,
|
||||
run_timeout: float | None = None,
|
||||
) -> None:
|
||||
if kind == "idle":
|
||||
if idle_timeout is None:
|
||||
raise ValueError("idle_timeout is required when kind='idle'")
|
||||
message = (
|
||||
f"Node '{node}' exceeded its idle timeout of "
|
||||
f"{idle_timeout:.3f}s without making progress "
|
||||
f"(elapsed: {elapsed:.3f}s)."
|
||||
)
|
||||
self.timeout = idle_timeout
|
||||
elif kind == "run":
|
||||
if run_timeout is None:
|
||||
raise ValueError("run_timeout is required when kind='run'")
|
||||
message = (
|
||||
f"Node '{node}' exceeded its run timeout of "
|
||||
f"{run_timeout:.3f}s (elapsed: {elapsed:.3f}s)."
|
||||
)
|
||||
self.timeout = run_timeout
|
||||
else:
|
||||
raise ValueError("kind must be 'idle' or 'run'")
|
||||
super().__init__(message)
|
||||
self.node = node
|
||||
self.elapsed = elapsed
|
||||
self.kind = kind
|
||||
self.idle_timeout = idle_timeout
|
||||
self.run_timeout = run_timeout
|
||||
|
||||
@@ -5,6 +5,7 @@ import inspect
|
||||
import warnings
|
||||
from collections.abc import Awaitable, Callable, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from typing import (
|
||||
Any,
|
||||
Generic,
|
||||
@@ -22,6 +23,11 @@ from typing_extensions import Unpack
|
||||
|
||||
from langgraph._internal import _serde
|
||||
from langgraph._internal._constants import CACHE_NS_WRITES, PREVIOUS
|
||||
from langgraph._internal._runnable import is_async_callable
|
||||
from langgraph._internal._timeout import (
|
||||
coerce_timeout_policy,
|
||||
sync_timeout_unsupported,
|
||||
)
|
||||
from langgraph._internal._typing import MISSING, DeprecatedKwargs
|
||||
from langgraph.channels.ephemeral_value import EphemeralValue
|
||||
from langgraph.channels.last_value import LastValue
|
||||
@@ -31,13 +37,19 @@ from langgraph.pregel._call import (
|
||||
P,
|
||||
SyncAsyncFuture,
|
||||
T,
|
||||
call,
|
||||
_call_with_options,
|
||||
get_runnable_for_entrypoint,
|
||||
identifier,
|
||||
)
|
||||
from langgraph.pregel._read import PregelNode
|
||||
from langgraph.pregel._write import ChannelWrite, ChannelWriteEntry
|
||||
from langgraph.types import _DC_KWARGS, CachePolicy, RetryPolicy, StreamMode
|
||||
from langgraph.types import (
|
||||
_DC_KWARGS,
|
||||
CachePolicy,
|
||||
RetryPolicy,
|
||||
StreamMode,
|
||||
TimeoutPolicy,
|
||||
)
|
||||
from langgraph.typing import ContextT
|
||||
from langgraph.warnings import LangGraphDeprecatedSinceV05, LangGraphDeprecatedSinceV10
|
||||
|
||||
@@ -51,6 +63,7 @@ class _TaskFunction(Generic[P, T]):
|
||||
*,
|
||||
retry_policy: Sequence[RetryPolicy],
|
||||
cache_policy: CachePolicy[Callable[P, str | bytes]] | None = None,
|
||||
timeout: TimeoutPolicy | None = None,
|
||||
name: str | None = None,
|
||||
) -> None:
|
||||
if name is not None:
|
||||
@@ -67,15 +80,17 @@ class _TaskFunction(Generic[P, T]):
|
||||
self.func = func
|
||||
self.retry_policy = retry_policy
|
||||
self.cache_policy = cache_policy
|
||||
self.timeout = timeout
|
||||
functools.update_wrapper(self, func)
|
||||
|
||||
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> SyncAsyncFuture[T]:
|
||||
return call(
|
||||
return _call_with_options(
|
||||
self.func,
|
||||
args,
|
||||
kwargs,
|
||||
retry_policy=self.retry_policy,
|
||||
cache_policy=self.cache_policy,
|
||||
*args,
|
||||
**kwargs,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
|
||||
def clear_cache(self, cache: BaseCache) -> None:
|
||||
@@ -98,6 +113,7 @@ def task(
|
||||
name: str | None = None,
|
||||
retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None,
|
||||
cache_policy: CachePolicy[Callable[P, str | bytes]] | None = None,
|
||||
timeout: float | timedelta | TimeoutPolicy | None = None,
|
||||
**kwargs: Unpack[DeprecatedKwargs],
|
||||
) -> Callable[
|
||||
[Callable[P, Awaitable[T]] | Callable[P, T]],
|
||||
@@ -119,6 +135,7 @@ def task(
|
||||
name: str | None = None,
|
||||
retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None,
|
||||
cache_policy: CachePolicy[Callable[P, str | bytes]] | None = None,
|
||||
timeout: float | timedelta | TimeoutPolicy | None = None,
|
||||
**kwargs: Unpack[DeprecatedKwargs],
|
||||
) -> (
|
||||
Callable[[Callable[P, Awaitable[T]] | Callable[P, T]], _TaskFunction[P, T]]
|
||||
@@ -142,6 +159,14 @@ def task(
|
||||
name: An optional name for the task. If not provided, the function name will be used.
|
||||
retry_policy: An optional retry policy (or list of policies) to use for the task in case of a failure.
|
||||
cache_policy: An optional cache policy to use for the task. This allows caching of the task results.
|
||||
timeout: Timeout for each task attempt. A number or `timedelta` is a hard
|
||||
wall-clock cap and is not refreshed. Use `TimeoutPolicy` to configure
|
||||
both a wall-clock `run_timeout` and an `idle_timeout` refreshed by
|
||||
progress signals. For long-running work that doesn't naturally emit
|
||||
progress, call `runtime.heartbeat()` from inside the task. When the
|
||||
timeout fires, `NodeTimeoutError` is raised and the retry policy (if
|
||||
any) decides whether to retry. Supported only for async tasks; sync
|
||||
tasks cannot be safely cancelled in-process.
|
||||
|
||||
Returns:
|
||||
A callable function when used as a decorator.
|
||||
@@ -196,6 +221,7 @@ def task(
|
||||
)
|
||||
if retry_policy is None:
|
||||
retry_policy = retry # type: ignore[assignment]
|
||||
timeout_policy = coerce_timeout_policy(timeout)
|
||||
|
||||
retry_policies: Sequence[RetryPolicy] = (
|
||||
()
|
||||
@@ -208,8 +234,15 @@ def task(
|
||||
def decorator(
|
||||
func: Callable[P, Awaitable[T]] | Callable[P, T],
|
||||
) -> Callable[P, SyncAsyncFuture[T]]:
|
||||
if timeout_policy is not None and not is_async_callable(func):
|
||||
name_ = name or getattr(func, "__name__", func.__class__.__name__)
|
||||
raise sync_timeout_unsupported(str(name_), kind="Task")
|
||||
return _TaskFunction(
|
||||
func, retry_policy=retry_policies, cache_policy=cache_policy, name=name
|
||||
func,
|
||||
retry_policy=retry_policies,
|
||||
cache_policy=cache_policy,
|
||||
timeout=timeout_policy,
|
||||
name=name,
|
||||
)
|
||||
|
||||
if __func_or_none__ is not None:
|
||||
@@ -268,6 +301,15 @@ class entrypoint(Generic[ContextT]):
|
||||
passed to the workflow.
|
||||
cache_policy: A cache policy to use for caching the results of the workflow.
|
||||
retry_policy: A retry policy (or list of policies) to use for the workflow in case of a failure.
|
||||
timeout: Timeout for each workflow attempt. A number or `timedelta` is a
|
||||
hard wall-clock cap and is not refreshed. Use `TimeoutPolicy` to
|
||||
configure both a wall-clock `run_timeout` and an `idle_timeout`
|
||||
refreshed by progress signals. For long-running work that doesn't
|
||||
naturally emit progress, call `runtime.heartbeat()` from inside the
|
||||
workflow. When the timeout fires, `NodeTimeoutError` is raised and
|
||||
the retry policy (if any) decides whether to retry. Supported only
|
||||
for async workflows; sync workflows cannot be safely cancelled
|
||||
in-process.
|
||||
|
||||
!!! warning "`config_schema` Deprecated"
|
||||
The `config_schema` parameter is deprecated in v0.6.0 and support will be removed in v2.0.0.
|
||||
@@ -400,6 +442,7 @@ class entrypoint(Generic[ContextT]):
|
||||
context_schema: type[ContextT] | None = None,
|
||||
cache_policy: CachePolicy | None = None,
|
||||
retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None,
|
||||
timeout: float | timedelta | TimeoutPolicy | None = None,
|
||||
**kwargs: Unpack[DeprecatedKwargs],
|
||||
) -> None:
|
||||
"""Initialize the entrypoint decorator."""
|
||||
@@ -426,6 +469,7 @@ class entrypoint(Generic[ContextT]):
|
||||
self.cache = cache
|
||||
self.cache_policy = cache_policy
|
||||
self.retry_policy = retry_policy
|
||||
self.timeout = coerce_timeout_policy(timeout)
|
||||
self.context_schema = context_schema
|
||||
|
||||
@dataclass(**_DC_KWARGS)
|
||||
@@ -535,6 +579,7 @@ class entrypoint(Generic[ContextT]):
|
||||
bound=bound,
|
||||
triggers=[START],
|
||||
channels=START,
|
||||
timeout=self.timeout,
|
||||
writers=[
|
||||
ChannelWrite(
|
||||
[
|
||||
|
||||
@@ -9,7 +9,7 @@ from langgraph.store.base import BaseStore
|
||||
|
||||
from langgraph._internal._typing import EMPTY_SEQ
|
||||
from langgraph.runtime import Runtime
|
||||
from langgraph.types import CachePolicy, RetryPolicy, StreamWriter
|
||||
from langgraph.types import CachePolicy, RetryPolicy, StreamWriter, TimeoutPolicy
|
||||
from langgraph.typing import ContextT, NodeInputT, NodeInputT_contra
|
||||
|
||||
|
||||
@@ -88,5 +88,8 @@ class StateNodeSpec(Generic[NodeInputT, ContextT]):
|
||||
input_schema: type[NodeInputT]
|
||||
retry_policy: RetryPolicy | Sequence[RetryPolicy] | None
|
||||
cache_policy: CachePolicy | None
|
||||
is_error_handler: bool = False
|
||||
error_handler_node: str | None = None
|
||||
ends: tuple[str, ...] | dict[str, str] | None = EMPTY_SEQ
|
||||
defer: bool = False
|
||||
timeout: TimeoutPolicy | None = None
|
||||
|
||||
@@ -244,6 +244,71 @@ def add_messages(
|
||||
return merged
|
||||
|
||||
|
||||
def _messages_delta_reducer(
|
||||
state: list[AnyMessage], writes: list[list[AnyMessage]]
|
||||
) -> list[AnyMessage]:
|
||||
"""**Experimental.** Batch reducer for use with `DeltaChannel`.
|
||||
|
||||
Processes all writes in one pass — dedup by ID, `RemoveMessage`
|
||||
tombstoning — without calling `add_messages`.
|
||||
|
||||
This reducer is batching-invariant, as required by `DeltaChannel`:
|
||||
`reducer(reducer(state, xs), ys) == reducer(state, xs + ys)`.
|
||||
|
||||
Raw dict / string / tuple inputs are coerced to typed `BaseMessage`
|
||||
objects so that HTTP-driven graphs work without a separate coercion
|
||||
step. This is not full `add_messages` parity — `REMOVE_ALL_MESSAGES`,
|
||||
unknown-id `RemoveMessage` errors, missing-id UUID assignment, and
|
||||
`BaseMessageChunk` conversion are not handled here.
|
||||
|
||||
Example::
|
||||
|
||||
from typing import Annotated
|
||||
from langgraph.channels.delta import DeltaChannel
|
||||
from langgraph.graph.message import _messages_delta_reducer
|
||||
|
||||
class State(TypedDict):
|
||||
messages: Annotated[list, DeltaChannel(_messages_delta_reducer)]
|
||||
"""
|
||||
|
||||
# Each write is either a list of message-likes or a single message-like
|
||||
# (BaseMessage / dict / str / tuple). Only lists flatten; everything
|
||||
# else is one message.
|
||||
flat: list[Any] = []
|
||||
for w in writes:
|
||||
if isinstance(w, list):
|
||||
flat.extend(w)
|
||||
else:
|
||||
flat.append(w)
|
||||
# Steady state: the reducer's own output is already typed, so skip
|
||||
# `convert_to_messages` on state when the first element is a BaseMessage.
|
||||
# Only raw input (initial dicts, deserialized blobs) hits the slow path.
|
||||
if state and isinstance(state[0], BaseMessage):
|
||||
state_msgs = state
|
||||
else:
|
||||
state_msgs = cast("list[AnyMessage]", convert_to_messages(state))
|
||||
msgs = cast("list[AnyMessage]", convert_to_messages(flat))
|
||||
|
||||
index: dict[str, int] = {
|
||||
m.id: i for i, m in enumerate(state_msgs) if m.id is not None
|
||||
}
|
||||
result: list[AnyMessage | None] = list(state_msgs)
|
||||
for msg in msgs:
|
||||
mid = msg.id
|
||||
if mid is None:
|
||||
result.append(msg)
|
||||
elif isinstance(msg, RemoveMessage):
|
||||
if mid in index:
|
||||
result[index[mid]] = None
|
||||
del index[mid]
|
||||
elif mid in index:
|
||||
result[index[mid]] = msg
|
||||
else:
|
||||
index[mid] = len(result)
|
||||
result.append(msg)
|
||||
return [m for m in result if m is not None]
|
||||
|
||||
|
||||
@deprecated(
|
||||
"MessageGraph is deprecated in langgraph 1.0.0, to be removed in 2.0.0. Please use StateGraph with a `messages` key instead.",
|
||||
category=None,
|
||||
|
||||
@@ -7,6 +7,7 @@ import warnings
|
||||
from collections import defaultdict
|
||||
from collections.abc import Awaitable, Callable, Hashable, Sequence
|
||||
from dataclasses import is_dataclass
|
||||
from datetime import timedelta
|
||||
from functools import partial
|
||||
from inspect import isclass, isfunction, ismethod, signature
|
||||
from types import FunctionType
|
||||
@@ -45,9 +46,11 @@ from langgraph._internal._fields import (
|
||||
)
|
||||
from langgraph._internal._pydantic import create_model
|
||||
from langgraph._internal._runnable import coerce_to_runnable
|
||||
from langgraph._internal._timeout import coerce_timeout_policy
|
||||
from langgraph._internal._typing import EMPTY_SEQ, MISSING, DeprecatedKwargs
|
||||
from langgraph.channels.base import BaseChannel
|
||||
from langgraph.channels.binop import BinaryOperatorAggregate
|
||||
from langgraph.channels.delta import DeltaChannel
|
||||
from langgraph.channels.ephemeral_value import EphemeralValue
|
||||
from langgraph.channels.last_value import LastValue, LastValueAfterFinish
|
||||
from langgraph.channels.named_barrier_value import (
|
||||
@@ -81,6 +84,7 @@ from langgraph.types import (
|
||||
Command,
|
||||
RetryPolicy,
|
||||
Send,
|
||||
TimeoutPolicy,
|
||||
ensure_valid_checkpointer,
|
||||
)
|
||||
from langgraph.typing import ContextT, InputT, NodeInputT, OutputT, StateT
|
||||
@@ -299,7 +303,9 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
|
||||
input_schema: None = None,
|
||||
retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None,
|
||||
cache_policy: CachePolicy | None = None,
|
||||
error_handler: StateNode[Any, ContextT] | None = None,
|
||||
destinations: dict[str, str] | tuple[str, ...] | None = None,
|
||||
timeout: float | timedelta | TimeoutPolicy | None = None,
|
||||
**kwargs: Unpack[DeprecatedKwargs],
|
||||
) -> Self:
|
||||
"""Add a new node to the `StateGraph`, input schema is inferred as the state schema.
|
||||
@@ -366,7 +372,9 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
|
||||
input_schema: type[NodeInputT],
|
||||
retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None,
|
||||
cache_policy: CachePolicy | None = None,
|
||||
error_handler: StateNode[Any, ContextT] | None = None,
|
||||
destinations: dict[str, str] | tuple[str, ...] | None = None,
|
||||
timeout: float | timedelta | TimeoutPolicy | None = None,
|
||||
**kwargs: Unpack[DeprecatedKwargs],
|
||||
) -> Self:
|
||||
"""Add a new node to the `StateGraph` where input schema is specified.
|
||||
@@ -438,7 +446,9 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
|
||||
input_schema: None = None,
|
||||
retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None,
|
||||
cache_policy: CachePolicy | None = None,
|
||||
error_handler: StateNode[Any, ContextT] | None = None,
|
||||
destinations: dict[str, str] | tuple[str, ...] | None = None,
|
||||
timeout: float | timedelta | TimeoutPolicy | None = None,
|
||||
**kwargs: Unpack[DeprecatedKwargs],
|
||||
) -> Self:
|
||||
"""Add a new node to the `StateGraph`, input schema is inferred as the state schema.
|
||||
@@ -505,7 +515,9 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
|
||||
input_schema: type[NodeInputT],
|
||||
retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None,
|
||||
cache_policy: CachePolicy | None = None,
|
||||
error_handler: StateNode[Any, ContextT] | None = None,
|
||||
destinations: dict[str, str] | tuple[str, ...] | None = None,
|
||||
timeout: float | timedelta | TimeoutPolicy | None = None,
|
||||
**kwargs: Unpack[DeprecatedKwargs],
|
||||
) -> Self:
|
||||
"""Add a new node to the `StateGraph`, input schema is specified.
|
||||
@@ -579,7 +591,9 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
|
||||
input_schema: type[NodeInputT] | None = None,
|
||||
retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None,
|
||||
cache_policy: CachePolicy | None = None,
|
||||
error_handler: StateNode[Any, ContextT] | None = None,
|
||||
destinations: dict[str, str] | tuple[str, ...] | None = None,
|
||||
timeout: float | timedelta | TimeoutPolicy | None = None,
|
||||
**kwargs: Unpack[DeprecatedKwargs],
|
||||
) -> Self:
|
||||
"""Add a new node to the `StateGraph`.
|
||||
@@ -598,6 +612,7 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
|
||||
|
||||
If a sequence is provided, the first matching policy will be applied.
|
||||
cache_policy: The cache policy for the node.
|
||||
error_handler: Optional node-level error handler callable for this node.
|
||||
destinations: Destinations that indicate where a node can route to.
|
||||
|
||||
Useful for edgeless graphs with nodes that return `Command` objects.
|
||||
@@ -609,6 +624,14 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
|
||||
!!! warning
|
||||
|
||||
This is only used for graph rendering and doesn't have any effect on the graph execution.
|
||||
timeout: Timeout for each node attempt. A number or `timedelta` is
|
||||
a hard wall-clock cap and is not refreshed. Use `TimeoutPolicy`
|
||||
to configure both a wall-clock `run_timeout` and an
|
||||
`idle_timeout` refreshed by progress signals. When exceeded, a
|
||||
[`NodeTimeoutError`][langgraph.errors.NodeTimeoutError] is raised
|
||||
and the retry policy (if any) decides whether to retry. Timeouts
|
||||
are supported only for async nodes; sync nodes cannot be safely
|
||||
cancelled in-process.
|
||||
|
||||
Example:
|
||||
```python
|
||||
@@ -662,6 +685,7 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
|
||||
)
|
||||
if input_schema is None:
|
||||
input_schema = cast(type[NodeInputT] | None, input_)
|
||||
timeout = coerce_timeout_policy(timeout)
|
||||
|
||||
if not isinstance(node, str):
|
||||
action = node
|
||||
@@ -748,6 +772,25 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
|
||||
if destinations is not None:
|
||||
ends = destinations
|
||||
|
||||
resolved_input_schema: type[Any] = (
|
||||
input_schema or inferred_input_schema or self.state_schema
|
||||
)
|
||||
handler_node_name: str | None = None
|
||||
if error_handler is not None:
|
||||
handler_node_name = f"__error_handler__{node}"
|
||||
if handler_node_name in self.nodes:
|
||||
raise ValueError(
|
||||
f"Auto-generated error handler node `{handler_node_name}` already exists."
|
||||
)
|
||||
self.nodes[handler_node_name] = StateNodeSpec[Any, ContextT](
|
||||
coerce_to_runnable(error_handler, name=handler_node_name, trace=False), # type: ignore[arg-type]
|
||||
metadata=None,
|
||||
input_schema=resolved_input_schema,
|
||||
retry_policy=None,
|
||||
cache_policy=None,
|
||||
is_error_handler=True,
|
||||
)
|
||||
|
||||
if input_schema is not None:
|
||||
self.nodes[node] = StateNodeSpec[NodeInputT, ContextT](
|
||||
coerce_to_runnable(action, name=node, trace=False), # type: ignore[arg-type]
|
||||
@@ -755,8 +798,10 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
|
||||
input_schema=input_schema,
|
||||
retry_policy=retry_policy,
|
||||
cache_policy=cache_policy,
|
||||
error_handler_node=handler_node_name,
|
||||
ends=ends,
|
||||
defer=defer,
|
||||
timeout=timeout,
|
||||
)
|
||||
elif inferred_input_schema is not None:
|
||||
self.nodes[node] = StateNodeSpec(
|
||||
@@ -765,8 +810,10 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
|
||||
input_schema=inferred_input_schema,
|
||||
retry_policy=retry_policy,
|
||||
cache_policy=cache_policy,
|
||||
error_handler_node=handler_node_name,
|
||||
ends=ends,
|
||||
defer=defer,
|
||||
timeout=timeout,
|
||||
)
|
||||
else:
|
||||
self.nodes[node] = StateNodeSpec[StateT, ContextT](
|
||||
@@ -775,8 +822,10 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
|
||||
input_schema=self.state_schema,
|
||||
retry_policy=retry_policy,
|
||||
cache_policy=cache_policy,
|
||||
error_handler_node=handler_node_name,
|
||||
ends=ends,
|
||||
defer=defer,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
input_schema = input_schema or inferred_input_schema
|
||||
@@ -1031,7 +1080,6 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
|
||||
for node in interrupt:
|
||||
if node not in self.nodes:
|
||||
raise ValueError(f"Interrupt node `{node}` not found")
|
||||
|
||||
self.compiled = True
|
||||
return self
|
||||
|
||||
@@ -1045,6 +1093,7 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
|
||||
interrupt_after: All | list[str] | None = None,
|
||||
debug: bool = False,
|
||||
name: str | None = None,
|
||||
transformers: Sequence[Callable[[tuple[str, ...]], Any]] | None = None,
|
||||
) -> CompiledStateGraph[StateT, ContextT, InputT, OutputT]:
|
||||
"""Compiles the `StateGraph` into a `CompiledStateGraph` object.
|
||||
|
||||
@@ -1077,11 +1126,19 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
|
||||
interrupt_after: An optional list of node names to interrupt after.
|
||||
debug: A flag indicating whether to enable debug mode.
|
||||
name: The name to use for the compiled graph.
|
||||
transformers: Optional sequence of `StreamTransformer` classes or
|
||||
configured factories. Classes and factories are instantiated
|
||||
per run whenever `stream_events(version="v3")` / `astream_events(version="v3")` is called and are
|
||||
propagated to subgraph scopes. Custom factories should follow
|
||||
the standard `StreamTransformer` constructor shape by
|
||||
accepting `scope` as their first argument. Appended after the
|
||||
built-in stream transformers.
|
||||
|
||||
Returns:
|
||||
CompiledStateGraph: The compiled `StateGraph`.
|
||||
"""
|
||||
checkpointer = ensure_valid_checkpointer(checkpointer)
|
||||
|
||||
serde_allowlist: set[tuple[str, ...]] | None = None
|
||||
if _serde.STRICT_MSGPACK_ENABLED:
|
||||
schema_types: list[type[Any]] = [
|
||||
@@ -1136,6 +1193,11 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
|
||||
key for key, val in self.channels.items() if not is_managed_value(val)
|
||||
]
|
||||
)
|
||||
node_error_handler_map = {
|
||||
node_name: spec.error_handler_node
|
||||
for node_name, spec in self.nodes.items()
|
||||
if spec.error_handler_node is not None
|
||||
}
|
||||
|
||||
compiled = CompiledStateGraph[StateT, ContextT, InputT, OutputT](
|
||||
builder=self,
|
||||
@@ -1158,7 +1220,9 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
|
||||
debug=debug,
|
||||
store=store,
|
||||
cache=cache,
|
||||
node_error_handler_map=node_error_handler_map,
|
||||
name=name or "LangGraph",
|
||||
stream_transformers=transformers,
|
||||
)
|
||||
compiled._serde_allowlist = serde_allowlist
|
||||
|
||||
@@ -1331,7 +1395,10 @@ class CompiledStateGraph(
|
||||
metadata=node.metadata,
|
||||
retry_policy=node.retry_policy,
|
||||
cache_policy=node.cache_policy,
|
||||
is_error_handler=node.is_error_handler,
|
||||
error_handler_node=node.error_handler_node,
|
||||
bound=node.runnable, # type: ignore[arg-type]
|
||||
timeout=node.timeout,
|
||||
)
|
||||
else:
|
||||
raise RuntimeError
|
||||
@@ -1667,6 +1734,20 @@ def _is_field_channel(typ: type[Any]) -> BaseChannel | None:
|
||||
# Search through all annotated medata to find channel annotations
|
||||
for item in meta:
|
||||
if isinstance(item, BaseChannel):
|
||||
if isinstance(item, DeltaChannel) and hasattr(typ, "__origin__"):
|
||||
origin = typ.__origin__
|
||||
# Unwrap parameterized Required[X]/NotRequired[X] to X
|
||||
# (e.g. Annotated[NotRequired[dict[...]], ...]).
|
||||
if hasattr(origin, "__origin__") and origin.__origin__ in (
|
||||
Required,
|
||||
NotRequired,
|
||||
):
|
||||
origin = origin.__args__[0]
|
||||
item = item.__class__(
|
||||
item.reducer,
|
||||
origin,
|
||||
snapshot_frequency=item.snapshot_frequency,
|
||||
)
|
||||
return item
|
||||
elif isclass(item) and issubclass(item, BaseChannel):
|
||||
# ex, Annotated[int, EphemeralValue, SomeOtherAnnotation]
|
||||
|
||||
@@ -39,6 +39,7 @@ from langgraph._internal._constants import (
|
||||
CONFIG_KEY_CHECKPOINT_MAP,
|
||||
CONFIG_KEY_CHECKPOINT_NS,
|
||||
CONFIG_KEY_CHECKPOINTER,
|
||||
CONFIG_KEY_NODE_ERROR,
|
||||
CONFIG_KEY_READ,
|
||||
CONFIG_KEY_RESUME_MAP,
|
||||
CONFIG_KEY_RUNTIME,
|
||||
@@ -47,6 +48,7 @@ from langgraph._internal._constants import (
|
||||
CONFIG_KEY_TASK_ID,
|
||||
CONFIG_KEY_THREAD_ID,
|
||||
ERROR,
|
||||
ERROR_SOURCE_NODE,
|
||||
INTERRUPT,
|
||||
NO_WRITES,
|
||||
NS_END,
|
||||
@@ -66,6 +68,7 @@ from langgraph.channels.base import BaseChannel
|
||||
from langgraph.channels.topic import Topic
|
||||
from langgraph.channels.untracked_value import UntrackedValue
|
||||
from langgraph.constants import TAG_HIDDEN
|
||||
from langgraph.errors import NodeError
|
||||
from langgraph.managed.base import ManagedValueMapping
|
||||
from langgraph.pregel._call import get_runnable_for_task, identifier
|
||||
from langgraph.pregel._io import read_channels
|
||||
@@ -80,6 +83,7 @@ from langgraph.types import (
|
||||
PregelTask,
|
||||
RetryPolicy,
|
||||
Send,
|
||||
TimeoutPolicy,
|
||||
)
|
||||
|
||||
GetNextVersion = Callable[[V | None, None], V]
|
||||
@@ -114,13 +118,21 @@ class PregelTaskWrites(NamedTuple):
|
||||
|
||||
|
||||
class Call:
|
||||
__slots__ = ("func", "input", "retry_policy", "cache_policy", "callbacks")
|
||||
__slots__ = (
|
||||
"func",
|
||||
"input",
|
||||
"retry_policy",
|
||||
"cache_policy",
|
||||
"callbacks",
|
||||
"timeout",
|
||||
)
|
||||
|
||||
func: Callable
|
||||
input: tuple[tuple[Any, ...], dict[str, Any]]
|
||||
retry_policy: Sequence[RetryPolicy] | None
|
||||
cache_policy: CachePolicy | None
|
||||
callbacks: Callbacks
|
||||
timeout: TimeoutPolicy | None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -130,12 +142,14 @@ class Call:
|
||||
retry_policy: Sequence[RetryPolicy] | None,
|
||||
cache_policy: CachePolicy | None,
|
||||
callbacks: Callbacks,
|
||||
timeout: TimeoutPolicy | None = None,
|
||||
) -> None:
|
||||
self.func = func
|
||||
self.input = input
|
||||
self.retry_policy = retry_policy
|
||||
self.cache_policy = cache_policy
|
||||
self.callbacks = callbacks
|
||||
self.timeout = timeout
|
||||
|
||||
|
||||
def should_interrupt(
|
||||
@@ -281,7 +295,15 @@ def apply_writes(
|
||||
pending_writes_by_channel: dict[str, list[Any]] = defaultdict(list)
|
||||
for task in tasks:
|
||||
for chan, val in task.writes:
|
||||
if chan in (NO_WRITES, PUSH, RESUME, INTERRUPT, RETURN, ERROR):
|
||||
if chan in (
|
||||
NO_WRITES,
|
||||
PUSH,
|
||||
RESUME,
|
||||
INTERRUPT,
|
||||
RETURN,
|
||||
ERROR,
|
||||
ERROR_SOURCE_NODE,
|
||||
):
|
||||
pass
|
||||
elif chan in channels:
|
||||
pending_writes_by_channel[chan].append(val)
|
||||
@@ -733,11 +755,48 @@ def prepare_single_task(
|
||||
task_path[:3],
|
||||
writers=proc.flat_writers,
|
||||
subgraphs=proc.subgraphs,
|
||||
timeout=proc.timeout,
|
||||
)
|
||||
else:
|
||||
return PregelTask(task_id, name, task_path[:3])
|
||||
|
||||
|
||||
def _coerce_pending_error(value: Any) -> BaseException:
|
||||
if isinstance(value, BaseException):
|
||||
return value
|
||||
return Exception(str(value))
|
||||
|
||||
|
||||
def _read_errors_from_pending_writes(
|
||||
pending_writes: list[PendingWrite],
|
||||
) -> list[BaseException]:
|
||||
errors: list[BaseException] = []
|
||||
for _, channel, value in pending_writes:
|
||||
if channel == ERROR:
|
||||
errors.append(_coerce_pending_error(value))
|
||||
return errors
|
||||
|
||||
|
||||
def _read_error_for_task_id_from_pending_writes(
|
||||
pending_writes: list[PendingWrite], task_id: str
|
||||
) -> BaseException | None:
|
||||
for pending_task_id, channel, value in reversed(pending_writes):
|
||||
if pending_task_id == task_id and channel == ERROR:
|
||||
return _coerce_pending_error(value)
|
||||
return None
|
||||
|
||||
|
||||
def _read_error_source_node_from_pending_writes(
|
||||
pending_writes: list[PendingWrite], task_id: str
|
||||
) -> str | None:
|
||||
for pending_task_id, channel, value in reversed(pending_writes):
|
||||
if pending_task_id == task_id and channel == ERROR_SOURCE_NODE:
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return str(value)
|
||||
return None
|
||||
|
||||
|
||||
def prepare_push_task_functional(
|
||||
task_path: tuple[str, tuple, int, str, Call],
|
||||
# (PUSH, parent task path, idx of PUSH write, id of parent task, Call)
|
||||
@@ -870,6 +929,7 @@ def prepare_push_task_functional(
|
||||
cache_key,
|
||||
task_id,
|
||||
in_progress_task_path,
|
||||
timeout=call.timeout,
|
||||
)
|
||||
else:
|
||||
return PregelTask(task_id, name, in_progress_task_path)
|
||||
@@ -1041,11 +1101,153 @@ def prepare_push_task_send(
|
||||
translated_task_path,
|
||||
writers=proc.flat_writers,
|
||||
subgraphs=proc.subgraphs,
|
||||
timeout=packet.timeout if packet.timeout is not None else proc.timeout,
|
||||
)
|
||||
else:
|
||||
return PregelTask(task_id, packet.node, translated_task_path)
|
||||
|
||||
|
||||
def prepare_node_error_handler_task(
|
||||
failed_task: PregelExecutableTask,
|
||||
*,
|
||||
handler_node_name: str,
|
||||
failed_error: BaseException,
|
||||
checkpoint: Checkpoint,
|
||||
pending_writes: list[PendingWrite],
|
||||
processes: Mapping[str, PregelNode],
|
||||
channels: Mapping[str, BaseChannel],
|
||||
managed: ManagedValueMapping,
|
||||
config: RunnableConfig,
|
||||
step: int,
|
||||
stop: int,
|
||||
store: BaseStore | None = None,
|
||||
checkpointer: BaseCheckpointSaver | None = None,
|
||||
manager: None | ParentRunManager | AsyncParentRunManager = None,
|
||||
cache_policy: CachePolicy | None = None,
|
||||
retry_policy: Sequence[RetryPolicy] = (),
|
||||
) -> PregelExecutableTask | None:
|
||||
"""Prepare an immediate node-level error handler task for a failed task."""
|
||||
if handler_node_name not in processes:
|
||||
return None
|
||||
proc = processes[handler_node_name]
|
||||
proc_node = proc.node
|
||||
if proc_node is None:
|
||||
return None
|
||||
|
||||
checkpoint_id_bytes = binascii.unhexlify(checkpoint["id"].replace("-", ""))
|
||||
task_id_func = _xxhash_str if checkpoint["v"] > 1 else _uuid5_str
|
||||
configurable = config.get(CONF, {})
|
||||
parent_ns = configurable.get(CONFIG_KEY_CHECKPOINT_NS, "")
|
||||
checkpoint_ns = (
|
||||
f"{parent_ns}{NS_SEP}{handler_node_name}" if parent_ns else handler_node_name
|
||||
)
|
||||
task_id = task_id_func(
|
||||
checkpoint_id_bytes,
|
||||
checkpoint_ns,
|
||||
str(step),
|
||||
handler_node_name,
|
||||
PUSH,
|
||||
"node_error_handler",
|
||||
failed_task.id,
|
||||
)
|
||||
task_checkpoint_ns = f"{checkpoint_ns}:{task_id}"
|
||||
translated_task_path = (*failed_task.path[:3], "node_error_handler", False)
|
||||
metadata = {
|
||||
"langgraph_step": step,
|
||||
"langgraph_node": handler_node_name,
|
||||
"langgraph_triggers": PUSH_TRIGGER,
|
||||
"langgraph_path": translated_task_path,
|
||||
"langgraph_checkpoint_ns": task_checkpoint_ns,
|
||||
}
|
||||
if proc.metadata:
|
||||
metadata.update(proc.metadata)
|
||||
writes: deque[tuple[str, Any]] = deque()
|
||||
|
||||
effective_retry_policy = proc.retry_policy or retry_policy
|
||||
effective_cache_policy = proc.cache_policy or cache_policy
|
||||
if effective_cache_policy:
|
||||
args_key = effective_cache_policy.key_func(failed_task.input)
|
||||
cache_key = CacheKey(
|
||||
(
|
||||
CACHE_NS_WRITES,
|
||||
(identifier(proc) or "__dynamic__"),
|
||||
handler_node_name,
|
||||
),
|
||||
xxh3_128_hexdigest(
|
||||
args_key.encode() if isinstance(args_key, str) else args_key
|
||||
),
|
||||
effective_cache_policy.ttl,
|
||||
)
|
||||
else:
|
||||
cache_key = None
|
||||
|
||||
scratchpad = _scratchpad(
|
||||
config[CONF].get(CONFIG_KEY_SCRATCHPAD),
|
||||
pending_writes,
|
||||
task_id,
|
||||
xxh3_128_hexdigest(task_checkpoint_ns.encode()),
|
||||
config[CONF].get(CONFIG_KEY_RESUME_MAP),
|
||||
step,
|
||||
stop,
|
||||
)
|
||||
runtime = cast(Runtime, configurable.get(CONFIG_KEY_RUNTIME, DEFAULT_RUNTIME))
|
||||
runtime = runtime.override(
|
||||
store=store, previous=checkpoint["channel_values"].get(PREVIOUS, None)
|
||||
)
|
||||
additional_config: RunnableConfig = {
|
||||
"metadata": metadata,
|
||||
"tags": proc.tags,
|
||||
}
|
||||
return PregelExecutableTask(
|
||||
handler_node_name,
|
||||
failed_task.input,
|
||||
proc_node,
|
||||
writes,
|
||||
patch_config(
|
||||
merge_configs(config, additional_config),
|
||||
run_name=handler_node_name,
|
||||
callbacks=manager.get_child(f"graph:step:{step}") if manager else None,
|
||||
configurable={
|
||||
CONFIG_KEY_TASK_ID: task_id,
|
||||
CONFIG_KEY_SEND: writes.extend,
|
||||
CONFIG_KEY_READ: partial(
|
||||
local_read,
|
||||
scratchpad,
|
||||
channels,
|
||||
managed,
|
||||
PregelTaskWrites(
|
||||
translated_task_path,
|
||||
handler_node_name,
|
||||
writes,
|
||||
PUSH_TRIGGER,
|
||||
),
|
||||
),
|
||||
CONFIG_KEY_CHECKPOINTER: (
|
||||
checkpointer or configurable.get(CONFIG_KEY_CHECKPOINTER)
|
||||
),
|
||||
CONFIG_KEY_CHECKPOINT_MAP: {
|
||||
**configurable.get(CONFIG_KEY_CHECKPOINT_MAP, {}),
|
||||
parent_ns: checkpoint["id"],
|
||||
},
|
||||
CONFIG_KEY_CHECKPOINT_ID: None,
|
||||
CONFIG_KEY_CHECKPOINT_NS: task_checkpoint_ns,
|
||||
CONFIG_KEY_SCRATCHPAD: scratchpad,
|
||||
CONFIG_KEY_RUNTIME: runtime,
|
||||
CONFIG_KEY_NODE_ERROR: NodeError(
|
||||
node=failed_task.name, error=failed_error
|
||||
),
|
||||
},
|
||||
),
|
||||
PUSH_TRIGGER,
|
||||
effective_retry_policy,
|
||||
cache_key,
|
||||
task_id,
|
||||
translated_task_path,
|
||||
writers=proc.flat_writers,
|
||||
subgraphs=proc.subgraphs,
|
||||
)
|
||||
|
||||
|
||||
def checkpoint_null_version(
|
||||
checkpoint: Checkpoint,
|
||||
) -> V | None:
|
||||
@@ -1255,4 +1457,4 @@ def sanitize_untracked_values_in_send(
|
||||
for k, v in packet.arg.items()
|
||||
if not isinstance(channels.get(k), UntrackedValue)
|
||||
}
|
||||
return Send(node=packet.node, arg=sanitized_arg)
|
||||
return Send(node=packet.node, arg=sanitized_arg, timeout=packet.timeout)
|
||||
|
||||
@@ -8,6 +8,7 @@ import inspect
|
||||
import sys
|
||||
import types
|
||||
from collections.abc import Awaitable, Callable, Generator, Sequence
|
||||
from datetime import timedelta
|
||||
from typing import Any, Generic, TypeVar, cast
|
||||
|
||||
from langchain_core.runnables import Runnable
|
||||
@@ -20,9 +21,13 @@ from langgraph._internal._runnable import (
|
||||
is_async_callable,
|
||||
run_in_executor,
|
||||
)
|
||||
from langgraph._internal._timeout import (
|
||||
coerce_timeout_policy,
|
||||
sync_timeout_unsupported,
|
||||
)
|
||||
from langgraph.config import get_config
|
||||
from langgraph.pregel._write import ChannelWrite, ChannelWriteEntry
|
||||
from langgraph.types import CachePolicy, RetryPolicy
|
||||
from langgraph.types import CachePolicy, RetryPolicy, TimeoutPolicy
|
||||
|
||||
##
|
||||
# Utilities borrowed from cloudpickle.
|
||||
@@ -255,8 +260,31 @@ def call(
|
||||
*args: Any,
|
||||
retry_policy: Sequence[RetryPolicy] | None = None,
|
||||
cache_policy: CachePolicy | None = None,
|
||||
timeout: float | timedelta | TimeoutPolicy | None = None,
|
||||
**kwargs: Any,
|
||||
) -> SyncAsyncFuture[T]:
|
||||
return _call_with_options(
|
||||
func,
|
||||
args,
|
||||
kwargs,
|
||||
retry_policy=retry_policy,
|
||||
cache_policy=cache_policy,
|
||||
timeout=coerce_timeout_policy(timeout),
|
||||
)
|
||||
|
||||
|
||||
def _call_with_options(
|
||||
func: Callable[P, Awaitable[T]] | Callable[P, T],
|
||||
args: tuple[Any, ...],
|
||||
kwargs: dict[str, Any],
|
||||
*,
|
||||
retry_policy: Sequence[RetryPolicy] | None = None,
|
||||
cache_policy: CachePolicy | None = None,
|
||||
timeout: TimeoutPolicy | None = None,
|
||||
) -> SyncAsyncFuture[T]:
|
||||
if timeout is not None and not is_async_callable(func):
|
||||
name = getattr(func, "__name__", func.__class__.__name__)
|
||||
raise sync_timeout_unsupported(name, kind="Task")
|
||||
config = get_config()
|
||||
impl = config[CONF][CONFIG_KEY_CALL]
|
||||
fut = impl(
|
||||
@@ -265,5 +293,6 @@ def call(
|
||||
retry_policy=retry_policy,
|
||||
cache_policy=cache_policy,
|
||||
callbacks=config["callbacks"],
|
||||
timeout=timeout,
|
||||
)
|
||||
return fut
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from collections.abc import Callable, Mapping
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, cast
|
||||
|
||||
from langgraph.checkpoint.base import Checkpoint
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from langgraph.checkpoint.base import (
|
||||
BaseCheckpointSaver,
|
||||
Checkpoint,
|
||||
CheckpointMetadata,
|
||||
)
|
||||
from langgraph.checkpoint.base.id import uuid6
|
||||
from langgraph.checkpoint.serde.types import _DeltaSnapshot
|
||||
|
||||
from langgraph._internal._typing import MISSING
|
||||
from langgraph.channels.base import BaseChannel
|
||||
from langgraph.channels.delta import DeltaChannel
|
||||
from langgraph.managed.base import ManagedValueMapping, ManagedValueSpec
|
||||
|
||||
LATEST_VERSION = 4
|
||||
|
||||
GetNextVersion = Callable[[Any, None], Any]
|
||||
|
||||
|
||||
def empty_checkpoint() -> Checkpoint:
|
||||
return Checkpoint(
|
||||
@@ -24,6 +34,30 @@ def empty_checkpoint() -> Checkpoint:
|
||||
)
|
||||
|
||||
|
||||
def _should_snapshot_delta(
|
||||
name: str,
|
||||
ch: DeltaChannel,
|
||||
updates_since_snapshot: Mapping[str, int],
|
||||
*,
|
||||
force: bool,
|
||||
) -> bool:
|
||||
"""Decide whether `ch` should write a `_DeltaSnapshot` this step.
|
||||
|
||||
Triggers:
|
||||
* `force` — always snapshot (used by `durability="exit"`).
|
||||
* Update-count: this channel has accumulated at least
|
||||
`snapshot_frequency` updates since its last snapshot. The count
|
||||
is supplied by the caller via `updates_since_snapshot[name]` and
|
||||
is reset to `0` whenever a snapshot fires.
|
||||
|
||||
Version-format-independent: works for `int`, `float`, and `str`
|
||||
versioning schemes alike.
|
||||
"""
|
||||
if force:
|
||||
return True
|
||||
return updates_since_snapshot.get(name, 0) >= ch.snapshot_frequency
|
||||
|
||||
|
||||
def create_checkpoint(
|
||||
checkpoint: Checkpoint,
|
||||
channels: Mapping[str, BaseChannel] | None,
|
||||
@@ -31,35 +65,108 @@ def create_checkpoint(
|
||||
*,
|
||||
id: str | None = None,
|
||||
updated_channels: set[str] | None = None,
|
||||
get_next_version: GetNextVersion | None = None,
|
||||
force_delta_snapshot: bool = False,
|
||||
updates_since_snapshot: Mapping[str, int] | None = None,
|
||||
new_updates_since_snapshot: dict[str, int] | None = None,
|
||||
) -> Checkpoint:
|
||||
"""Create a checkpoint for the given channels."""
|
||||
"""Create a checkpoint for the given channels.
|
||||
|
||||
For each `DeltaChannel`, a `_DeltaSnapshot(value)` blob is written into
|
||||
`channel_values[k]` when this channel has accumulated at least
|
||||
`snapshot_frequency` updates since its last snapshot (counter supplied
|
||||
via `updates_since_snapshot`). Otherwise the channel is omitted from
|
||||
`channel_values`; its `channel_versions` entry still bumps so that the
|
||||
saver tracks the channel and the ancestor walk can replay writes.
|
||||
|
||||
Snapshots are eager: even if the channel had no write this step, a
|
||||
version bump is forced (via `get_next_version`) so `put()` includes
|
||||
the channel in `new_versions` and stores the blob.
|
||||
|
||||
`force_delta_snapshot` ignores the cadence and always snapshots —
|
||||
used by `durability="exit"` where intermediate writes are not stored
|
||||
as ancestor `checkpoint_writes`.
|
||||
|
||||
If `new_updates_since_snapshot` is provided, the function resets the
|
||||
counter to `0` for any channel that snapshotted this step. Counters
|
||||
for channels that did not snapshot are left untouched (the caller is
|
||||
responsible for incrementing them based on `updated_channels`).
|
||||
"""
|
||||
ts = datetime.now(timezone.utc).isoformat()
|
||||
counts = updates_since_snapshot or {}
|
||||
if channels is None:
|
||||
values = checkpoint["channel_values"]
|
||||
channel_versions = checkpoint["channel_versions"]
|
||||
else:
|
||||
values = {}
|
||||
channel_versions = dict(checkpoint["channel_versions"])
|
||||
for k in channels:
|
||||
if k not in checkpoint["channel_versions"]:
|
||||
if k not in channel_versions:
|
||||
continue
|
||||
v = channels[k].checkpoint()
|
||||
if v is not MISSING:
|
||||
values[k] = v
|
||||
ch = channels[k]
|
||||
if (
|
||||
isinstance(ch, DeltaChannel)
|
||||
and ch.is_available()
|
||||
and _should_snapshot_delta(
|
||||
k,
|
||||
ch,
|
||||
counts,
|
||||
force=force_delta_snapshot,
|
||||
)
|
||||
):
|
||||
# Eager snapshot: bump version if not already written this step
|
||||
# so put() includes this channel in new_versions and stores blob.
|
||||
if get_next_version is not None and (
|
||||
updated_channels is None or k not in updated_channels
|
||||
):
|
||||
channel_versions[k] = get_next_version(channel_versions[k], None)
|
||||
values[k] = _DeltaSnapshot(ch.get())
|
||||
if new_updates_since_snapshot is not None:
|
||||
new_updates_since_snapshot[k] = 0
|
||||
else:
|
||||
v = ch.checkpoint()
|
||||
if v is not MISSING:
|
||||
values[k] = v
|
||||
return Checkpoint(
|
||||
v=LATEST_VERSION,
|
||||
ts=ts,
|
||||
id=id or str(uuid6(clock_seq=step)),
|
||||
channel_values=values,
|
||||
channel_versions=checkpoint["channel_versions"],
|
||||
channel_versions=channel_versions,
|
||||
versions_seen=checkpoint["versions_seen"],
|
||||
updated_channels=None if updated_channels is None else sorted(updated_channels),
|
||||
)
|
||||
|
||||
|
||||
def _needs_replay(spec: BaseChannel, stored: object) -> bool:
|
||||
"""True if `spec` is a `DeltaChannel` and no value is stored at this
|
||||
checkpoint, requiring an ancestor walk to reconstruct.
|
||||
|
||||
`_DeltaSnapshot` blobs and plain values (migration) resolve directly via
|
||||
`from_checkpoint` — only absence (`MISSING`) triggers replay.
|
||||
"""
|
||||
if not isinstance(spec, DeltaChannel):
|
||||
return False
|
||||
return stored is MISSING
|
||||
|
||||
|
||||
def channels_from_checkpoint(
|
||||
specs: Mapping[str, BaseChannel | ManagedValueSpec],
|
||||
checkpoint: Checkpoint,
|
||||
*,
|
||||
saver: BaseCheckpointSaver | None = None,
|
||||
config: RunnableConfig | None = None,
|
||||
) -> tuple[Mapping[str, BaseChannel], ManagedValueMapping]:
|
||||
"""Get channels from a checkpoint."""
|
||||
"""Hydrate channels from a checkpoint.
|
||||
|
||||
For most channels, `spec.from_checkpoint(checkpoint["channel_values"][k])`
|
||||
is sufficient. `DeltaChannel` is the exception: when the channel is
|
||||
absent from `channel_values`, an ancestor walk via
|
||||
`saver.get_delta_channel_history` is required to find the nearest seed
|
||||
(`_DeltaSnapshot` blob or pre-migration plain value) and accumulate
|
||||
the writes between it and the target. All delta channels needing
|
||||
replay are batched into a single saver call.
|
||||
"""
|
||||
channel_specs: dict[str, BaseChannel] = {}
|
||||
managed_specs: dict[str, ManagedValueSpec] = {}
|
||||
for k, v in specs.items():
|
||||
@@ -67,13 +174,73 @@ def channels_from_checkpoint(
|
||||
channel_specs[k] = v
|
||||
else:
|
||||
managed_specs[k] = v
|
||||
return (
|
||||
{
|
||||
k: v.from_checkpoint(checkpoint["channel_values"].get(k, MISSING))
|
||||
for k, v in channel_specs.items()
|
||||
},
|
||||
managed_specs,
|
||||
)
|
||||
|
||||
delta_channels: list[str] = [
|
||||
k
|
||||
for k, spec in channel_specs.items()
|
||||
if _needs_replay(spec, checkpoint["channel_values"].get(k, MISSING))
|
||||
]
|
||||
histories: Mapping[str, Any] = {}
|
||||
if delta_channels and saver is not None and config is not None:
|
||||
histories = saver.get_delta_channel_history(
|
||||
config=config, channels=delta_channels
|
||||
)
|
||||
|
||||
channels: dict[str, BaseChannel] = {}
|
||||
for k, spec in channel_specs.items():
|
||||
ch: BaseChannel
|
||||
if k in histories:
|
||||
delta_spec = cast(DeltaChannel, spec)
|
||||
history = histories[k]
|
||||
replay_ch = delta_spec.from_checkpoint(history.get("seed", MISSING))
|
||||
replay_ch.replay_writes(history["writes"])
|
||||
ch = replay_ch
|
||||
else:
|
||||
ch = spec.from_checkpoint(checkpoint["channel_values"].get(k, MISSING))
|
||||
channels[k] = ch
|
||||
return channels, managed_specs
|
||||
|
||||
|
||||
async def achannels_from_checkpoint(
|
||||
specs: Mapping[str, BaseChannel | ManagedValueSpec],
|
||||
checkpoint: Checkpoint,
|
||||
*,
|
||||
saver: BaseCheckpointSaver | None = None,
|
||||
config: RunnableConfig | None = None,
|
||||
) -> tuple[Mapping[str, BaseChannel], ManagedValueMapping]:
|
||||
"""Async version of `channels_from_checkpoint`. See docstring there."""
|
||||
channel_specs: dict[str, BaseChannel] = {}
|
||||
managed_specs: dict[str, ManagedValueSpec] = {}
|
||||
for k, v in specs.items():
|
||||
if isinstance(v, BaseChannel):
|
||||
channel_specs[k] = v
|
||||
else:
|
||||
managed_specs[k] = v
|
||||
|
||||
delta_channels: list[str] = [
|
||||
k
|
||||
for k, spec in channel_specs.items()
|
||||
if _needs_replay(spec, checkpoint["channel_values"].get(k, MISSING))
|
||||
]
|
||||
histories: Mapping[str, Any] = {}
|
||||
if delta_channels and saver is not None and config is not None:
|
||||
histories = await saver.aget_delta_channel_history(
|
||||
config=config, channels=delta_channels
|
||||
)
|
||||
|
||||
channels: dict[str, BaseChannel] = {}
|
||||
for k, spec in channel_specs.items():
|
||||
ch: BaseChannel
|
||||
if k in histories:
|
||||
delta_spec = cast(DeltaChannel, spec)
|
||||
history = histories[k]
|
||||
replay_ch = delta_spec.from_checkpoint(history.get("seed", MISSING))
|
||||
replay_ch.replay_writes(history["writes"])
|
||||
ch = replay_ch
|
||||
else:
|
||||
ch = spec.from_checkpoint(checkpoint["channel_values"].get(k, MISSING))
|
||||
channels[k] = ch
|
||||
return channels, managed_specs
|
||||
|
||||
|
||||
def copy_checkpoint(checkpoint: Checkpoint) -> Checkpoint:
|
||||
@@ -86,3 +253,17 @@ def copy_checkpoint(checkpoint: Checkpoint) -> Checkpoint:
|
||||
versions_seen={k: v.copy() for k, v in checkpoint["versions_seen"].items()},
|
||||
updated_channels=checkpoint.get("updated_channels", None),
|
||||
)
|
||||
|
||||
|
||||
def read_delta_updates_since_snapshot(
|
||||
metadata: CheckpointMetadata | None,
|
||||
) -> dict[str, int]:
|
||||
"""Read the per-channel update counter from checkpoint metadata.
|
||||
|
||||
Returns an empty dict for missing/None metadata; the dict is
|
||||
`total=False` on `CheckpointMetadata`, so absence means "no prior
|
||||
delta-channel activity tracked."
|
||||
"""
|
||||
if not metadata:
|
||||
return {}
|
||||
return dict(metadata.get("delta_updates_since_snapshot", {}) or {})
|
||||
|
||||
@@ -45,11 +45,13 @@ from langgraph._internal._constants import (
|
||||
CONFIG_KEY_REPLAY_STATE,
|
||||
CONFIG_KEY_RESUME_MAP,
|
||||
CONFIG_KEY_RESUMING,
|
||||
CONFIG_KEY_RUNTIME,
|
||||
CONFIG_KEY_SCRATCHPAD,
|
||||
CONFIG_KEY_STREAM,
|
||||
CONFIG_KEY_TASK_ID,
|
||||
CONFIG_KEY_THREAD_ID,
|
||||
ERROR,
|
||||
ERROR_SOURCE_NODE,
|
||||
INPUT,
|
||||
INTERRUPT,
|
||||
NS_END,
|
||||
@@ -68,6 +70,7 @@ from langgraph.callbacks import (
|
||||
GraphResumeEvent,
|
||||
)
|
||||
from langgraph.channels.base import BaseChannel
|
||||
from langgraph.channels.delta import DeltaChannel
|
||||
from langgraph.channels.untracked_value import UntrackedValue
|
||||
from langgraph.constants import TAG_HIDDEN
|
||||
from langgraph.errors import (
|
||||
@@ -86,12 +89,14 @@ from langgraph.pregel._algo import (
|
||||
checkpoint_null_version,
|
||||
increment,
|
||||
prepare_next_tasks,
|
||||
prepare_node_error_handler_task,
|
||||
prepare_single_task,
|
||||
sanitize_untracked_values_in_send,
|
||||
should_interrupt,
|
||||
task_path_str,
|
||||
)
|
||||
from langgraph.pregel._checkpoint import (
|
||||
achannels_from_checkpoint,
|
||||
channels_from_checkpoint,
|
||||
copy_checkpoint,
|
||||
create_checkpoint,
|
||||
@@ -117,6 +122,7 @@ from langgraph.pregel.debug import (
|
||||
map_debug_tasks,
|
||||
)
|
||||
from langgraph.pregel.protocol import StreamChunk, StreamProtocol
|
||||
from langgraph.runtime import RunControl, Runtime
|
||||
from langgraph.types import (
|
||||
All,
|
||||
CachePolicy,
|
||||
@@ -188,6 +194,8 @@ class PregelLoop:
|
||||
_migrate_checkpoint: Callable[[Checkpoint], None] | None
|
||||
submit: Submit
|
||||
channels: Mapping[str, BaseChannel]
|
||||
# Only set on AsyncPregelLoop; sync loops keep this as None.
|
||||
_delta_write_futs: list[Any] | None = None
|
||||
managed: ManagedValueMapping
|
||||
checkpoint: Checkpoint
|
||||
checkpoint_id_saved: str
|
||||
@@ -202,10 +210,12 @@ class PregelLoop:
|
||||
"input",
|
||||
"pending",
|
||||
"done",
|
||||
"draining",
|
||||
"interrupt_before",
|
||||
"interrupt_after",
|
||||
"out_of_steps",
|
||||
]
|
||||
control: RunControl | None
|
||||
tasks: dict[str, PregelExecutableTask]
|
||||
output: None | dict[str, Any] | Any = None
|
||||
updated_channels: set[str] | None = None
|
||||
@@ -313,6 +323,8 @@ class PregelLoop:
|
||||
else ()
|
||||
)
|
||||
self.prev_checkpoint_config = None
|
||||
runtime = self.config[CONF].get(CONFIG_KEY_RUNTIME)
|
||||
self.control = runtime.control if isinstance(runtime, Runtime) else None
|
||||
|
||||
def _push_graph_lifecycle_event(
|
||||
self,
|
||||
@@ -320,11 +332,16 @@ class PregelLoop:
|
||||
*,
|
||||
interrupts: tuple[Interrupt, ...] = (),
|
||||
) -> None:
|
||||
# drain status never reaches lifecycle events: tick() returns False
|
||||
# before pushing, and interrupts are raised through GraphInterrupt
|
||||
if self.status == "draining":
|
||||
raise RuntimeError("Draining status cannot emit lifecycle events")
|
||||
status = self.status
|
||||
if kind == "resume":
|
||||
self._graph_lifecycle_events.append(
|
||||
GraphResumeEvent(
|
||||
run_id=None,
|
||||
status=self.status,
|
||||
status=status,
|
||||
checkpoint_id=self.checkpoint["id"],
|
||||
checkpoint_ns=self.checkpoint_ns,
|
||||
)
|
||||
@@ -333,7 +350,7 @@ class PregelLoop:
|
||||
self._graph_lifecycle_events.append(
|
||||
GraphInterruptEvent(
|
||||
run_id=None,
|
||||
status=self.status,
|
||||
status=status,
|
||||
checkpoint_id=self.checkpoint["id"],
|
||||
checkpoint_ns=self.checkpoint_ns,
|
||||
interrupts=interrupts,
|
||||
@@ -406,7 +423,7 @@ class PregelLoop:
|
||||
task = self.tasks.get(task_id)
|
||||
else:
|
||||
task = None
|
||||
self.submit(
|
||||
fut = self.submit(
|
||||
self.checkpointer_put_writes,
|
||||
config,
|
||||
writes_to_save,
|
||||
@@ -414,12 +431,16 @@ class PregelLoop:
|
||||
task_path_str(task.path) if task else "",
|
||||
)
|
||||
else:
|
||||
self.submit(
|
||||
fut = self.submit(
|
||||
self.checkpointer_put_writes,
|
||||
config,
|
||||
writes_to_save,
|
||||
task_id,
|
||||
)
|
||||
if self._delta_write_futs is not None and any(
|
||||
isinstance(self.specs.get(c), DeltaChannel) for c, _ in writes_to_save
|
||||
):
|
||||
self._delta_write_futs.append(fut)
|
||||
# output writes
|
||||
if hasattr(self, "tasks"):
|
||||
self.output_writes(task_id, writes)
|
||||
@@ -503,6 +524,16 @@ class PregelLoop:
|
||||
# return the new task, to be started if not run before
|
||||
return pushed
|
||||
|
||||
def schedule_error_handler(
|
||||
self, failed_task: PregelExecutableTask, error: BaseException
|
||||
) -> PregelExecutableTask | None:
|
||||
raise NotImplementedError
|
||||
|
||||
async def aschedule_error_handler(
|
||||
self, failed_task: PregelExecutableTask, error: BaseException
|
||||
) -> PregelExecutableTask | None:
|
||||
raise NotImplementedError
|
||||
|
||||
def tick(self) -> bool:
|
||||
"""Execute a single iteration of the Pregel loop.
|
||||
|
||||
@@ -561,6 +592,10 @@ class PregelLoop:
|
||||
self.status = "done"
|
||||
return False
|
||||
|
||||
if self.control is not None and self.control.drain_requested:
|
||||
self.status = "draining"
|
||||
return False
|
||||
|
||||
# if there are pending writes from a previous loop, apply them
|
||||
if not self.is_replaying and self.checkpoint_pending_writes:
|
||||
self._match_writes(self.tasks)
|
||||
@@ -627,7 +662,7 @@ class PregelLoop:
|
||||
|
||||
def _match_writes(self, tasks: Mapping[str, PregelExecutableTask]) -> None:
|
||||
for tid, k, v in self.checkpoint_pending_writes:
|
||||
if k in (ERROR, INTERRUPT, RESUME):
|
||||
if k in (ERROR, ERROR_SOURCE_NODE, INTERRUPT, RESUME):
|
||||
continue
|
||||
if task := tasks.get(tid):
|
||||
task.writes.append((k, v))
|
||||
@@ -875,6 +910,22 @@ class PregelLoop:
|
||||
if exiting and self.checkpoint["id"] == self.checkpoint_id_saved:
|
||||
# checkpoint already saved
|
||||
return
|
||||
# Carry per-delta-channel update bookkeeping forward across
|
||||
# supersteps. Capture from the OLD metadata before potentially
|
||||
# replacing it with a fresh dict that wouldn't contain it. Then
|
||||
# increment for any delta channel updated this step (so the count
|
||||
# reflects "supersteps that wrote to this channel since last
|
||||
# snapshot"). create_checkpoint will reset entries to 0 for any
|
||||
# channel that fires a snapshot this step.
|
||||
prev_counts = dict(
|
||||
self.checkpoint_metadata.get("delta_updates_since_snapshot", {}) or {}
|
||||
)
|
||||
new_counts = dict(prev_counts)
|
||||
if self.updated_channels:
|
||||
for ch_name in self.updated_channels:
|
||||
ch_obj = self.channels.get(ch_name)
|
||||
if isinstance(ch_obj, DeltaChannel):
|
||||
new_counts[ch_name] = new_counts.get(ch_name, 0) + 1
|
||||
if not exiting:
|
||||
metadata["step"] = self.step
|
||||
metadata["parents"] = self.config[CONF].get(CONFIG_KEY_CHECKPOINT_MAP, {})
|
||||
@@ -890,7 +941,17 @@ class PregelLoop:
|
||||
self.step,
|
||||
id=self.checkpoint["id"] if exiting else None,
|
||||
updated_channels=self.updated_channels,
|
||||
get_next_version=self.checkpointer_get_next_version
|
||||
if do_checkpoint
|
||||
else None,
|
||||
force_delta_snapshot=exiting and self.durability == "exit",
|
||||
updates_since_snapshot=new_counts,
|
||||
new_updates_since_snapshot=new_counts,
|
||||
)
|
||||
if new_counts:
|
||||
self.checkpoint_metadata["delta_updates_since_snapshot"] = new_counts
|
||||
elif "delta_updates_since_snapshot" in self.checkpoint_metadata:
|
||||
del self.checkpoint_metadata["delta_updates_since_snapshot"]
|
||||
# sanitize TASK channel in the checkpoint before saving (durability=="exit")
|
||||
if TASKS in self.checkpoint["channel_values"] and any(
|
||||
isinstance(channel, UntrackedValue) for channel in self.channels.values()
|
||||
@@ -1200,6 +1261,45 @@ class SyncPregelLoop(PregelLoop, AbstractContextManager):
|
||||
self.output_writes(task.id, task.writes, cached=True)
|
||||
return pushed
|
||||
|
||||
def schedule_error_handler(
|
||||
self, failed_task: PregelExecutableTask, error: BaseException
|
||||
) -> PregelExecutableTask | None:
|
||||
handler_node = self.nodes[failed_task.name].error_handler_node
|
||||
if not handler_node:
|
||||
return None
|
||||
writes = list(failed_task.writes)
|
||||
writes.append((ERROR_SOURCE_NODE, failed_task.name))
|
||||
self.put_writes(
|
||||
failed_task.id,
|
||||
writes,
|
||||
)
|
||||
handler_task = prepare_node_error_handler_task(
|
||||
failed_task,
|
||||
handler_node_name=handler_node,
|
||||
failed_error=error,
|
||||
checkpoint=self.checkpoint,
|
||||
pending_writes=self.checkpoint_pending_writes,
|
||||
processes=self.nodes,
|
||||
channels=self.channels,
|
||||
managed=self.managed,
|
||||
config=failed_task.config,
|
||||
step=self.step,
|
||||
stop=self.stop,
|
||||
store=self.store,
|
||||
checkpointer=self.checkpointer,
|
||||
manager=self.manager,
|
||||
retry_policy=self.retry_policy,
|
||||
cache_policy=self.cache_policy,
|
||||
)
|
||||
if handler_task is None:
|
||||
return None
|
||||
self.tasks[handler_task.id] = handler_task
|
||||
if not self.is_replaying:
|
||||
self._match_writes({handler_task.id: handler_task})
|
||||
for task in self.match_cached_writes():
|
||||
self.output_writes(task.id, task.writes, cached=True)
|
||||
return handler_task
|
||||
|
||||
def put_writes(self, task_id: str, writes: WritesT) -> None:
|
||||
"""Put writes for a task, to be read by the next tick."""
|
||||
super().put_writes(task_id, writes)
|
||||
@@ -1273,7 +1373,10 @@ class SyncPregelLoop(PregelLoop, AbstractContextManager):
|
||||
)
|
||||
self.submit = self.stack.enter_context(BackgroundExecutor(self.config))
|
||||
self.channels, self.managed = channels_from_checkpoint(
|
||||
self.specs, self.checkpoint
|
||||
self.specs,
|
||||
self.checkpoint,
|
||||
saver=self.checkpointer,
|
||||
config=self.checkpoint_config,
|
||||
)
|
||||
self.stack.push(self._suppress_interrupt)
|
||||
self.status = "input"
|
||||
@@ -1368,6 +1471,11 @@ class AsyncPregelLoop(PregelLoop, AbstractAsyncContextManager):
|
||||
metadata: CheckpointMetadata,
|
||||
new_versions: ChannelVersions,
|
||||
) -> RunnableConfig:
|
||||
# Drain DeltaChannel write futures before committing the checkpoint so
|
||||
# ancestor walks never see a checkpoint without its backing writes.
|
||||
if self._delta_write_futs:
|
||||
futs, self._delta_write_futs = self._delta_write_futs, []
|
||||
await asyncio.gather(*futs)
|
||||
try:
|
||||
if prev is not None:
|
||||
await prev
|
||||
@@ -1399,6 +1507,45 @@ class AsyncPregelLoop(PregelLoop, AbstractAsyncContextManager):
|
||||
self.output_writes(task.id, task.writes, cached=True)
|
||||
return pushed
|
||||
|
||||
async def aschedule_error_handler(
|
||||
self, failed_task: PregelExecutableTask, error: BaseException
|
||||
) -> PregelExecutableTask | None:
|
||||
handler_node = self.nodes[failed_task.name].error_handler_node
|
||||
if not handler_node:
|
||||
return None
|
||||
writes = list(failed_task.writes)
|
||||
writes.append((ERROR_SOURCE_NODE, failed_task.name))
|
||||
self.put_writes(
|
||||
failed_task.id,
|
||||
writes,
|
||||
)
|
||||
handler_task = prepare_node_error_handler_task(
|
||||
failed_task,
|
||||
handler_node_name=handler_node,
|
||||
failed_error=error,
|
||||
checkpoint=self.checkpoint,
|
||||
pending_writes=self.checkpoint_pending_writes,
|
||||
processes=self.nodes,
|
||||
channels=self.channels,
|
||||
managed=self.managed,
|
||||
config=failed_task.config,
|
||||
step=self.step,
|
||||
stop=self.stop,
|
||||
store=self.store,
|
||||
checkpointer=self.checkpointer,
|
||||
manager=self.manager,
|
||||
retry_policy=self.retry_policy,
|
||||
cache_policy=self.cache_policy,
|
||||
)
|
||||
if handler_task is None:
|
||||
return None
|
||||
self.tasks[handler_task.id] = handler_task
|
||||
if not self.is_replaying:
|
||||
self._match_writes({handler_task.id: handler_task})
|
||||
for task in await self.amatch_cached_writes():
|
||||
self.output_writes(task.id, task.writes, cached=True)
|
||||
return handler_task
|
||||
|
||||
def put_writes(self, task_id: str, writes: WritesT) -> None:
|
||||
"""Put writes for a task, to be read by the next tick."""
|
||||
super().put_writes(task_id, writes)
|
||||
@@ -1473,11 +1620,15 @@ class AsyncPregelLoop(PregelLoop, AbstractAsyncContextManager):
|
||||
if saved.pending_writes is not None
|
||||
else []
|
||||
)
|
||||
self._delta_write_futs = []
|
||||
self.submit = await self.stack.enter_async_context(
|
||||
AsyncBackgroundExecutor(self.config)
|
||||
)
|
||||
self.channels, self.managed = channels_from_checkpoint(
|
||||
self.specs, self.checkpoint
|
||||
self.channels, self.managed = await achannels_from_checkpoint(
|
||||
self.specs,
|
||||
self.checkpoint,
|
||||
saver=self.checkpointer,
|
||||
config=self.checkpoint_config,
|
||||
)
|
||||
self.stack.push(self._suppress_interrupt)
|
||||
self.status = "input"
|
||||
|
||||
@@ -24,6 +24,11 @@ try:
|
||||
except ImportError:
|
||||
_StreamingCallbackHandler = object # type: ignore
|
||||
|
||||
try:
|
||||
from langchain_core.tracers._streaming import _V2StreamingCallbackHandler
|
||||
except ImportError:
|
||||
_V2StreamingCallbackHandler = object # type: ignore
|
||||
|
||||
T = TypeVar("T")
|
||||
Meta = tuple[tuple[str, ...], dict[str, Any]]
|
||||
|
||||
@@ -248,3 +253,126 @@ class StreamMessagesHandler(BaseCallbackHandler, _StreamingCallbackHandler):
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
self.metadata.pop(run_id, None)
|
||||
|
||||
|
||||
class StreamMessagesHandlerV2(StreamMessagesHandler, _V2StreamingCallbackHandler):
|
||||
"""v2 variant of `StreamMessagesHandler`.
|
||||
|
||||
Declaring `_V2StreamingCallbackHandler` as a base flips
|
||||
`BaseChatModel.invoke` to route through `_stream_chat_model_events`
|
||||
(firing `on_stream_event`) instead of `_stream` (firing
|
||||
`on_llm_new_token`). Inherits `on_stream_event` from the parent,
|
||||
which forwards protocol events onto the messages stream channel.
|
||||
|
||||
Pregel attaches this class instead of the v1 handler only when
|
||||
`StreamingHandler` opts in via the internal
|
||||
`CONFIG_KEY_STREAM_MESSAGES_V2` config key; direct
|
||||
`graph.stream(stream_mode="messages")` callers keep the v1
|
||||
AIMessageChunk shape.
|
||||
"""
|
||||
|
||||
def on_llm_new_token(
|
||||
self,
|
||||
token: str,
|
||||
*,
|
||||
chunk: ChatGenerationChunk | None = None,
|
||||
run_id: UUID,
|
||||
parent_run_id: UUID | None = None,
|
||||
tags: list[str] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
"""Intentional no-op — v1 chunks are not used on v2-flagged runs.
|
||||
|
||||
The v2 marker already steers `invoke` to the event generator, so
|
||||
`on_llm_new_token` should not fire under normal routing. This
|
||||
override stays a pass-through (no call to `super()`) to make
|
||||
the intent explicit and to guard against any caller (e.g. a
|
||||
node that calls `model.stream()` directly, which still fires
|
||||
the v1 callback) leaking AIMessageChunks onto a v2-flagged
|
||||
messages stream.
|
||||
"""
|
||||
# Intentionally empty: v2 handler does not forward v1 chunks.
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
stream: Callable[[StreamChunk], None],
|
||||
subgraphs: bool,
|
||||
*,
|
||||
parent_ns: tuple[str, ...] | None = None,
|
||||
) -> None:
|
||||
super().__init__(stream, subgraphs, parent_ns=parent_ns)
|
||||
self._streamed_run_ids: set[UUID] = set()
|
||||
|
||||
def on_llm_end(
|
||||
self,
|
||||
response: LLMResult,
|
||||
*,
|
||||
run_id: UUID,
|
||||
parent_run_id: UUID | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
if meta := self.metadata.get(run_id):
|
||||
if response.generations and response.generations[0]:
|
||||
gen = response.generations[0][0]
|
||||
if isinstance(gen, ChatGeneration):
|
||||
if run_id in self._streamed_run_ids:
|
||||
if gen.message.id is None:
|
||||
gen.message.id = str(uuid4())
|
||||
self.seen.add(gen.message.id)
|
||||
else:
|
||||
self._emit(meta, gen.message, dedupe=True)
|
||||
self._streamed_run_ids.discard(run_id)
|
||||
self.metadata.pop(run_id, None)
|
||||
|
||||
def on_llm_error(
|
||||
self,
|
||||
error: BaseException,
|
||||
*,
|
||||
run_id: UUID,
|
||||
parent_run_id: UUID | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
self._streamed_run_ids.discard(run_id)
|
||||
super().on_llm_error(
|
||||
error,
|
||||
run_id=run_id,
|
||||
parent_run_id=parent_run_id,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def on_stream_event(
|
||||
self,
|
||||
event: dict[str, Any],
|
||||
*,
|
||||
run_id: UUID,
|
||||
parent_run_id: UUID | None = None,
|
||||
tags: list[str] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
"""Forward a protocol event from `stream_events(version="v3")` as a messages stream part.
|
||||
|
||||
Fires once per `MessagesData` event (`message-start`, per-block
|
||||
`content-block-*`, `message-finish`). The transformer layer
|
||||
correlates events back to a single `ChatModelStream` via
|
||||
`metadata["run_id"]` — attached here so the v1
|
||||
`stream_mode="messages"` output (which emits
|
||||
`(AIMessageChunk, metadata)` via `on_llm_new_token`) keeps its
|
||||
original metadata shape.
|
||||
|
||||
Lives on the v2 handler rather than the v1 base: content-block
|
||||
events are a v2-only concept, and forwarding them only when the
|
||||
v2 handler is attached keeps the message channel's shape
|
||||
predictable for v1 callers.
|
||||
"""
|
||||
if meta := self.metadata.get(run_id):
|
||||
# Record message_id on message-start so on_chain_end's
|
||||
# dedupe skips the finalized AIMessage the node returns
|
||||
# (otherwise the messages projection double-counts: once
|
||||
# from streaming, once from the chain output).
|
||||
if event.get("event") == "message-start":
|
||||
self._streamed_run_ids.add(run_id)
|
||||
msg_id = event.get("message_id")
|
||||
if msg_id:
|
||||
self.seen.add(msg_id)
|
||||
v2_meta = {**meta[1], "run_id": str(run_id)}
|
||||
self.stream((meta[0], "messages", (event, v2_meta)))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator, Callable, Iterator, Mapping, Sequence
|
||||
from datetime import timedelta
|
||||
from functools import cached_property
|
||||
from typing import (
|
||||
Any,
|
||||
@@ -11,10 +12,11 @@ from langchain_core.runnables import Runnable, RunnableConfig
|
||||
from langgraph._internal._config import merge_configs
|
||||
from langgraph._internal._constants import CONF, CONFIG_KEY_READ
|
||||
from langgraph._internal._runnable import RunnableCallable, RunnableSeq
|
||||
from langgraph._internal._timeout import coerce_timeout_policy
|
||||
from langgraph.pregel._utils import find_subgraph_pregel
|
||||
from langgraph.pregel._write import ChannelWrite
|
||||
from langgraph.pregel.protocol import PregelProtocol
|
||||
from langgraph.types import CachePolicy, RetryPolicy
|
||||
from langgraph.types import CachePolicy, RetryPolicy, TimeoutPolicy
|
||||
|
||||
READ_TYPE = Callable[[str | Sequence[str], bool], Any | dict[str, Any]]
|
||||
INPUT_CACHE_KEY_TYPE = tuple[Callable[..., Any], tuple[str, ...]]
|
||||
@@ -123,12 +125,25 @@ class PregelNode:
|
||||
cache_policy: CachePolicy | None
|
||||
"""The cache policy to use when invoking the node."""
|
||||
|
||||
timeout: TimeoutPolicy | None
|
||||
"""Timeout policy for a single invocation.
|
||||
|
||||
If exceeded, `NodeTimeoutError` is raised and the retry policy (if any)
|
||||
decides whether to retry. Supported only for async nodes.
|
||||
"""
|
||||
|
||||
tags: Sequence[str] | None
|
||||
"""Tags to attach to the node for tracing."""
|
||||
|
||||
metadata: Mapping[str, Any] | None
|
||||
"""Metadata to attach to the node for tracing."""
|
||||
|
||||
is_error_handler: bool
|
||||
"""Whether this node is registered as an error handler node."""
|
||||
|
||||
error_handler_node: str | None
|
||||
"""Optional handler node name for failures from this node."""
|
||||
|
||||
subgraphs: Sequence[PregelProtocol]
|
||||
"""Subgraphs used by the node."""
|
||||
|
||||
@@ -144,7 +159,10 @@ class PregelNode:
|
||||
bound: Runnable[Any, Any] | None = None,
|
||||
retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None,
|
||||
cache_policy: CachePolicy | None = None,
|
||||
is_error_handler: bool = False,
|
||||
error_handler_node: str | None = None,
|
||||
subgraphs: Sequence[PregelProtocol] | None = None,
|
||||
timeout: float | timedelta | TimeoutPolicy | None = None,
|
||||
) -> None:
|
||||
self.channels = channels
|
||||
self.triggers = list(triggers)
|
||||
@@ -156,8 +174,11 @@ class PregelNode:
|
||||
self.retry_policy = (retry_policy,)
|
||||
else:
|
||||
self.retry_policy = retry_policy
|
||||
self.timeout = coerce_timeout_policy(timeout)
|
||||
self.tags = tags
|
||||
self.metadata = metadata
|
||||
self.is_error_handler = is_error_handler
|
||||
self.error_handler_node = error_handler_node
|
||||
if subgraphs is not None:
|
||||
self.subgraphs = subgraphs
|
||||
elif self.bound is not DEFAULT_BOUND:
|
||||
|
||||
@@ -4,32 +4,487 @@ import asyncio
|
||||
import logging
|
||||
import random
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import weakref
|
||||
from collections.abc import Awaitable, Callable, Sequence
|
||||
from dataclasses import replace
|
||||
from typing import Any
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Literal, NamedTuple
|
||||
|
||||
from langchain_core.callbacks import BaseCallbackHandler
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
|
||||
from langgraph._internal._config import patch_configurable, recast_checkpoint_ns
|
||||
from langgraph._internal._config import (
|
||||
merge_configs,
|
||||
patch_configurable,
|
||||
recast_checkpoint_ns,
|
||||
)
|
||||
from langgraph._internal._constants import (
|
||||
CONF,
|
||||
CONFIG_KEY_CALL,
|
||||
CONFIG_KEY_CHECKPOINT_ID,
|
||||
CONFIG_KEY_CHECKPOINT_NS,
|
||||
CONFIG_KEY_RESUMING,
|
||||
CONFIG_KEY_RUNTIME,
|
||||
CONFIG_KEY_SEND,
|
||||
CONFIG_KEY_STREAM,
|
||||
CONFIG_KEY_TASK_ID,
|
||||
CONFIG_KEY_THREAD_ID,
|
||||
CONFIG_KEY_TIMED_ATTEMPT_OBSERVER,
|
||||
NS_SEP,
|
||||
)
|
||||
from langgraph.errors import GraphBubbleUp, ParentCommand
|
||||
from langgraph._internal._runnable import create_task_in_config_context
|
||||
from langgraph._internal._timeout import sync_timeout_unsupported
|
||||
from langgraph.errors import GraphBubbleUp, NodeTimeoutError, ParentCommand
|
||||
from langgraph.pregel.protocol import StreamProtocol
|
||||
from langgraph.runtime import ExecutionInfo, Runtime
|
||||
from langgraph.types import Command, PregelExecutableTask, RetryPolicy
|
||||
from langgraph.types import Command, PregelExecutableTask, RetryPolicy, TimeoutPolicy
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
SUPPORTS_EXC_NOTES = sys.version_info >= (3, 11)
|
||||
|
||||
|
||||
def _timeout_secs(value: float | timedelta) -> float:
|
||||
return value.total_seconds() if isinstance(value, timedelta) else value
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _ResolvedTimeout:
|
||||
run_timeout_secs: float | None
|
||||
idle_timeout_secs: float | None
|
||||
refresh_on: Literal["auto", "heartbeat"] | None
|
||||
|
||||
|
||||
def _resolve_timeout(timeout: TimeoutPolicy) -> _ResolvedTimeout:
|
||||
idle_timeout_secs = (
|
||||
_timeout_secs(timeout.idle_timeout)
|
||||
if timeout.idle_timeout is not None
|
||||
else None
|
||||
)
|
||||
return _ResolvedTimeout(
|
||||
run_timeout_secs=(
|
||||
_timeout_secs(timeout.run_timeout)
|
||||
if timeout.run_timeout is not None
|
||||
else None
|
||||
),
|
||||
idle_timeout_secs=idle_timeout_secs,
|
||||
refresh_on=timeout.refresh_on if idle_timeout_secs is not None else None,
|
||||
)
|
||||
|
||||
|
||||
class _AttemptContext(NamedTuple):
|
||||
"""Immutable per-attempt metadata shared across start/progress/finish events.
|
||||
|
||||
Built once at attempt start and referenced (not copied) by every emitted
|
||||
`_AttemptEvent`, so per-event allocation is just the small event wrapper.
|
||||
|
||||
Intentionally underscore-prefixed: this and `_AttemptEvent` are part of an
|
||||
internal observer contract consumed by langgraph-server. Do not move to
|
||||
`langgraph.types` — server imports them by this path.
|
||||
"""
|
||||
|
||||
task_id: str
|
||||
task_name: str
|
||||
attempt: int
|
||||
run_id: str | None
|
||||
thread_id: str | None
|
||||
checkpoint_ns: str | None
|
||||
started_at: datetime
|
||||
run_timeout_secs: float | None
|
||||
idle_timeout_secs: float | None
|
||||
refresh_on: Literal["auto", "heartbeat"] | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _AttemptEvent:
|
||||
"""One lifecycle event for a timed attempt.
|
||||
|
||||
Holds a reference to the shared `_AttemptContext` and the event-specific
|
||||
fields. The observer must treat this and `context` as read-only — they
|
||||
are reused across all events for the same attempt.
|
||||
"""
|
||||
|
||||
context: _AttemptContext
|
||||
event: Literal["start", "progress", "finish"]
|
||||
progress_at: datetime | None = None
|
||||
finished_at: datetime | None = None
|
||||
status: Literal["success", "error"] | None = None
|
||||
error_type: str | None = None
|
||||
error_message: str | None = None
|
||||
|
||||
|
||||
class _TimedAttemptScope:
|
||||
"""Guarded-config window for timed attempts.
|
||||
|
||||
The wrapped config marks writes, stream events, runtime stream writer calls,
|
||||
child task scheduling, and any LangChain callback event emitted under the
|
||||
node's run as observable progress when `refresh_on="auto"`.
|
||||
`runtime.heartbeat()` exposes a manual progress signal for work that doesn't
|
||||
otherwise emit any of these, and is the only progress signal when
|
||||
`refresh_on="heartbeat"`.
|
||||
Guarded writes are serialized with `close()` so cancelled background tasks
|
||||
cannot persist writes past the timeout boundary. Stream/custom output is
|
||||
best-effort: it is dropped after close is observed, but callbacks run outside
|
||||
the lock because they may contain arbitrary user/runtime code.
|
||||
"""
|
||||
|
||||
__slots__ = (
|
||||
"__weakref__",
|
||||
"_active",
|
||||
"_last_progress",
|
||||
"_last_progress_emit",
|
||||
"_lock",
|
||||
"_on_progress",
|
||||
"_progress_min_interval",
|
||||
"_refresh_on",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
on_progress: Callable[[], None] | None = None,
|
||||
progress_min_interval: float = 0.0,
|
||||
refresh_on: Literal["auto", "heartbeat"] | None = None,
|
||||
) -> None:
|
||||
self._active = True
|
||||
self._last_progress = time.monotonic()
|
||||
self._lock = threading.Lock()
|
||||
self._on_progress = on_progress
|
||||
self._progress_min_interval = progress_min_interval
|
||||
self._refresh_on = refresh_on
|
||||
# `-inf` so the first touch always passes the rate-limit gate.
|
||||
self._last_progress_emit: float = float("-inf")
|
||||
|
||||
def wrap_config(self, config: RunnableConfig) -> RunnableConfig:
|
||||
configurable = config.get(CONF, {})
|
||||
patch: dict[str, Any] = {}
|
||||
if (send := configurable.get(CONFIG_KEY_SEND)) is not None:
|
||||
patch[CONFIG_KEY_SEND] = self._guard_send(send)
|
||||
if (stream := configurable.get(CONFIG_KEY_STREAM)) is not None:
|
||||
patch[CONFIG_KEY_STREAM] = self._guard_stream(stream)
|
||||
if (call := configurable.get(CONFIG_KEY_CALL)) is not None:
|
||||
patch[CONFIG_KEY_CALL] = self._guard_call(call)
|
||||
if isinstance(runtime := configurable.get(CONFIG_KEY_RUNTIME), Runtime):
|
||||
if self._refresh_on is not None:
|
||||
patch[CONFIG_KEY_RUNTIME] = runtime.override(
|
||||
stream_writer=self._guard_stream_writer(runtime.stream_writer),
|
||||
heartbeat=self.touch,
|
||||
)
|
||||
else:
|
||||
patch[CONFIG_KEY_RUNTIME] = runtime.override(
|
||||
stream_writer=self._guard_stream_writer(runtime.stream_writer)
|
||||
)
|
||||
new_config = patch_configurable(config, patch) if patch else config
|
||||
if self._refresh_on == "auto":
|
||||
return merge_configs(
|
||||
new_config, {"callbacks": [_IdleProgressCallbackHandler(self)]}
|
||||
)
|
||||
return new_config
|
||||
|
||||
def touch(self) -> None:
|
||||
# Avoid locking this hot progress path. We accept a small race window in
|
||||
# timestamp ordering because idle_timeout is expected to be coarse compared
|
||||
# with scheduler/thread timing.
|
||||
now = time.monotonic()
|
||||
self._last_progress = now
|
||||
if self._on_progress is None:
|
||||
return
|
||||
# Best-effort rate limit: a benign race may emit a duplicate progress
|
||||
# event under heavy concurrency, which observers must already tolerate
|
||||
# (callbacks fire from arbitrary threads).
|
||||
if now - self._last_progress_emit < self._progress_min_interval:
|
||||
return
|
||||
self._last_progress_emit = now
|
||||
self._on_progress()
|
||||
|
||||
def close(self) -> None:
|
||||
with self._lock:
|
||||
self._active = False
|
||||
|
||||
async def wait_for_idle_timeout(self, idle_timeout_s: float) -> None:
|
||||
while True:
|
||||
with self._lock:
|
||||
if not self._active:
|
||||
return
|
||||
remaining = self._last_progress + idle_timeout_s - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise asyncio.TimeoutError
|
||||
await asyncio.sleep(remaining)
|
||||
|
||||
def _guard_send(
|
||||
self, send: Callable[[Sequence[tuple[str, Any]]], None]
|
||||
) -> Callable[[Sequence[tuple[str, Any]]], None]:
|
||||
def guarded_send(writes: Sequence[tuple[str, Any]]) -> None:
|
||||
with self._lock:
|
||||
if self._active:
|
||||
if writes and self._refresh_on == "auto":
|
||||
self._last_progress = time.monotonic()
|
||||
send(writes)
|
||||
|
||||
return guarded_send
|
||||
|
||||
def _guard_stream(self, stream: StreamProtocol) -> StreamProtocol:
|
||||
# No lock: stream callbacks fire from the event loop only, so the
|
||||
# active-check + write happen atomically between awaits.
|
||||
def guarded_stream(chunk: tuple[tuple[str, ...], str, Any]) -> None:
|
||||
if not self._active:
|
||||
return
|
||||
if self._refresh_on == "auto":
|
||||
self._last_progress = time.monotonic()
|
||||
stream(chunk)
|
||||
|
||||
return StreamProtocol(guarded_stream, stream.modes)
|
||||
|
||||
def _guard_call(self, call: Callable[..., Any]) -> Callable[..., Any]:
|
||||
# No lock: child-task scheduling happens from the event loop only.
|
||||
def guarded_call(*args: Any, **kwargs: Any) -> Any:
|
||||
if not self._active:
|
||||
raise asyncio.CancelledError
|
||||
if self._refresh_on == "auto":
|
||||
self._last_progress = time.monotonic()
|
||||
return call(*args, **kwargs)
|
||||
|
||||
return guarded_call
|
||||
|
||||
def _guard_stream_writer(
|
||||
self, stream_writer: Callable[[Any], None]
|
||||
) -> Callable[[Any], None]:
|
||||
def guarded_stream_writer(chunk: Any) -> None:
|
||||
with self._lock:
|
||||
if not self._active:
|
||||
return
|
||||
if self._refresh_on == "auto":
|
||||
self._last_progress = time.monotonic()
|
||||
stream_writer(chunk)
|
||||
|
||||
return guarded_stream_writer
|
||||
|
||||
|
||||
class _IdleProgressCallbackHandler(BaseCallbackHandler):
|
||||
"""Resets the idle timeout clock on any LangChain callback event.
|
||||
|
||||
Inherits via `config["callbacks"]`, so it sees only events emitted by
|
||||
runs descended from the node's attempt — sibling nodes do not bleed
|
||||
through. Holds the scope by weakref so a child manager that outlives
|
||||
the attempt cannot keep the scope alive.
|
||||
"""
|
||||
|
||||
# Run inline so progress is recorded in callback emission order;
|
||||
# thread-pool dispatch would introduce extra reordering.
|
||||
run_inline = True
|
||||
|
||||
def __init__(self, scope: _TimedAttemptScope) -> None:
|
||||
self._scope_ref = weakref.ref(scope)
|
||||
|
||||
def _touch(self, *args: Any, **kwargs: Any) -> None:
|
||||
if (scope := self._scope_ref()) is not None:
|
||||
scope.touch()
|
||||
|
||||
on_llm_start = _touch
|
||||
on_chat_model_start = _touch
|
||||
on_llm_new_token = _touch
|
||||
on_llm_end = _touch
|
||||
on_llm_error = _touch
|
||||
on_chain_start = _touch
|
||||
on_chain_end = _touch
|
||||
on_chain_error = _touch
|
||||
on_tool_start = _touch
|
||||
on_tool_end = _touch
|
||||
on_tool_error = _touch
|
||||
on_retriever_start = _touch
|
||||
on_retriever_end = _touch
|
||||
on_retriever_error = _touch
|
||||
on_agent_action = _touch
|
||||
on_agent_finish = _touch
|
||||
on_text = _touch
|
||||
on_retry = _touch
|
||||
on_custom_event = _touch
|
||||
|
||||
|
||||
def _drain_cancelled(task: asyncio.Task[Any]) -> None:
|
||||
# Mark the abandoned task's exception as retrieved so asyncio doesn't log it.
|
||||
with suppress(asyncio.CancelledError):
|
||||
task.exception()
|
||||
|
||||
|
||||
def _start_timed_attempt(
|
||||
task: PregelExecutableTask, config: RunnableConfig, timeout: _ResolvedTimeout
|
||||
) -> _AttemptContext | None:
|
||||
configurable = config.get(CONF, {})
|
||||
callback = configurable.get(CONFIG_KEY_TIMED_ATTEMPT_OBSERVER)
|
||||
if callback is None:
|
||||
return None
|
||||
runtime = configurable.get(CONFIG_KEY_RUNTIME)
|
||||
execution_info = runtime.execution_info if isinstance(runtime, Runtime) else None
|
||||
context = _AttemptContext(
|
||||
task_id=task.id,
|
||||
task_name=task.name,
|
||||
attempt=execution_info.node_attempt if execution_info is not None else 1,
|
||||
run_id=execution_info.run_id if execution_info is not None else None,
|
||||
thread_id=execution_info.thread_id if execution_info is not None else None,
|
||||
checkpoint_ns=(
|
||||
execution_info.checkpoint_ns if execution_info is not None else None
|
||||
),
|
||||
started_at=datetime.now(timezone.utc),
|
||||
run_timeout_secs=timeout.run_timeout_secs,
|
||||
idle_timeout_secs=timeout.idle_timeout_secs,
|
||||
refresh_on=timeout.refresh_on,
|
||||
)
|
||||
_dispatch_observer(callback, _AttemptEvent(context=context, event="start"))
|
||||
return context
|
||||
|
||||
|
||||
def _finish_timed_attempt(
|
||||
config: RunnableConfig,
|
||||
context: _AttemptContext | None,
|
||||
error: BaseException | None = None,
|
||||
) -> None:
|
||||
if context is None:
|
||||
return
|
||||
callback = config.get(CONF, {}).get(CONFIG_KEY_TIMED_ATTEMPT_OBSERVER)
|
||||
if callback is None:
|
||||
return
|
||||
_dispatch_observer(
|
||||
callback,
|
||||
_AttemptEvent(
|
||||
context=context,
|
||||
event="finish",
|
||||
finished_at=datetime.now(timezone.utc),
|
||||
status="error" if error is not None else "success",
|
||||
error_type=type(error).__name__ if error is not None else None,
|
||||
error_message=str(error) if error is not None else None,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _emit_progress(
|
||||
callback: Callable[[_AttemptEvent], None],
|
||||
context: _AttemptContext,
|
||||
) -> None:
|
||||
_dispatch_observer(
|
||||
callback,
|
||||
_AttemptEvent(
|
||||
context=context,
|
||||
event="progress",
|
||||
progress_at=datetime.now(timezone.utc),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _dispatch_observer(
|
||||
callback: Callable[[_AttemptEvent], None],
|
||||
event: _AttemptEvent,
|
||||
) -> None:
|
||||
try:
|
||||
callback(event)
|
||||
except Exception:
|
||||
logger.warning("Timed attempt observer failed", exc_info=True)
|
||||
|
||||
|
||||
async def _run_timeout_watchdog(run_timeout_s: float) -> None:
|
||||
await asyncio.sleep(run_timeout_s)
|
||||
raise asyncio.TimeoutError
|
||||
|
||||
|
||||
async def _arun_with_timeout(
|
||||
task: PregelExecutableTask,
|
||||
config: RunnableConfig,
|
||||
timeout: _ResolvedTimeout,
|
||||
attempt_ctx: _AttemptContext | None,
|
||||
*,
|
||||
stream: bool,
|
||||
) -> Any:
|
||||
run_timeout_s = timeout.run_timeout_secs
|
||||
idle_timeout_s = timeout.idle_timeout_secs
|
||||
on_progress: Callable[[], None] | None = None
|
||||
if attempt_ctx is not None:
|
||||
callback = config.get(CONF, {}).get(CONFIG_KEY_TIMED_ATTEMPT_OBSERVER)
|
||||
if callback is not None and idle_timeout_s is not None:
|
||||
on_progress = lambda: _emit_progress(callback, attempt_ctx) # noqa: E731
|
||||
scope = _TimedAttemptScope(
|
||||
on_progress=on_progress,
|
||||
# Cap progress emission at ~4 events per idle window so token-rate
|
||||
# callbacks don't flood the observer.
|
||||
progress_min_interval=idle_timeout_s / 4 if idle_timeout_s is not None else 0.0,
|
||||
refresh_on=timeout.refresh_on,
|
||||
)
|
||||
scoped_config = scope.wrap_config(config)
|
||||
start = time.monotonic()
|
||||
if stream:
|
||||
# Yielded chunks count as progress only under `refresh_on="auto"`.
|
||||
# `refresh_on="heartbeat"` is the strict mode where only explicit
|
||||
# `runtime.heartbeat()` calls reset the idle clock.
|
||||
async def run() -> Any:
|
||||
async for _ in task.proc.astream(task.input, scoped_config):
|
||||
if timeout.refresh_on == "auto":
|
||||
scope.touch()
|
||||
|
||||
else:
|
||||
|
||||
async def run() -> Any:
|
||||
return await task.proc.ainvoke(task.input, scoped_config)
|
||||
|
||||
bg = create_task_in_config_context(run, scoped_config)
|
||||
watchdogs: dict[asyncio.Task[None], Literal["idle", "run"]] = {}
|
||||
if idle_timeout_s is not None:
|
||||
watchdogs[asyncio.create_task(scope.wait_for_idle_timeout(idle_timeout_s))] = (
|
||||
"idle"
|
||||
)
|
||||
if run_timeout_s is not None:
|
||||
watchdogs[asyncio.create_task(_run_timeout_watchdog(run_timeout_s))] = "run"
|
||||
try:
|
||||
done, _ = await asyncio.wait(
|
||||
{bg, *watchdogs}, return_when=asyncio.FIRST_COMPLETED
|
||||
)
|
||||
if bg in done:
|
||||
# Task completed in time.
|
||||
for watchdog in watchdogs:
|
||||
watchdog.cancel()
|
||||
# FIRST_COMPLETED can return both; a watchdog may have
|
||||
# already raised TimeoutError before we cancelled it.
|
||||
for watchdog in watchdogs:
|
||||
with suppress(asyncio.CancelledError, asyncio.TimeoutError):
|
||||
await watchdog
|
||||
return await bg
|
||||
# bg was not in `done`, so every member of `done` is one of our
|
||||
# watchdogs. Only a watchdog's TimeoutError converts to
|
||||
# NodeTimeoutError; any TimeoutError raised by the proc itself
|
||||
# propagates unchanged.
|
||||
for watchdog in done:
|
||||
kind = watchdogs[watchdog]
|
||||
try:
|
||||
await watchdog
|
||||
except asyncio.TimeoutError as exc:
|
||||
elapsed = time.monotonic() - start
|
||||
scope.close()
|
||||
task.writes.clear()
|
||||
bg.cancel()
|
||||
bg.add_done_callback(_drain_cancelled)
|
||||
raise NodeTimeoutError(
|
||||
task.name,
|
||||
elapsed,
|
||||
kind=kind,
|
||||
idle_timeout=idle_timeout_s,
|
||||
run_timeout=run_timeout_s,
|
||||
) from exc
|
||||
raise RuntimeError(
|
||||
f"{kind} timeout watchdog completed without raising TimeoutError"
|
||||
)
|
||||
raise RuntimeError("timeout wait completed without task or watchdog")
|
||||
except asyncio.CancelledError:
|
||||
scope.close()
|
||||
bg.cancel()
|
||||
for watchdog in watchdogs:
|
||||
watchdog.cancel()
|
||||
bg.add_done_callback(_drain_cancelled)
|
||||
raise
|
||||
finally:
|
||||
scope.close()
|
||||
for watchdog in watchdogs:
|
||||
watchdog.cancel()
|
||||
|
||||
|
||||
def _ensure_execution_info(
|
||||
runtime: Runtime, config: RunnableConfig, task: PregelExecutableTask
|
||||
) -> Runtime:
|
||||
@@ -90,6 +545,10 @@ def run_with_retry(
|
||||
) -> None:
|
||||
"""Run a task with retries."""
|
||||
retry_policy = task.retry_policy or retry_policy
|
||||
if task.timeout is not None:
|
||||
# `validate_timeout_supported` catches sync nodes at compile time;
|
||||
# this is a runtime safety net for paths that may bypass that validation.
|
||||
raise sync_timeout_unsupported(task.name)
|
||||
attempts = 0
|
||||
node_first_attempt_time = time.time()
|
||||
config = task.config
|
||||
@@ -195,6 +654,9 @@ async def arun_with_retry(
|
||||
) -> None:
|
||||
"""Run a task asynchronously with retries."""
|
||||
retry_policy = task.retry_policy or retry_policy
|
||||
resolved_timeout = (
|
||||
_resolve_timeout(task.timeout) if task.timeout is not None else None
|
||||
)
|
||||
attempts = 0
|
||||
node_first_attempt_time = time.time()
|
||||
config = task.config
|
||||
@@ -229,35 +691,53 @@ async def arun_with_retry(
|
||||
)
|
||||
},
|
||||
)
|
||||
attempt_ctx = (
|
||||
_start_timed_attempt(task, config, resolved_timeout)
|
||||
if resolved_timeout is not None
|
||||
else None
|
||||
)
|
||||
try:
|
||||
# clear any writes from previous attempts
|
||||
task.writes.clear()
|
||||
# run the task
|
||||
if resolved_timeout is None:
|
||||
if stream:
|
||||
async for _ in task.proc.astream(task.input, config):
|
||||
pass
|
||||
break
|
||||
return await task.proc.ainvoke(task.input, config)
|
||||
result = await _arun_with_timeout(
|
||||
task, config, resolved_timeout, attempt_ctx, stream=stream
|
||||
)
|
||||
_finish_timed_attempt(config, attempt_ctx)
|
||||
if stream:
|
||||
async for _ in task.proc.astream(task.input, config):
|
||||
pass
|
||||
# if successful, end
|
||||
break
|
||||
else:
|
||||
return await task.proc.ainvoke(task.input, config)
|
||||
return result
|
||||
except ParentCommand as exc:
|
||||
ns: str = config[CONF][CONFIG_KEY_CHECKPOINT_NS]
|
||||
cmd = exc.args[0]
|
||||
# strip task_ids from namespace for comparison (ns format: "node1|node2:task_id")
|
||||
if cmd.graph in (ns, recast_checkpoint_ns(ns), task.name):
|
||||
# this command is for the current graph, handle it
|
||||
for w in task.writers:
|
||||
w.invoke(cmd, config)
|
||||
try:
|
||||
# this command is for the current graph, handle it
|
||||
for w in task.writers:
|
||||
w.invoke(cmd, config)
|
||||
except Exception as writer_exc:
|
||||
_finish_timed_attempt(config, attempt_ctx, writer_exc)
|
||||
raise
|
||||
_finish_timed_attempt(config, attempt_ctx)
|
||||
break
|
||||
elif cmd.graph == Command.PARENT:
|
||||
# this command is for the parent graph, assign it to the parent.
|
||||
exc.args = (replace(cmd, graph=_checkpoint_ns_for_parent_command(ns)),)
|
||||
# bubble up
|
||||
_finish_timed_attempt(config, attempt_ctx)
|
||||
# bubble up the exception to the parent graph
|
||||
raise
|
||||
except GraphBubbleUp:
|
||||
# if interrupted, end
|
||||
_finish_timed_attempt(config, attempt_ctx)
|
||||
raise
|
||||
except Exception as exc:
|
||||
_finish_timed_attempt(config, attempt_ctx, exc)
|
||||
if SUPPORTS_EXC_NOTES:
|
||||
exc.add_note(f"During task with name '{task.name}' and id '{task.id}'")
|
||||
if not retry_policy:
|
||||
|
||||
@@ -10,8 +10,10 @@ from collections.abc import (
|
||||
AsyncIterator,
|
||||
Awaitable,
|
||||
Callable,
|
||||
Collection,
|
||||
Iterable,
|
||||
Iterator,
|
||||
Mapping,
|
||||
Sequence,
|
||||
)
|
||||
from functools import partial
|
||||
@@ -46,6 +48,7 @@ from langgraph.types import (
|
||||
CachePolicy,
|
||||
PregelExecutableTask,
|
||||
RetryPolicy,
|
||||
TimeoutPolicy,
|
||||
)
|
||||
|
||||
F = TypeVar("F", concurrent.futures.Future, asyncio.Future)
|
||||
@@ -71,6 +74,10 @@ SKIP_RERAISE_SET: weakref.WeakSet[concurrent.futures.Future | asyncio.Future] =
|
||||
class FuturesDict(Generic[F, E], dict[F, PregelExecutableTask | None]):
|
||||
event: E
|
||||
callback: weakref.ref[Callable[[PregelExecutableTask, BaseException | None], None]]
|
||||
# Stop condition is injected by PregelRunner instead of hard-coded here.
|
||||
# This lets the runner treat graph-error-handled exceptions as non-fatal
|
||||
# so `on_done` does not trigger an early stop for those futures.
|
||||
should_stop: Callable[[set[F]], bool]
|
||||
counter: int
|
||||
done: set[F]
|
||||
lock: threading.Lock
|
||||
@@ -81,6 +88,7 @@ class FuturesDict(Generic[F, E], dict[F, PregelExecutableTask | None]):
|
||||
callback: weakref.ref[
|
||||
Callable[[PregelExecutableTask, BaseException | None], None]
|
||||
],
|
||||
should_stop: Callable[[set[F]], bool],
|
||||
future_type: type[F],
|
||||
# used for generic typing, newer py supports FutureDict[...](...)
|
||||
) -> None:
|
||||
@@ -88,6 +96,7 @@ class FuturesDict(Generic[F, E], dict[F, PregelExecutableTask | None]):
|
||||
self.lock = threading.Lock()
|
||||
self.event = event
|
||||
self.callback = callback
|
||||
self.should_stop = should_stop
|
||||
self.counter = 0
|
||||
self.done: set[F] = set()
|
||||
|
||||
@@ -108,6 +117,7 @@ class FuturesDict(Generic[F, E], dict[F, PregelExecutableTask | None]):
|
||||
task: PregelExecutableTask,
|
||||
fut: F,
|
||||
) -> None:
|
||||
# Called automatically by future.add_done_callback registered in __setitem__.
|
||||
try:
|
||||
if cb := self.callback():
|
||||
cb(task, _exception(fut))
|
||||
@@ -115,7 +125,9 @@ class FuturesDict(Generic[F, E], dict[F, PregelExecutableTask | None]):
|
||||
with self.lock:
|
||||
self.done.add(fut)
|
||||
self.counter -= 1
|
||||
if self.counter == 0 or _should_stop_others(self.done):
|
||||
# Wake waiter when all tracked futures are done, or when runner-level
|
||||
# stop condition is met (for example, a non-handled fatal exception).
|
||||
if self.counter == 0 or self.should_stop(self.done):
|
||||
self.event.set()
|
||||
|
||||
|
||||
@@ -131,11 +143,34 @@ class PregelRunner:
|
||||
put_writes: weakref.ref[Callable[[str, Sequence[tuple[str, Any]]], None]],
|
||||
use_astream: bool = False,
|
||||
node_finished: Callable[[str], None] | None = None,
|
||||
node_error_handler_map: Mapping[str, str] | None = None,
|
||||
schedule_error_handler: Callable[
|
||||
[PregelExecutableTask, BaseException], PregelExecutableTask | None
|
||||
]
|
||||
| None = None,
|
||||
aschedule_error_handler: Callable[
|
||||
[PregelExecutableTask, BaseException],
|
||||
Awaitable[PregelExecutableTask | None],
|
||||
]
|
||||
| None = None,
|
||||
) -> None:
|
||||
self.submit = submit
|
||||
self.put_writes = put_writes
|
||||
self.use_astream = use_astream
|
||||
self.node_finished = node_finished
|
||||
self.node_error_handler_map = dict(node_error_handler_map or {})
|
||||
self.error_handler_nodes = set(self.node_error_handler_map.values())
|
||||
self.schedule_error_handler = schedule_error_handler
|
||||
self.aschedule_error_handler = aschedule_error_handler
|
||||
# Exception object ids that are already routed to graph-level error handler.
|
||||
# These ids are consulted by stop/panic checks to avoid re-raising handled
|
||||
# exceptions via the normal fatal path in the same run.
|
||||
self._handled_exception_ids: set[int] = set()
|
||||
|
||||
def _should_route_to_error_handler(self, task: PregelExecutableTask) -> bool:
|
||||
if task.name in self.error_handler_nodes:
|
||||
return False
|
||||
return task.name in self.node_error_handler_map
|
||||
|
||||
def tick(
|
||||
self,
|
||||
@@ -154,6 +189,9 @@ class PregelRunner:
|
||||
futures = FuturesDict(
|
||||
callback=weakref.WeakMethod(self.commit),
|
||||
event=threading.Event(),
|
||||
should_stop=partial(
|
||||
_should_stop_others, handled_exception_ids=self._handled_exception_ids
|
||||
),
|
||||
future_type=concurrent.futures.Future,
|
||||
)
|
||||
# give control back to the caller
|
||||
@@ -163,6 +201,7 @@ class PregelRunner:
|
||||
return
|
||||
elif len(tasks) == 1 and timeout is None and get_waiter is None:
|
||||
t = tasks[0]
|
||||
scheduled_error_handler = False
|
||||
try:
|
||||
run_with_retry(
|
||||
t,
|
||||
@@ -181,12 +220,23 @@ class PregelRunner:
|
||||
self.commit(t, None)
|
||||
except Exception as exc:
|
||||
self.commit(t, exc)
|
||||
if (
|
||||
not isinstance(exc, GraphBubbleUp)
|
||||
and self._should_route_to_error_handler(t)
|
||||
and self.schedule_error_handler is not None
|
||||
):
|
||||
self._handled_exception_ids.add(id(exc))
|
||||
if handler_task := self.schedule_error_handler(t, exc):
|
||||
tasks = (handler_task,)
|
||||
scheduled_error_handler = True
|
||||
# Continue to the regular scheduling path for handler execution.
|
||||
if reraise and futures:
|
||||
# will be re-raised after futures are done
|
||||
fut: concurrent.futures.Future = concurrent.futures.Future()
|
||||
fut.set_exception(exc)
|
||||
futures.done.add(fut)
|
||||
elif reraise:
|
||||
if id(exc) not in self._handled_exception_ids:
|
||||
# will be re-raised after futures are done
|
||||
fut: concurrent.futures.Future = concurrent.futures.Future()
|
||||
fut.set_exception(exc)
|
||||
futures.done.add(fut)
|
||||
elif reraise and id(exc) not in self._handled_exception_ids:
|
||||
if tb := exc.__traceback__:
|
||||
while tb.tb_next is not None and any(
|
||||
tb.tb_frame.f_code.co_filename.endswith(name)
|
||||
@@ -195,10 +245,12 @@ class PregelRunner:
|
||||
tb = tb.tb_next
|
||||
exc.__traceback__ = tb
|
||||
raise
|
||||
if not futures: # maybe `t` scheduled another task
|
||||
if not futures and not scheduled_error_handler:
|
||||
# maybe `t` scheduled another task
|
||||
return
|
||||
else:
|
||||
tasks = () # don't reschedule this task
|
||||
if not scheduled_error_handler:
|
||||
tasks = () # don't reschedule this task
|
||||
# add waiter task if requested
|
||||
if get_waiter is not None:
|
||||
futures[get_waiter()] = None
|
||||
@@ -225,6 +277,7 @@ class PregelRunner:
|
||||
# each task is independent from all other concurrent tasks
|
||||
# yield updates/debug output as each task finishes
|
||||
end_time = timeout + time.monotonic() if timeout else None
|
||||
handled_futures: set[concurrent.futures.Future[Any]] = set()
|
||||
while len(futures) > (1 if get_waiter is not None else 0):
|
||||
done, inflight = concurrent.futures.wait(
|
||||
futures,
|
||||
@@ -233,17 +286,49 @@ class PregelRunner:
|
||||
)
|
||||
if not done:
|
||||
break # timed out
|
||||
done_for_stop: set[concurrent.futures.Future[Any]] = set()
|
||||
for fut in done:
|
||||
task = futures.pop(fut)
|
||||
if task is None:
|
||||
# waiter task finished, schedule another
|
||||
if inflight and get_waiter is not None:
|
||||
futures[get_waiter()] = None
|
||||
elif (
|
||||
(task_exc := _exception(fut))
|
||||
and self._should_route_to_error_handler(task)
|
||||
and not isinstance(task_exc, GraphBubbleUp)
|
||||
):
|
||||
self._handled_exception_ids.add(id(task_exc))
|
||||
SKIP_RERAISE_SET.add(fut)
|
||||
handled_futures.add(fut)
|
||||
if self.schedule_error_handler is not None:
|
||||
if handler_task := self.schedule_error_handler(task, task_exc):
|
||||
handler_fut = self.submit()( # type: ignore[misc]
|
||||
run_with_retry,
|
||||
handler_task,
|
||||
retry_policy,
|
||||
configurable={
|
||||
CONFIG_KEY_CALL: partial(
|
||||
_call,
|
||||
weakref.ref(handler_task),
|
||||
retry_policy=retry_policy,
|
||||
futures=weakref.ref(futures),
|
||||
schedule_task=schedule_task,
|
||||
submit=self.submit,
|
||||
),
|
||||
},
|
||||
__reraise_on_exit__=reraise,
|
||||
)
|
||||
futures[handler_fut] = handler_task
|
||||
else:
|
||||
done_for_stop.add(fut)
|
||||
else:
|
||||
# remove references to loop vars
|
||||
del fut, task
|
||||
# maybe stop other tasks
|
||||
if _should_stop_others(done):
|
||||
if _should_stop_others(
|
||||
done_for_stop, handled_exception_ids=self._handled_exception_ids
|
||||
):
|
||||
break
|
||||
# give control back to the caller
|
||||
yield
|
||||
@@ -258,6 +343,8 @@ class PregelRunner:
|
||||
_panic_or_proceed(
|
||||
futures.done.union(f for f, t in futures.items() if t is not None),
|
||||
panic=reraise,
|
||||
handled_exception_ids=self._handled_exception_ids,
|
||||
handled_futures=handled_futures,
|
||||
)
|
||||
except Exception as exc:
|
||||
if tb := exc.__traceback__:
|
||||
@@ -291,6 +378,9 @@ class PregelRunner:
|
||||
futures = FuturesDict(
|
||||
callback=weakref.WeakMethod(self.commit),
|
||||
event=asyncio.Event(),
|
||||
should_stop=partial(
|
||||
_should_stop_others, handled_exception_ids=self._handled_exception_ids
|
||||
),
|
||||
future_type=asyncio.Future,
|
||||
)
|
||||
# give control back to the caller
|
||||
@@ -300,6 +390,7 @@ class PregelRunner:
|
||||
return
|
||||
elif len(tasks) == 1 and get_waiter is None and timeout is None:
|
||||
t = tasks[0]
|
||||
scheduled_error_handler = False
|
||||
try:
|
||||
await arun_with_retry(
|
||||
t,
|
||||
@@ -321,12 +412,22 @@ class PregelRunner:
|
||||
self.commit(t, None)
|
||||
except Exception as exc:
|
||||
self.commit(t, exc)
|
||||
if (
|
||||
not isinstance(exc, GraphBubbleUp)
|
||||
and self._should_route_to_error_handler(t)
|
||||
and self.aschedule_error_handler is not None
|
||||
):
|
||||
self._handled_exception_ids.add(id(exc))
|
||||
if handler_task := await self.aschedule_error_handler(t, exc):
|
||||
tasks = (handler_task,)
|
||||
scheduled_error_handler = True
|
||||
if reraise and futures:
|
||||
# will be re-raised after futures are done
|
||||
fut: asyncio.Future = loop.create_future()
|
||||
fut.set_exception(exc)
|
||||
futures.done.add(fut)
|
||||
elif reraise:
|
||||
if id(exc) not in self._handled_exception_ids:
|
||||
# will be re-raised after futures are done
|
||||
fut: asyncio.Future = loop.create_future()
|
||||
fut.set_exception(exc)
|
||||
futures.done.add(fut)
|
||||
elif reraise and id(exc) not in self._handled_exception_ids:
|
||||
if tb := exc.__traceback__:
|
||||
while tb.tb_next is not None and any(
|
||||
tb.tb_frame.f_code.co_filename.endswith(name)
|
||||
@@ -335,10 +436,12 @@ class PregelRunner:
|
||||
tb = tb.tb_next
|
||||
exc.__traceback__ = tb
|
||||
raise
|
||||
if not futures: # maybe `t` scheduled another task
|
||||
if not futures and not scheduled_error_handler:
|
||||
# maybe `t` scheduled another task
|
||||
return
|
||||
else:
|
||||
tasks = () # don't reschedule this task
|
||||
if not scheduled_error_handler:
|
||||
tasks = () # don't reschedule this task
|
||||
# add waiter task if requested
|
||||
if get_waiter is not None:
|
||||
futures[get_waiter()] = None
|
||||
@@ -373,6 +476,7 @@ class PregelRunner:
|
||||
# each task is independent from all other concurrent tasks
|
||||
# yield updates/debug output as each task finishes
|
||||
end_time = timeout + loop.time() if timeout else None
|
||||
handled_futures: set[asyncio.Future[Any]] = set()
|
||||
while len(futures) > (1 if get_waiter is not None else 0):
|
||||
done, inflight = await asyncio.wait(
|
||||
futures,
|
||||
@@ -381,17 +485,59 @@ class PregelRunner:
|
||||
)
|
||||
if not done:
|
||||
break # timed out
|
||||
done_for_stop: set[asyncio.Future[Any]] = set()
|
||||
for fut in done:
|
||||
task = futures.pop(fut)
|
||||
if task is None:
|
||||
# waiter task finished, schedule another
|
||||
if inflight and get_waiter is not None:
|
||||
futures[get_waiter()] = None
|
||||
elif (
|
||||
(task_exc := _exception(fut))
|
||||
and self._should_route_to_error_handler(task)
|
||||
and not isinstance(task_exc, GraphBubbleUp)
|
||||
):
|
||||
self._handled_exception_ids.add(id(task_exc))
|
||||
SKIP_RERAISE_SET.add(fut)
|
||||
handled_futures.add(fut)
|
||||
if self.aschedule_error_handler is not None:
|
||||
if handler_task := await self.aschedule_error_handler(
|
||||
task, task_exc
|
||||
):
|
||||
handler_fut = cast(
|
||||
asyncio.Future,
|
||||
self.submit()( # type: ignore[misc]
|
||||
arun_with_retry,
|
||||
handler_task,
|
||||
retry_policy,
|
||||
stream=self.use_astream,
|
||||
configurable={
|
||||
CONFIG_KEY_CALL: partial(
|
||||
_acall,
|
||||
weakref.ref(handler_task),
|
||||
retry_policy=retry_policy,
|
||||
stream=self.use_astream,
|
||||
futures=weakref.ref(futures),
|
||||
schedule_task=schedule_task,
|
||||
submit=self.submit,
|
||||
loop=loop,
|
||||
),
|
||||
},
|
||||
__name__=handler_task.name,
|
||||
__cancel_on_exit__=True,
|
||||
__reraise_on_exit__=reraise,
|
||||
),
|
||||
)
|
||||
futures[handler_fut] = handler_task
|
||||
else:
|
||||
done_for_stop.add(fut)
|
||||
else:
|
||||
# remove references to loop vars
|
||||
del fut, task
|
||||
# maybe stop other tasks
|
||||
if _should_stop_others(done):
|
||||
if _should_stop_others(
|
||||
done_for_stop, handled_exception_ids=self._handled_exception_ids
|
||||
):
|
||||
break
|
||||
# give control back to the caller
|
||||
yield
|
||||
@@ -411,6 +557,8 @@ class PregelRunner:
|
||||
futures.done.union(f for f, t in futures.items() if t is not None),
|
||||
timeout_exc_cls=asyncio.TimeoutError,
|
||||
panic=reraise,
|
||||
handled_exception_ids=self._handled_exception_ids,
|
||||
handled_futures=handled_futures,
|
||||
)
|
||||
except Exception as exc:
|
||||
if tb := exc.__traceback__:
|
||||
@@ -446,6 +594,11 @@ class PregelRunner:
|
||||
else:
|
||||
# save error to checkpointer
|
||||
task.writes.append((ERROR, exception))
|
||||
if self._should_route_to_error_handler(task) and not isinstance(
|
||||
exception, GraphBubbleUp
|
||||
):
|
||||
# Mark early in commit path; loop-side routing may happen later.
|
||||
self._handled_exception_ids.add(id(exception))
|
||||
self.put_writes()(task.id, task.writes) # type: ignore[misc]
|
||||
else:
|
||||
if self.node_finished and (
|
||||
@@ -461,6 +614,8 @@ class PregelRunner:
|
||||
|
||||
def _should_stop_others(
|
||||
done: set[F],
|
||||
*,
|
||||
handled_exception_ids: set[int] | None = None,
|
||||
) -> bool:
|
||||
"""Check if any task failed, if so, cancel all other tasks.
|
||||
GraphInterrupts are not considered failures."""
|
||||
@@ -468,7 +623,11 @@ def _should_stop_others(
|
||||
if fut.cancelled():
|
||||
continue
|
||||
elif exc := fut.exception():
|
||||
if not isinstance(exc, GraphBubbleUp) and fut not in SKIP_RERAISE_SET:
|
||||
if (
|
||||
id(exc) not in (handled_exception_ids or set())
|
||||
and not isinstance(exc, GraphBubbleUp)
|
||||
and fut not in SKIP_RERAISE_SET
|
||||
):
|
||||
return True
|
||||
|
||||
return False
|
||||
@@ -492,6 +651,9 @@ def _panic_or_proceed(
|
||||
*,
|
||||
timeout_exc_cls: type[Exception] = TimeoutError,
|
||||
panic: bool = True,
|
||||
handled_exception_ids: set[int] | None = None,
|
||||
handled_futures: Collection[concurrent.futures.Future[Any] | asyncio.Future[Any]]
|
||||
| None = None,
|
||||
) -> None:
|
||||
"""Cancel remaining tasks if any failed, re-raise exception if panic is True."""
|
||||
done: set[concurrent.futures.Future[Any] | asyncio.Future[Any]] = set()
|
||||
@@ -508,6 +670,10 @@ def _panic_or_proceed(
|
||||
# if any task failed
|
||||
fut = done.pop()
|
||||
if exc := _exception(fut):
|
||||
if fut in (handled_futures or set()):
|
||||
continue
|
||||
if id(exc) in (handled_exception_ids or set()):
|
||||
continue
|
||||
# cancel all pending tasks
|
||||
while inflight:
|
||||
inflight.pop().cancel()
|
||||
@@ -537,6 +703,7 @@ def _call(
|
||||
*,
|
||||
retry_policy: Sequence[RetryPolicy] | None = None,
|
||||
cache_policy: CachePolicy | None = None,
|
||||
timeout: TimeoutPolicy | None = None,
|
||||
callbacks: Callbacks = None,
|
||||
futures: weakref.ref[FuturesDict],
|
||||
schedule_task: Callable[
|
||||
@@ -560,6 +727,7 @@ def _call(
|
||||
retry_policy=retry_policy,
|
||||
cache_policy=cache_policy,
|
||||
callbacks=callbacks,
|
||||
timeout=timeout,
|
||||
),
|
||||
):
|
||||
if fut := next(
|
||||
@@ -624,6 +792,7 @@ def _acall(
|
||||
*,
|
||||
retry_policy: Sequence[RetryPolicy] | None = None,
|
||||
cache_policy: CachePolicy | None = None,
|
||||
timeout: TimeoutPolicy | None = None,
|
||||
callbacks: Callbacks = None,
|
||||
# injected dependencies
|
||||
futures: weakref.ref[FuturesDict],
|
||||
@@ -657,6 +826,7 @@ def _acall(
|
||||
input,
|
||||
retry_policy=retry_policy,
|
||||
cache_policy=cache_policy,
|
||||
timeout=timeout,
|
||||
callbacks=callbacks,
|
||||
futures=futures,
|
||||
schedule_task=schedule_task,
|
||||
@@ -678,6 +848,7 @@ async def _acall_impl(
|
||||
*,
|
||||
retry_policy: Sequence[RetryPolicy] | None = None,
|
||||
cache_policy: CachePolicy | None = None,
|
||||
timeout: TimeoutPolicy | None = None,
|
||||
callbacks: Callbacks = None,
|
||||
# injected dependencies
|
||||
futures: weakref.ref[FuturesDict[asyncio.Future, asyncio.Event]],
|
||||
@@ -703,6 +874,7 @@ async def _acall_impl(
|
||||
retry_policy=retry_policy,
|
||||
cache_policy=cache_policy,
|
||||
callbacks=callbacks,
|
||||
timeout=timeout,
|
||||
),
|
||||
):
|
||||
if fut := next(
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator, Callable, Iterator
|
||||
from contextvars import ContextVar, Token
|
||||
from typing import Any, TypeVar, cast
|
||||
from uuid import UUID
|
||||
|
||||
from langchain_core.callbacks import BaseCallbackHandler
|
||||
|
||||
from langgraph._internal._constants import NS_SEP
|
||||
from langgraph.constants import TAG_NOSTREAM
|
||||
from langgraph.pregel.protocol import StreamChunk
|
||||
|
||||
try:
|
||||
from langchain_core.tracers._streaming import _StreamingCallbackHandler
|
||||
except ImportError:
|
||||
_StreamingCallbackHandler = object # type: ignore[assignment,misc]
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
ToolCallWriter = Callable[[Any], None]
|
||||
"""A closure bound to a single tool call that emits `tool-output-delta` events."""
|
||||
|
||||
_tool_call_writer: ContextVar[ToolCallWriter | None] = ContextVar(
|
||||
"langgraph_tool_call_writer", default=None
|
||||
)
|
||||
"""ContextVar holding the writer for the currently-executing tool call.
|
||||
|
||||
Set by `StreamToolCallHandler.on_tool_start` and reset on end/error.
|
||||
Read by `ToolRuntime.emit_output_delta` (in `langgraph.prebuilt`).
|
||||
"""
|
||||
|
||||
|
||||
class StreamToolCallHandler(BaseCallbackHandler, _StreamingCallbackHandler):
|
||||
"""Callback handler that emits tool-call lifecycle events on the stream.
|
||||
|
||||
Fires on LangChain's `on_tool_*` callbacks and pushes to the `tools`
|
||||
stream mode. Emits `tool-started` / `tool-output-delta` /
|
||||
`tool-finished` / `tool-error` payloads keyed by `tool_call_id`.
|
||||
|
||||
While a tool is executing, this handler sets `_tool_call_writer` to a
|
||||
closure bound to that call's namespace and `tool_call_id`.
|
||||
`ToolRuntime.emit_output_delta` reads that ContextVar so tool bodies
|
||||
can stream partial output without threading the writer through their
|
||||
own signature.
|
||||
|
||||
Attached by `Pregel.stream` / `astream` when `"tools"` is in
|
||||
`stream_modes`. `run_inline = True` keeps event ordering
|
||||
deterministic.
|
||||
"""
|
||||
|
||||
run_inline = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
stream: Callable[[StreamChunk], None],
|
||||
subgraphs: bool,
|
||||
*,
|
||||
parent_ns: tuple[str, ...] | None = None,
|
||||
) -> None:
|
||||
"""Configure the handler to stream tool-call events.
|
||||
|
||||
Args:
|
||||
stream: Callable that accepts a `StreamChunk` tuple
|
||||
`(namespace, mode, payload)` and enqueues it.
|
||||
subgraphs: Whether to emit events from tools called inside
|
||||
nested subgraphs. When False, only tools at the
|
||||
handler's own scope (`parent_ns`) emit.
|
||||
parent_ns: Namespace where the handler was attached.
|
||||
Mirrors the `StreamMessagesHandler` escape hatch:
|
||||
tools whose containing namespace equals `parent_ns`
|
||||
still emit even with `subgraphs=False`, so a node that
|
||||
explicitly streams a subgraph with `stream_mode="tools"`
|
||||
sees its own tools.
|
||||
"""
|
||||
self.stream = stream
|
||||
self.subgraphs = subgraphs
|
||||
self.parent_ns = parent_ns
|
||||
# run_id → (namespace, tool_call_id, ContextVar token)
|
||||
# `on_tool_end` does not receive `tool_call_id` in kwargs, so
|
||||
# we correlate by `run_id` which is present on every callback.
|
||||
self._run_to_call: dict[
|
||||
UUID, tuple[tuple[str, ...], str, Token[ToolCallWriter | None]]
|
||||
] = {}
|
||||
|
||||
def _ns_for_emit(
|
||||
self,
|
||||
metadata: dict[str, Any] | None,
|
||||
tags: list[str] | None,
|
||||
) -> tuple[str, ...] | None:
|
||||
"""Resolve the namespace this tool call should emit at, or `None` to skip.
|
||||
|
||||
Mirrors `StreamMessagesHandler.on_chat_model_start`'s namespace
|
||||
derivation: parses `langgraph_checkpoint_ns` (which ends with
|
||||
the `node_name:task_id` of the calling node), drops that
|
||||
trailing segment, and returns the containing subgraph's own
|
||||
namespace. Returns `None` when the call should be silently
|
||||
suppressed:
|
||||
|
||||
- `metadata` is missing — handler is attached to a context
|
||||
without Pregel routing info.
|
||||
- `TAG_NOSTREAM` is in `tags` — caller explicitly opted out.
|
||||
- Tool runs in a subgraph (`len(ns) > 0`) and the handler was
|
||||
attached with `subgraphs=False` and a different `parent_ns`
|
||||
than the call's containing subgraph.
|
||||
"""
|
||||
if not metadata:
|
||||
return None
|
||||
if tags and TAG_NOSTREAM in tags:
|
||||
return None
|
||||
nskey = metadata.get("langgraph_checkpoint_ns")
|
||||
if not nskey:
|
||||
ns: tuple[str, ...] = ()
|
||||
else:
|
||||
ns = tuple(cast(str, nskey).split(NS_SEP))[:-1]
|
||||
if not self.subgraphs and len(ns) > 0 and ns != self.parent_ns:
|
||||
return None
|
||||
return ns
|
||||
|
||||
def _start(
|
||||
self,
|
||||
serialized: dict[str, Any] | None,
|
||||
input_str: str,
|
||||
*,
|
||||
run_id: UUID,
|
||||
metadata: dict[str, Any] | None,
|
||||
tags: list[str] | None,
|
||||
inputs: dict[str, Any] | None,
|
||||
kwargs: dict[str, Any],
|
||||
) -> None:
|
||||
ns = self._ns_for_emit(metadata, tags)
|
||||
if ns is None:
|
||||
return
|
||||
tool_call_id = cast("str | None", kwargs.get("tool_call_id")) or str(run_id)
|
||||
tool_name = (
|
||||
(serialized or {}).get("name")
|
||||
or cast("str | None", kwargs.get("name"))
|
||||
or ""
|
||||
)
|
||||
|
||||
def writer(delta: Any) -> None:
|
||||
self.stream(
|
||||
(
|
||||
ns,
|
||||
"tools",
|
||||
{
|
||||
"event": "tool-output-delta",
|
||||
"tool_call_id": tool_call_id,
|
||||
"delta": delta,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
token = _tool_call_writer.set(writer)
|
||||
self._run_to_call[run_id] = (ns, tool_call_id, token)
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"event": "tool-started",
|
||||
"tool_call_id": tool_call_id,
|
||||
"tool_name": tool_name,
|
||||
}
|
||||
if inputs is not None:
|
||||
payload["input"] = inputs
|
||||
self.stream((ns, "tools", payload))
|
||||
|
||||
def _end(self, output: Any, *, run_id: UUID) -> None:
|
||||
info = self._run_to_call.pop(run_id, None)
|
||||
if info is None:
|
||||
return
|
||||
ns, tool_call_id, token = info
|
||||
self._reset_writer(token)
|
||||
self.stream(
|
||||
(
|
||||
ns,
|
||||
"tools",
|
||||
{
|
||||
"event": "tool-finished",
|
||||
"tool_call_id": tool_call_id,
|
||||
"output": output,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
def _error(self, error: BaseException, *, run_id: UUID) -> None:
|
||||
info = self._run_to_call.pop(run_id, None)
|
||||
if info is None:
|
||||
return
|
||||
ns, tool_call_id, token = info
|
||||
self._reset_writer(token)
|
||||
self.stream(
|
||||
(
|
||||
ns,
|
||||
"tools",
|
||||
{
|
||||
"event": "tool-error",
|
||||
"tool_call_id": tool_call_id,
|
||||
"message": str(error),
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
def tap_output_aiter(
|
||||
self, run_id: UUID, output: AsyncIterator[T]
|
||||
) -> AsyncIterator[T]:
|
||||
"""Pass-through — required by the `_StreamingCallbackHandler` protocol."""
|
||||
return output
|
||||
|
||||
def tap_output_iter(self, run_id: UUID, output: Iterator[T]) -> Iterator[T]:
|
||||
"""Pass-through — sync counterpart to `tap_output_aiter`."""
|
||||
return output
|
||||
|
||||
@staticmethod
|
||||
def _reset_writer(token: Token[ToolCallWriter | None]) -> None:
|
||||
# Token is invalid if `on_tool_end` runs in a different context
|
||||
# than `on_tool_start` (e.g. langchain may hand off to a thread
|
||||
# worker without copying the context). Swallow that case; the
|
||||
# ContextVar lifetime is bounded by the enclosing task anyway.
|
||||
try:
|
||||
_tool_call_writer.reset(token)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Sync callbacks
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def on_tool_start(
|
||||
self,
|
||||
serialized: dict[str, Any],
|
||||
input_str: str,
|
||||
*,
|
||||
run_id: UUID,
|
||||
parent_run_id: UUID | None = None,
|
||||
tags: list[str] | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
inputs: dict[str, Any] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
self._start(
|
||||
serialized,
|
||||
input_str,
|
||||
run_id=run_id,
|
||||
metadata=metadata,
|
||||
tags=tags,
|
||||
inputs=inputs,
|
||||
kwargs=kwargs,
|
||||
)
|
||||
|
||||
def on_tool_end(
|
||||
self,
|
||||
output: Any,
|
||||
*,
|
||||
run_id: UUID,
|
||||
parent_run_id: UUID | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
self._end(output, run_id=run_id)
|
||||
|
||||
def on_tool_error(
|
||||
self,
|
||||
error: BaseException,
|
||||
*,
|
||||
run_id: UUID,
|
||||
parent_run_id: UUID | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
self._error(error, run_id=run_id)
|
||||
@@ -4,16 +4,27 @@ import ast
|
||||
import inspect
|
||||
import re
|
||||
import textwrap
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Sequence
|
||||
from functools import partial
|
||||
from typing import Any
|
||||
|
||||
from langchain_core.runnables import Runnable, RunnableLambda, RunnableSequence
|
||||
from langchain_core.runnables import (
|
||||
Runnable,
|
||||
RunnableLambda,
|
||||
RunnableParallel,
|
||||
RunnableSequence,
|
||||
)
|
||||
from langchain_core.runnables.base import RunnableBindingBase
|
||||
from langchain_core.runnables.config import run_in_executor
|
||||
from langgraph.checkpoint.base import ChannelVersions
|
||||
from typing_extensions import override
|
||||
|
||||
from langgraph._internal._runnable import RunnableCallable, RunnableSeq
|
||||
from langgraph._internal._timeout import sync_timeout_unsupported
|
||||
from langgraph.pregel.protocol import PregelProtocol
|
||||
|
||||
_SEQUENCE_TYPES = (RunnableSeq, RunnableSequence)
|
||||
|
||||
|
||||
def get_new_channel_versions(
|
||||
previous_versions: ChannelVersions, current_versions: ChannelVersions
|
||||
@@ -64,6 +75,68 @@ def find_subgraph_pregel(candidate: Runnable) -> PregelProtocol | None:
|
||||
return None
|
||||
|
||||
|
||||
def _sequence_steps(runnable: Runnable) -> Sequence[Runnable] | None:
|
||||
if isinstance(runnable, _SEQUENCE_TYPES):
|
||||
return runnable.steps
|
||||
return None
|
||||
|
||||
|
||||
def _parallel_steps(runnable: Runnable) -> Sequence[Runnable] | None:
|
||||
if isinstance(runnable, RunnableParallel):
|
||||
return tuple(runnable.steps__.values())
|
||||
return None
|
||||
|
||||
|
||||
def _has_method_override(runnable: Runnable, method_name: str) -> bool:
|
||||
method = getattr(type(runnable), method_name, None)
|
||||
return method is not None and method is not getattr(Runnable, method_name)
|
||||
|
||||
|
||||
def _is_executor_backed_afunc(afunc: Callable[..., Any] | None) -> bool:
|
||||
return isinstance(afunc, partial) and afunc.func is run_in_executor
|
||||
|
||||
|
||||
def _has_native_async(runnable: Runnable) -> bool:
|
||||
if isinstance(runnable, RunnableCallable):
|
||||
return runnable.afunc is not None and not _is_executor_backed_afunc(
|
||||
runnable.afunc
|
||||
)
|
||||
if isinstance(runnable, RunnableLambda):
|
||||
return bool(getattr(runnable, "afunc", False))
|
||||
return _has_method_override(runnable, "ainvoke")
|
||||
|
||||
|
||||
def _runnable_has_native_async(runnable: Runnable) -> bool:
|
||||
"""Return whether a runnable can be idle-timed without known sync code.
|
||||
|
||||
For custom runnable subclasses, an `ainvoke` override is treated as the
|
||||
async contract. We do not introspect whether that implementation delegates
|
||||
to blocking work internally — e.g. a subclass whose `ainvoke` calls
|
||||
`asyncio.to_thread(self.invoke, ...)` will pass this check but the wrapped
|
||||
sync work is still uncancellable. Idle-timeout enforcement on such a
|
||||
runnable will fire `NodeTimeoutError` correctly, but the background thread
|
||||
will keep running until its sync work returns.
|
||||
"""
|
||||
|
||||
while isinstance(runnable, RunnableBindingBase):
|
||||
runnable = runnable.bound
|
||||
steps = _sequence_steps(runnable)
|
||||
if steps is None:
|
||||
steps = _parallel_steps(runnable)
|
||||
if steps is not None:
|
||||
return all(_runnable_has_native_async(step) for step in steps)
|
||||
# Raw callables and the common composition wrappers created by graph
|
||||
# builders fall through here. We do not exhaustively unwrap every Runnable
|
||||
# wrapper — wrappers that provide `ainvoke` are treated as owning the async
|
||||
# contract.
|
||||
return _has_native_async(runnable)
|
||||
|
||||
|
||||
def validate_timeout_supported(runnable: Runnable, *, name: str) -> None:
|
||||
if not _runnable_has_native_async(runnable):
|
||||
raise sync_timeout_unsupported(name)
|
||||
|
||||
|
||||
def get_function_nonlocals(func: Callable) -> list[Any]:
|
||||
"""Get the nonlocal variables accessed by a function.
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field, replace
|
||||
from typing import Any, Generic, cast
|
||||
|
||||
@@ -15,6 +16,7 @@ from langgraph.typing import ContextT
|
||||
__all__ = (
|
||||
"BaseUser",
|
||||
"ExecutionInfo",
|
||||
"RunControl",
|
||||
"Runtime",
|
||||
"ServerInfo",
|
||||
"get_runtime",
|
||||
@@ -74,16 +76,49 @@ class ServerInfo:
|
||||
"""
|
||||
|
||||
|
||||
class RunControl:
|
||||
"""Run-scoped control surface for cooperative draining.
|
||||
|
||||
Intended for a single graph run. Create a fresh `RunControl` per run;
|
||||
reusing a control after `request_drain()` leaves it drained.
|
||||
|
||||
Safe to call from any thread: the drain request is represented by a
|
||||
single attribute write, so no lock is needed for this signal.
|
||||
If more mutable state is added here, add synchronization.
|
||||
"""
|
||||
|
||||
__slots__ = ("_drain_reason",)
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._drain_reason: str | None = None
|
||||
|
||||
def request_drain(self, reason: str = "shutdown") -> None:
|
||||
self._drain_reason = reason
|
||||
|
||||
@property
|
||||
def drain_requested(self) -> bool:
|
||||
return self._drain_reason is not None
|
||||
|
||||
@property
|
||||
def drain_reason(self) -> str | None:
|
||||
return self._drain_reason
|
||||
|
||||
|
||||
def _no_op_stream_writer(_: Any) -> None: ...
|
||||
|
||||
|
||||
def _no_op_heartbeat() -> None: ...
|
||||
|
||||
|
||||
class _RuntimeOverrides(TypedDict, Generic[ContextT], total=False):
|
||||
context: ContextT
|
||||
store: BaseStore | None
|
||||
stream_writer: StreamWriter
|
||||
heartbeat: Callable[[], None]
|
||||
previous: Any
|
||||
execution_info: ExecutionInfo
|
||||
server_info: ServerInfo | None
|
||||
control: RunControl | None
|
||||
|
||||
|
||||
@dataclass(**_DC_KWARGS)
|
||||
@@ -162,7 +197,7 @@ class Runtime(Generic[ContextT]):
|
||||
|
||||
context: ContextT = field(default=None) # type: ignore[assignment]
|
||||
"""Static context for the graph run, like `user_id`, `db_conn`, etc.
|
||||
|
||||
|
||||
Can also be thought of as 'run dependencies'."""
|
||||
|
||||
store: BaseStore | None = field(default=None)
|
||||
@@ -171,9 +206,19 @@ class Runtime(Generic[ContextT]):
|
||||
stream_writer: StreamWriter = field(default=_no_op_stream_writer)
|
||||
"""Function that writes to the custom stream."""
|
||||
|
||||
heartbeat: Callable[[], None] = field(default=_no_op_heartbeat)
|
||||
"""Record progress for the current node's `idle_timeout`.
|
||||
|
||||
Call this from inside long-running work that does not naturally emit
|
||||
writes, stream chunks, child tasks, or LangChain callback events, to
|
||||
prevent the node from being treated as idle. It is also the only
|
||||
progress signal honored under `TimeoutPolicy(refresh_on="heartbeat")`.
|
||||
Outside an idle-timed attempt this is a no-op.
|
||||
"""
|
||||
|
||||
previous: Any = field(default=None)
|
||||
"""The previous return value for the given thread.
|
||||
|
||||
|
||||
Only available with the functional API when a checkpointer is provided.
|
||||
"""
|
||||
|
||||
@@ -185,6 +230,13 @@ class Runtime(Generic[ContextT]):
|
||||
server_info: ServerInfo | None = field(default=None)
|
||||
"""Metadata injected by LangGraph Server. None when running open-source LangGraph without LangSmith deployments."""
|
||||
|
||||
control: RunControl | None = field(default=None)
|
||||
"""Run-scoped control plane for cooperative draining.
|
||||
|
||||
Populated automatically during graph runs. None outside an active
|
||||
graph runtime.
|
||||
"""
|
||||
|
||||
def merge(self, other: Runtime[ContextT]) -> Runtime[ContextT]:
|
||||
"""Merge two runtimes together.
|
||||
|
||||
@@ -196,9 +248,13 @@ class Runtime(Generic[ContextT]):
|
||||
stream_writer=other.stream_writer
|
||||
if other.stream_writer is not _no_op_stream_writer
|
||||
else self.stream_writer,
|
||||
heartbeat=other.heartbeat
|
||||
if other.heartbeat is not _no_op_heartbeat
|
||||
else self.heartbeat,
|
||||
previous=self.previous if other.previous is None else other.previous,
|
||||
execution_info=other.execution_info or self.execution_info,
|
||||
server_info=other.server_info or self.server_info,
|
||||
control=other.control or self.control,
|
||||
)
|
||||
|
||||
def override(
|
||||
@@ -217,13 +273,23 @@ class Runtime(Generic[ContextT]):
|
||||
execution_info=self.execution_info.patch(**overrides),
|
||||
)
|
||||
|
||||
@property
|
||||
def drain_requested(self) -> bool:
|
||||
return self.control.drain_requested if self.control is not None else False
|
||||
|
||||
@property
|
||||
def drain_reason(self) -> str | None:
|
||||
return self.control.drain_reason if self.control is not None else None
|
||||
|
||||
|
||||
DEFAULT_RUNTIME = Runtime(
|
||||
context=None,
|
||||
store=None,
|
||||
stream_writer=_no_op_stream_writer,
|
||||
heartbeat=_no_op_heartbeat,
|
||||
previous=None,
|
||||
execution_info=None,
|
||||
control=None,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Streaming infrastructure for LangGraph.
|
||||
|
||||
Compile a graph with `transformers=[...]` and call `graph.stream_events(version="v3")` /
|
||||
`graph.astream_events(version="v3")` to drive a transformer pipeline that projects the
|
||||
graph's raw events into ergonomic per-channel streams.
|
||||
"""
|
||||
|
||||
from langgraph.stream._types import ProtocolEvent, StreamTransformer
|
||||
from langgraph.stream.run_stream import (
|
||||
AsyncGraphRunStream,
|
||||
AsyncSubgraphRunStream,
|
||||
GraphRunStream,
|
||||
SubgraphRunStream,
|
||||
)
|
||||
from langgraph.stream.stream_channel import StreamChannel
|
||||
from langgraph.stream.transformers import (
|
||||
CheckpointsTransformer,
|
||||
CustomTransformer,
|
||||
DebugTransformer,
|
||||
LifecyclePayload,
|
||||
LifecycleTransformer,
|
||||
SubgraphStatus,
|
||||
SubgraphTransformer,
|
||||
TasksTransformer,
|
||||
UpdatesTransformer,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AsyncGraphRunStream",
|
||||
"AsyncSubgraphRunStream",
|
||||
"CheckpointsTransformer",
|
||||
"CustomTransformer",
|
||||
"DebugTransformer",
|
||||
"GraphRunStream",
|
||||
"LifecyclePayload",
|
||||
"LifecycleTransformer",
|
||||
"ProtocolEvent",
|
||||
"StreamChannel",
|
||||
"StreamTransformer",
|
||||
"SubgraphRunStream",
|
||||
"SubgraphStatus",
|
||||
"SubgraphTransformer",
|
||||
"TasksTransformer",
|
||||
"UpdatesTransformer",
|
||||
]
|
||||
@@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any, cast
|
||||
|
||||
from langgraph.stream._types import ProtocolEvent, _ProtocolEventParams
|
||||
from langgraph.types import StreamPart
|
||||
|
||||
|
||||
def convert_to_protocol_event(part: StreamPart) -> ProtocolEvent:
|
||||
"""Convert a v2 StreamPart to a ProtocolEvent.
|
||||
|
||||
Args:
|
||||
part: A stream part with keys `type`, `ns`, `data`, and
|
||||
optionally `interrupts` (present on values events).
|
||||
|
||||
Returns:
|
||||
The equivalent ProtocolEvent.
|
||||
"""
|
||||
part_dict = cast(dict[str, Any], part)
|
||||
params: _ProtocolEventParams = {
|
||||
"namespace": list(part_dict["ns"]),
|
||||
"timestamp": int(time.time() * 1000),
|
||||
"data": part_dict["data"],
|
||||
}
|
||||
if "interrupts" in part_dict:
|
||||
params["interrupts"] = part_dict["interrupts"]
|
||||
return {
|
||||
"type": "event",
|
||||
"method": part_dict["type"],
|
||||
"params": params,
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from langgraph.stream._types import (
|
||||
ProtocolEvent,
|
||||
StreamTransformer,
|
||||
transformer_requires_async,
|
||||
)
|
||||
from langgraph.stream.stream_channel import StreamChannel
|
||||
|
||||
TransformerFactory = Callable[["tuple[str, ...]"], StreamTransformer]
|
||||
"""Factory that builds a scoped transformer for a mux.
|
||||
|
||||
Called once per `StreamMux` with the mux's scope (typically `()` for
|
||||
the root). Standard transformer classes accept a single positional
|
||||
scope argument, so the class itself is a valid factory. User
|
||||
transformers can close over their config:
|
||||
`lambda scope: MyTransformer(scope, foo=...)`.
|
||||
"""
|
||||
|
||||
|
||||
class StreamMux:
|
||||
"""Central event dispatcher for the streaming infrastructure.
|
||||
|
||||
Owns the main event log and routes events through a transformer
|
||||
pipeline. StreamChannels with a name discovered in transformer
|
||||
projections are auto-wired so that every `push()` also injects a
|
||||
`ProtocolEvent` into the main log. StreamChannels without a name
|
||||
are local-only.
|
||||
|
||||
Pass `is_async=True` when the mux will be consumed via async
|
||||
iteration (`handler.astream()`). All StreamChannel instances
|
||||
discovered during registration are automatically bound to the
|
||||
matching mode.
|
||||
|
||||
Attributes:
|
||||
extensions: Merged projection dict across all registered
|
||||
transformers. Treat as read-only — mutations won't be
|
||||
reflected back in individual transformers' state.
|
||||
native_keys: Projection keys contributed by transformers with
|
||||
`_native = True`.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
transformers: list[StreamTransformer] | None = None,
|
||||
*,
|
||||
is_async: bool = False,
|
||||
factories: list[TransformerFactory] | None = None,
|
||||
scope: tuple[str, ...] = (),
|
||||
_assign_seq: bool = True,
|
||||
) -> None:
|
||||
"""Initialize the mux and register transformers in order.
|
||||
|
||||
Callers pass either `transformers` (pre-built instances) or
|
||||
`factories` (callables producing fresh instances per mux). Each
|
||||
transformer's `init()` is called, projections are merged into
|
||||
`extensions`, `_native` keys are recorded in `native_keys`, and
|
||||
any StreamChannel instances are bound and (if named) wired.
|
||||
|
||||
Args:
|
||||
transformers: Already-built transformer instances. Registered
|
||||
only on this mux — they are NOT cloned into child
|
||||
mini-muxes built by `_make_child`. Use `factories` for
|
||||
transformers that should propagate to nested scopes.
|
||||
is_async: True for async dispatch (`apush` / `aclose` /
|
||||
`afail`), False for the sync path.
|
||||
factories: One-argument callables `(scope) -> StreamTransformer`.
|
||||
Called once with this mux's `scope` here, and cloned
|
||||
again per child scope by `_make_child` so each
|
||||
sub-mux gets fresh instances.
|
||||
scope: The namespace the mux operates within. The root mux
|
||||
is `()`.
|
||||
_assign_seq: Internal flag for child muxes. Root muxes assign
|
||||
monotonic `seq` numbers when appending to their main event
|
||||
log; child muxes share forwarded event objects and must not
|
||||
mutate their envelopes.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If any transformer requires an async run but
|
||||
the mux is in sync mode.
|
||||
TypeError: If a transformer's `init()` doesn't return a dict.
|
||||
ValueError: If transformers' projection keys collide.
|
||||
"""
|
||||
self.is_async = is_async
|
||||
self.scope: tuple[str, ...] = scope
|
||||
self._assign_seq = _assign_seq
|
||||
self._events: StreamChannel[ProtocolEvent] = StreamChannel()
|
||||
self._events._bind(is_async=is_async)
|
||||
self._events._bind_mux(self)
|
||||
self._transformers: list[StreamTransformer] = []
|
||||
self._channels: list[StreamChannel[Any]] = []
|
||||
self._seq = 0
|
||||
self._push_seq = 0
|
||||
|
||||
self.extensions: dict[str, Any] = {}
|
||||
self.native_keys: set[str] = set()
|
||||
self._projection_owners: dict[str, str] = {}
|
||||
self._transformer_by_key: dict[str, StreamTransformer] = {}
|
||||
|
||||
# Stored only when constructed from factories — used by
|
||||
# `_make_child` to clone the transformer pipeline at a deeper
|
||||
# scope. Pre-built transformers can't be cloned, so a mux
|
||||
# built with `transformers=` rejects child construction.
|
||||
self._factories: list[TransformerFactory] | None = (
|
||||
list(factories) if factories is not None else None
|
||||
)
|
||||
self._pump_fn: Callable[[], bool] | None = None
|
||||
self._apump_fn: Callable[[], Awaitable[bool]] | None = None
|
||||
|
||||
# Factories run first (they propagate to child mini-muxes
|
||||
# via `_make_child`), then any pre-built `transformers=`
|
||||
# instances are registered as root-only — they aren't cloned
|
||||
# for child scopes.
|
||||
if factories is not None:
|
||||
for factory in factories:
|
||||
self._register(factory(scope))
|
||||
for transformer in transformers or ():
|
||||
self._register(transformer)
|
||||
|
||||
def transformer_by_key(self, key: str) -> StreamTransformer | None:
|
||||
"""Return the transformer that contributed `key` to the projection."""
|
||||
return self._transformer_by_key.get(key)
|
||||
|
||||
def _next_push_seq(self) -> int:
|
||||
self._push_seq += 1
|
||||
return self._push_seq
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Pump wiring + mini-mux nesting
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def bind_pump(self, fn: Callable[[], bool]) -> None:
|
||||
"""Wire the sync pull callback onto every projection in this mux.
|
||||
|
||||
Records the pump on the mux so child mini-muxes built by
|
||||
`_make_child` can inherit it. Propagates to:
|
||||
- the main event log (`self._events`)
|
||||
- every projection StreamChannel in `extensions`
|
||||
- any registered transformer that exposes `_bind_pump` (e.g.
|
||||
`MessagesTransformer` so `ChatModelStream` instances drive the
|
||||
shared pump from their cursors)
|
||||
"""
|
||||
self._pump_fn = fn
|
||||
self._events._request_more = fn
|
||||
for ch in self._channels:
|
||||
ch._request_more = fn
|
||||
for transformer in self._transformers:
|
||||
bind = getattr(transformer, "_bind_pump", None)
|
||||
if bind is not None:
|
||||
bind(fn)
|
||||
|
||||
def bind_apump(self, fn: Callable[[], Awaitable[bool]]) -> None:
|
||||
"""Async counterpart to `bind_pump`."""
|
||||
self._apump_fn = fn
|
||||
self._events._arequest_more = fn
|
||||
for ch in self._channels:
|
||||
ch._arequest_more = fn
|
||||
for transformer in self._transformers:
|
||||
abind = getattr(transformer, "_bind_apump", None)
|
||||
if abind is not None:
|
||||
abind(fn)
|
||||
|
||||
def _make_child(self, scope: tuple[str, ...]) -> StreamMux:
|
||||
"""Build a mini-mux with the same factories scoped to `scope`.
|
||||
|
||||
Used by `SubgraphTransformer` to attach a fresh transformer
|
||||
pipeline to each discovered subgraph handle. The child mux
|
||||
inherits the current pump bindings (so cursors on its
|
||||
projection logs drive the root pump), carries the same factory
|
||||
list forward to any grandchild subgraphs, and does not assign
|
||||
`seq` numbers so forwarded events can be shared without
|
||||
mutating their envelope.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the mux was not constructed with
|
||||
`factories=`. Mini-muxes require factories so each scope
|
||||
gets its own fresh transformer instances.
|
||||
"""
|
||||
if self._factories is None:
|
||||
raise RuntimeError(
|
||||
"StreamMux._make_child requires the mux to be constructed "
|
||||
"with `factories=`; pre-built transformers can't be "
|
||||
"cloned to a new scope."
|
||||
)
|
||||
child = StreamMux(
|
||||
factories=self._factories,
|
||||
is_async=self.is_async,
|
||||
scope=scope,
|
||||
_assign_seq=False,
|
||||
)
|
||||
if self._pump_fn is not None:
|
||||
child.bind_pump(self._pump_fn)
|
||||
if self._apump_fn is not None:
|
||||
child.bind_apump(self._apump_fn)
|
||||
return child
|
||||
|
||||
def _register(self, transformer: StreamTransformer) -> None:
|
||||
"""Register a single transformer.
|
||||
|
||||
Calls `transformer.init()`, stores the transformer for event
|
||||
processing, binds any StreamChannel instances in the projection,
|
||||
and merges the projection into `extensions`.
|
||||
"""
|
||||
if transformer_requires_async(transformer) and not self.is_async:
|
||||
raise RuntimeError(
|
||||
f"{type(transformer).__name__} requires an async run — "
|
||||
"it overrides aprocess/afinalize/afail or sets "
|
||||
"requires_async=True. Use astream(), not stream()."
|
||||
)
|
||||
projection = transformer.init()
|
||||
if not isinstance(projection, dict):
|
||||
raise TypeError(
|
||||
f"StreamTransformer.init() must return a dict, "
|
||||
f"got {type(projection).__name__}"
|
||||
)
|
||||
conflicts = set(projection) & set(self.extensions)
|
||||
if conflicts:
|
||||
attributions = ", ".join(
|
||||
f"{key!r} (owned by {self._projection_owners[key]})"
|
||||
for key in sorted(conflicts)
|
||||
)
|
||||
raise ValueError(
|
||||
f"Transformer {type(transformer).__name__} returned "
|
||||
f"projection keys that conflict with already-registered "
|
||||
f"keys: {attributions}"
|
||||
)
|
||||
is_native = bool(getattr(transformer, "_native", False))
|
||||
self._transformers.append(transformer)
|
||||
self._bind_and_wire(projection, native=is_native)
|
||||
self.extensions.update(projection)
|
||||
owner_name = type(transformer).__name__
|
||||
for key in projection:
|
||||
self._projection_owners[key] = owner_name
|
||||
self._transformer_by_key[key] = transformer
|
||||
if is_native:
|
||||
self.native_keys.update(projection.keys())
|
||||
transformer._on_register(self)
|
||||
|
||||
def push(self, event: ProtocolEvent) -> None:
|
||||
"""Route an event through all transformers, then append to the main log.
|
||||
|
||||
Each transformer's `process()` is called in registration order.
|
||||
If any transformer returns False, the event is suppressed from
|
||||
the main log, but transformers that already saw it keep their
|
||||
side effects.
|
||||
|
||||
On the root mux, `seq` is assigned right before an event enters
|
||||
the main log, not before the transformer pipeline runs. This
|
||||
ensures that events auto-forwarded from StreamChannels during
|
||||
`process()` get earlier seq numbers than the original event,
|
||||
preserving monotonic ordering in the root log. Child muxes do
|
||||
not assign `seq`, so subgraph forwarding can share event objects
|
||||
without mutating their envelopes.
|
||||
|
||||
Args:
|
||||
event: The protocol event to dispatch.
|
||||
"""
|
||||
keep = True
|
||||
for transformer in self._transformers:
|
||||
if not transformer.process(event):
|
||||
keep = False
|
||||
if keep:
|
||||
if self._assign_seq:
|
||||
self._seq += 1
|
||||
event["seq"] = self._seq
|
||||
self._events.push(event)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Finalize all transformers, close all projections and the main log.
|
||||
|
||||
StreamChannels discovered in transformer projections are
|
||||
auto-closed after `finalize()` runs — transformers don't need
|
||||
to close them manually. If any transformer's `finalize()` raises,
|
||||
the remaining transformers, projections, and the main log are
|
||||
still closed; the first error is re-raised after cleanup
|
||||
completes.
|
||||
|
||||
Raises:
|
||||
BaseException: The first error raised by a transformer's
|
||||
`finalize()`, re-raised after cleanup finishes.
|
||||
"""
|
||||
first_error: BaseException | None = None
|
||||
for transformer in self._transformers:
|
||||
try:
|
||||
transformer.finalize()
|
||||
except BaseException as e:
|
||||
if first_error is None:
|
||||
first_error = e
|
||||
for ch in self._channels:
|
||||
if not ch._closed:
|
||||
ch.close()
|
||||
self._events.close()
|
||||
if first_error is not None:
|
||||
raise first_error
|
||||
|
||||
def fail(self, err: BaseException) -> None:
|
||||
"""Fail all transformers, projections, and the main log.
|
||||
|
||||
StreamChannels discovered in transformer projections are
|
||||
auto-failed — transformers don't need to fail them manually.
|
||||
If any transformer's `fail()` raises, the remaining
|
||||
transformers, projections, and the main log are still failed.
|
||||
|
||||
Args:
|
||||
err: The exception that ended the run.
|
||||
"""
|
||||
for transformer in self._transformers:
|
||||
try:
|
||||
transformer.fail(err)
|
||||
except BaseException:
|
||||
pass
|
||||
for ch in self._channels:
|
||||
if not ch._closed:
|
||||
ch.fail(err)
|
||||
self._events.fail(err)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Async dispatch
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def apush(self, event: ProtocolEvent) -> None:
|
||||
"""Dispatch an event on the async lane.
|
||||
|
||||
Awaits each transformer's `aprocess` in registration order
|
||||
before appending to the main log. A slow `aprocess` serializes
|
||||
the pipeline by design — that's the guarantee that lets a later
|
||||
transformer (or a synchronous consumer) see the result of the
|
||||
async work. For decoupled work, use `schedule()` from inside
|
||||
`process` / `aprocess` instead.
|
||||
|
||||
The main log append is a non-blocking `push` — matching v1's
|
||||
`put_nowait` shape. The root mux assigns `seq`; child muxes do
|
||||
not, so forwarded subgraph events can be shared without copying.
|
||||
Memory is bounded by caller pace via the caller-driven pump; see
|
||||
`StreamChannel` for the full tradeoff story.
|
||||
|
||||
Args:
|
||||
event: The protocol event to dispatch.
|
||||
"""
|
||||
keep = True
|
||||
for transformer in self._transformers:
|
||||
if not await transformer.aprocess(event):
|
||||
keep = False
|
||||
if keep:
|
||||
if self._assign_seq:
|
||||
self._seq += 1
|
||||
event["seq"] = self._seq
|
||||
self._events.push(event)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
"""Finalize on the async lane.
|
||||
|
||||
Awaits every task started via `StreamTransformer.schedule()`
|
||||
across all transformers, then calls `afinalize()` on each,
|
||||
then auto-closes channels and the main event log.
|
||||
|
||||
If any scheduled task raised under `on_error="raise"`, or any
|
||||
transformer's `afinalize` raises, the exception propagates.
|
||||
The caller (the pump) handles it by routing into `afail`.
|
||||
|
||||
Raises:
|
||||
BaseException: The first scheduled-task or `afinalize`
|
||||
error, re-raised after cleanup.
|
||||
"""
|
||||
pending = self._collect_scheduled_tasks()
|
||||
if pending:
|
||||
results = await asyncio.gather(*pending, return_exceptions=True)
|
||||
first_err = next(
|
||||
(
|
||||
r
|
||||
for r in results
|
||||
if isinstance(r, BaseException)
|
||||
and not isinstance(r, asyncio.CancelledError)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if first_err is not None:
|
||||
raise first_err
|
||||
|
||||
first_error: BaseException | None = None
|
||||
for transformer in self._transformers:
|
||||
try:
|
||||
await transformer.afinalize()
|
||||
except BaseException as e:
|
||||
if first_error is None:
|
||||
first_error = e
|
||||
for ch in self._channels:
|
||||
if not ch._closed:
|
||||
ch.close()
|
||||
self._events.close()
|
||||
if first_error is not None:
|
||||
raise first_error
|
||||
|
||||
async def afail(self, err: BaseException) -> None:
|
||||
"""Fail on the async lane.
|
||||
|
||||
Cancels every scheduled task across all transformers, awaits
|
||||
them to completion, then runs each transformer's `afail` hook
|
||||
and auto-fails channels and the main event log.
|
||||
|
||||
Args:
|
||||
err: The exception that ended the run.
|
||||
"""
|
||||
pending = self._collect_scheduled_tasks()
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
if pending:
|
||||
await asyncio.gather(*pending, return_exceptions=True)
|
||||
|
||||
for transformer in self._transformers:
|
||||
try:
|
||||
await transformer.afail(err)
|
||||
except BaseException:
|
||||
pass
|
||||
for ch in self._channels:
|
||||
if not ch._closed:
|
||||
ch.fail(err)
|
||||
if not self._events._closed:
|
||||
self._events.fail(err)
|
||||
|
||||
def _collect_scheduled_tasks(self) -> list[asyncio.Task[Any]]:
|
||||
"""Return a snapshot of in-flight tasks scheduled via transformers."""
|
||||
return [
|
||||
task
|
||||
for transformer in self._transformers
|
||||
for task in getattr(transformer, "_stream_scheduled_tasks", ())
|
||||
if not task.done()
|
||||
]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Binding and StreamChannel auto-wiring
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _bind_and_wire(
|
||||
self, projection: dict[str, Any], *, native: bool = False
|
||||
) -> None:
|
||||
"""Bind and optionally wire StreamChannel instances in a projection.
|
||||
|
||||
All StreamChannels are bound and tracked. Channels with a name
|
||||
are additionally wired for protocol auto-forwarding.
|
||||
|
||||
Args:
|
||||
projection: The projection dict returned by a transformer's
|
||||
`init()`.
|
||||
native: True when the owning transformer is `_native`.
|
||||
Named channels owned by a native transformer use the
|
||||
channel name directly as the protocol method;
|
||||
user-defined channels are prefixed with `custom:`.
|
||||
"""
|
||||
for value in projection.values():
|
||||
if isinstance(value, StreamChannel):
|
||||
value._bind(is_async=self.is_async)
|
||||
value._bind_mux(self)
|
||||
self._channels.append(value)
|
||||
if value.name is not None:
|
||||
method = value.name if native else f"custom:{value.name}"
|
||||
|
||||
def _make_forward(method_name: str) -> Callable[[Any], None]:
|
||||
def _forward(item: Any) -> None:
|
||||
self._forward(method_name, item)
|
||||
|
||||
return _forward
|
||||
|
||||
value._wire(_make_forward(method))
|
||||
|
||||
def _forward(self, method: str, item: Any) -> None:
|
||||
"""Inject a ProtocolEvent for a StreamChannel push.
|
||||
|
||||
Forwarded events bypass the transformer pipeline to avoid
|
||||
infinite recursion (a transformer that pushes to a channel
|
||||
during `process()` would re-trigger itself). These events are
|
||||
visible in this mux's main event log but are not passed through
|
||||
transformers' `process()` methods. Only the root mux assigns
|
||||
`seq` to forwarded channel events.
|
||||
|
||||
Args:
|
||||
method: The full protocol method (already with or without
|
||||
the `custom:` prefix; resolved by `_bind_and_wire`).
|
||||
item: The payload pushed onto the channel.
|
||||
"""
|
||||
event: ProtocolEvent = {
|
||||
"type": "event",
|
||||
"method": method,
|
||||
"params": {
|
||||
"namespace": [],
|
||||
"timestamp": int(time.time() * 1000),
|
||||
"data": item,
|
||||
},
|
||||
}
|
||||
if self._assign_seq:
|
||||
self._seq += 1
|
||||
event["seq"] = self._seq
|
||||
self._events.push(event)
|
||||
@@ -0,0 +1,313 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Coroutine
|
||||
from typing import Any, ClassVar, Literal
|
||||
|
||||
from typing_extensions import NotRequired, TypedDict
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _ProtocolEventParams(TypedDict):
|
||||
"""Parameters for a protocol event.
|
||||
|
||||
`timestamp` is wall-clock milliseconds since the epoch and can go
|
||||
backwards across NTP adjustments — use `ProtocolEvent.seq` for
|
||||
ordering.
|
||||
"""
|
||||
|
||||
namespace: list[str]
|
||||
timestamp: int
|
||||
data: Any
|
||||
interrupts: NotRequired[tuple[Any, ...]]
|
||||
|
||||
|
||||
class ProtocolEvent(TypedDict):
|
||||
"""A protocol event emitted by the streaming infrastructure.
|
||||
|
||||
Wraps a raw stream part (values, messages, custom, etc.) in a uniform
|
||||
envelope with a monotonic sequence number assigned by the root StreamMux.
|
||||
Consumers that need a total order across root events should use `seq`, not
|
||||
`params.timestamp` (which is wall-clock and not monotonic).
|
||||
"""
|
||||
|
||||
type: Literal["event"]
|
||||
eventId: NotRequired[str]
|
||||
seq: NotRequired[int]
|
||||
method: str # StreamMode value: "values", "messages", "custom", etc.
|
||||
params: _ProtocolEventParams
|
||||
|
||||
|
||||
class StreamTransformer(ABC):
|
||||
"""Extension point for custom stream projections.
|
||||
|
||||
Transformers observe protocol events flowing through the StreamMux and
|
||||
build typed derived projections (StreamChannels, promises, etc.).
|
||||
|
||||
Set `_native = True` on a transformer to have its projection keys
|
||||
exposed as direct attributes on the run stream (in addition to
|
||||
appearing in `run.extensions`).
|
||||
|
||||
Subclasses must implement `init` and override at least one of
|
||||
`process` / `aprocess`. The `finalize` / `afinalize` and `fail` /
|
||||
`afail` hooks are optional — the default implementations are no-ops.
|
||||
StreamChannel instances in the projection dict are auto-closed /
|
||||
auto-failed by the mux, so most transformers don't need `finalize`
|
||||
or `fail` at all.
|
||||
|
||||
Transformers that need async work pick the async lane by:
|
||||
|
||||
1. Overriding `aprocess` (and optionally `afinalize` / `afail`), or
|
||||
2. Calling `self.schedule(coro)` from inside a sync `process`, or
|
||||
3. Setting `requires_async = True` explicitly.
|
||||
|
||||
The mux detects these cases at registration and raises if they're
|
||||
used under sync `stream()` — they only work under `astream()`.
|
||||
|
||||
Use `aprocess` when the pump must wait for async work before the
|
||||
next transformer sees the event (e.g. PII redaction that mutates
|
||||
`event` in place). Use `schedule()` for decoupled async work whose
|
||||
result lands on an independent projection (e.g. async moderation
|
||||
scoring, cost lookup, external tracing).
|
||||
|
||||
Attributes:
|
||||
scope: Namespace the transformer operates within — `()` for the
|
||||
root mux. Set at construction from the mux's scope (each
|
||||
factory is called as `factory(scope)`).
|
||||
requires_async: Explicit opt-in for transformers that need a
|
||||
running event loop but don't override any async method (for
|
||||
example, transformers that call `schedule()` from a sync
|
||||
`process`). The mux also auto-detects the async lane when
|
||||
`aprocess`, `afinalize`, or `afail` is overridden.
|
||||
supports_sync: Set True only for transformers that override
|
||||
async-lane hooks while still fully supporting the sync lane.
|
||||
Such transformers may be registered under `stream()`.
|
||||
required_stream_modes: Stream modes the graph must emit for
|
||||
this transformer to have anything to process. Computed as
|
||||
the union across all registered transformers to determine
|
||||
which modes a `stream_events(version="v3")` run requests from the graph.
|
||||
Empty tuple means the transformer consumes only synthetic
|
||||
events (or is purely passive).
|
||||
"""
|
||||
|
||||
requires_async: ClassVar[bool] = False
|
||||
supports_sync: ClassVar[bool] = False
|
||||
required_stream_modes: ClassVar[tuple[str, ...]] = ()
|
||||
|
||||
def __init__(self, scope: tuple[str, ...] = ()) -> None:
|
||||
"""Initialize the transformer with its mux's scope.
|
||||
|
||||
Args:
|
||||
scope: The namespace tuple the owning mux is scoped to.
|
||||
`()` for the root. Factories receive this at
|
||||
construction time (`factory(scope)` in `StreamMux`).
|
||||
"""
|
||||
self.scope: tuple[str, ...] = scope
|
||||
|
||||
@abstractmethod
|
||||
def init(self) -> dict[str, Any]:
|
||||
"""Return the projection dict.
|
||||
|
||||
Keys become entries in `run.extensions`. If the transformer has
|
||||
`_native = True`, keys are also set as direct attributes on the
|
||||
run stream.
|
||||
|
||||
StreamChannel instances in the return value are automatically
|
||||
wired by the StreamMux for protocol event auto-forwarding.
|
||||
"""
|
||||
...
|
||||
|
||||
def _on_register(self, mux: Any) -> None:
|
||||
"""Called by `StreamMux._register` after this transformer is wired in.
|
||||
|
||||
Default is a no-op. Override to capture a reference to the
|
||||
owning mux — needed for transformers that build mini-muxes
|
||||
via `mux._make_child(...)` (e.g. `SubgraphTransformer`).
|
||||
"""
|
||||
|
||||
def process(self, event: ProtocolEvent) -> bool:
|
||||
"""Handle an event on the sync lane.
|
||||
|
||||
Called for every event before it is appended to the main event
|
||||
log. Subclasses must override either `process` or `aprocess`.
|
||||
The default raises so a missing override fails loudly rather
|
||||
than silently passing every event through.
|
||||
|
||||
Args:
|
||||
event: The protocol event to observe.
|
||||
|
||||
Returns:
|
||||
True to keep the event in the main log, False to suppress it.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f"{type(self).__name__} must override process() or aprocess()"
|
||||
)
|
||||
|
||||
async def aprocess(self, event: ProtocolEvent) -> bool:
|
||||
"""Handle an event on the async lane.
|
||||
|
||||
The mux awaits this before dispatching to the next transformer,
|
||||
so a slow `aprocess` serializes the pipeline. Use it only when
|
||||
a later transformer — or a consumer reading the event
|
||||
synchronously — must see the result of the async work (e.g.
|
||||
PII redaction that mutates `event` in place).
|
||||
|
||||
The default delegates to `process`, so purely-sync transformers
|
||||
run unchanged under `astream()`.
|
||||
|
||||
Args:
|
||||
event: The protocol event to observe.
|
||||
|
||||
Returns:
|
||||
True to keep the event in the main log, False to suppress it.
|
||||
"""
|
||||
return self.process(event)
|
||||
|
||||
def finalize(self) -> None:
|
||||
"""Called when the run ends normally (sync lane).
|
||||
|
||||
Override to close StreamChannels, resolve promises, or perform
|
||||
other teardown. StreamChannel instances in the projection dict
|
||||
are auto-closed by the mux.
|
||||
"""
|
||||
|
||||
async def afinalize(self) -> None:
|
||||
"""Called when the run ends normally (async lane).
|
||||
|
||||
By the time this runs, the mux has already awaited every task
|
||||
started via `schedule()`, so StreamChannels can be closed here
|
||||
without a last-task-wins race.
|
||||
|
||||
The default delegates to `finalize`.
|
||||
"""
|
||||
self.finalize()
|
||||
|
||||
def fail(self, err: BaseException) -> None:
|
||||
"""Called when the run ends with an error (sync lane).
|
||||
|
||||
Override to fail StreamChannels, reject promises, or perform
|
||||
other teardown. StreamChannel instances in the projection dict
|
||||
are auto-failed by the mux.
|
||||
|
||||
Args:
|
||||
err: The exception that ended the run.
|
||||
"""
|
||||
|
||||
async def afail(self, err: BaseException) -> None:
|
||||
"""Called when the run ends with an error (async lane).
|
||||
|
||||
The mux cancels and awaits every task started via `schedule()`
|
||||
before calling this, so cleanup doesn't race with in-flight work.
|
||||
|
||||
The default delegates to `fail`.
|
||||
|
||||
Args:
|
||||
err: The exception that ended the run.
|
||||
"""
|
||||
self.fail(err)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Scheduled async work
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def schedule(
|
||||
self,
|
||||
coro: Coroutine[Any, Any, Any],
|
||||
*,
|
||||
on_error: Literal["log", "raise"] = "log",
|
||||
) -> asyncio.Task[Any]:
|
||||
"""Schedule a coroutine tied to this transformer's lifecycle.
|
||||
|
||||
The mux holds the task reference, awaits all scheduled tasks
|
||||
during `aclose()` before calling `afinalize()`, and cancels
|
||||
them on `afail()`. Authors don't need to track tasks or
|
||||
implement the last-task-closes-the-log dance.
|
||||
|
||||
Requires a running event loop — call only under `astream()`.
|
||||
Set `requires_async = True` on the class so registration under
|
||||
sync `stream()` fails fast with a clear message.
|
||||
|
||||
Args:
|
||||
coro: The coroutine to run. Its lifecycle is owned by the
|
||||
mux from this point on.
|
||||
on_error: `"log"` (default) catches and logs any exception
|
||||
the coroutine raises, so a single failure doesn't tear
|
||||
down the run. `"raise"` lets the exception propagate
|
||||
when the mux joins pendings, converting the close path
|
||||
into the fail path.
|
||||
|
||||
Returns:
|
||||
The asyncio Task. Authors rarely need to await it directly
|
||||
— consumers read results from whatever projection the
|
||||
coroutine pushes into.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If called without a running event loop (i.e.
|
||||
under sync `stream()` rather than `astream()`).
|
||||
"""
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
raise RuntimeError(
|
||||
f"{type(self).__name__}.schedule() requires a running "
|
||||
"event loop; this transformer must run under astream(), "
|
||||
"not stream(). Set requires_async=True on the class so "
|
||||
"this fails at registration rather than at first event."
|
||||
) from None
|
||||
|
||||
wrapped = self._wrap_scheduled(coro) if on_error == "log" else coro
|
||||
task = asyncio.create_task(wrapped)
|
||||
tasks = self._scheduled_task_set()
|
||||
tasks.add(task)
|
||||
task.add_done_callback(tasks.discard)
|
||||
return task
|
||||
|
||||
@staticmethod
|
||||
async def _wrap_scheduled(coro: Coroutine[Any, Any, Any]) -> Any:
|
||||
try:
|
||||
return await coro
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except BaseException:
|
||||
_logger.exception("Scheduled StreamTransformer task failed")
|
||||
|
||||
def _scheduled_task_set(self) -> set[asyncio.Task[Any]]:
|
||||
"""Return the lazily-allocated task set.
|
||||
|
||||
Avoids requiring subclasses to call `super().__init__()`.
|
||||
"""
|
||||
tasks: set[asyncio.Task[Any]] | None = getattr(
|
||||
self, "_stream_scheduled_tasks", None
|
||||
)
|
||||
if tasks is None:
|
||||
tasks = set()
|
||||
self._stream_scheduled_tasks = tasks
|
||||
return tasks
|
||||
|
||||
|
||||
def transformer_requires_async(transformer: StreamTransformer) -> bool:
|
||||
"""Return True if the transformer needs a running event loop.
|
||||
|
||||
A transformer requires async if it explicitly opts in
|
||||
(`requires_async = True`) or overrides any of the async-lane methods
|
||||
(`aprocess`, `afinalize`, `afail`) without also declaring that it
|
||||
supports the sync lane.
|
||||
|
||||
Args:
|
||||
transformer: The transformer to inspect.
|
||||
|
||||
Returns:
|
||||
True if the transformer cannot run under sync `stream()`.
|
||||
"""
|
||||
if transformer.requires_async:
|
||||
return True
|
||||
if transformer.supports_sync:
|
||||
return False
|
||||
cls = type(transformer)
|
||||
for name in ("aprocess", "afinalize", "afail"):
|
||||
if getattr(cls, name) is not getattr(StreamTransformer, name):
|
||||
return True
|
||||
return False
|
||||
@@ -0,0 +1,608 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable, Iterator, Mapping
|
||||
from types import MappingProxyType, TracebackType
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from langchain_core._api import beta
|
||||
|
||||
from langgraph.stream._convert import convert_to_protocol_event
|
||||
from langgraph.stream._mux import StreamMux
|
||||
from langgraph.stream._types import ProtocolEvent
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from langgraph.stream.transformers import SubgraphStatus
|
||||
|
||||
|
||||
def _drive_until_done(pump: Callable[[], bool]) -> None:
|
||||
"""Call the sync pump until it returns False."""
|
||||
while pump():
|
||||
pass
|
||||
|
||||
|
||||
async def _adrive_until_done(pump: Callable[[], Awaitable[bool]]) -> None:
|
||||
"""Call the async pump until it returns False."""
|
||||
while await pump():
|
||||
pass
|
||||
|
||||
|
||||
@beta(message="The v3 streaming protocol on Pregel is experimental.")
|
||||
class GraphRunStream:
|
||||
"""Sync run stream with caller-driven pumping.
|
||||
|
||||
The caller's iteration on any projection (`values`, `messages`,
|
||||
raw events, or `output`) drives the graph forward. No background
|
||||
thread is used — the caller's `for` loop is the pump.
|
||||
|
||||
Projections are single-consumer — iterating `run.values` twice
|
||||
raises. Use `projection.tee(n)` if you genuinely need fan-out.
|
||||
|
||||
All transformer projections live in `extensions`. Native transformer
|
||||
projections (those with `_native = True`) are also set as direct
|
||||
attributes on this instance (e.g. `run.values`, `run.messages`).
|
||||
|
||||
!!! warning
|
||||
|
||||
Returned by `Pregel.stream_events(version="v3")`, which is
|
||||
experimental and may change.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
graph_iter: Iterator[Any] | None,
|
||||
mux: StreamMux,
|
||||
*,
|
||||
wire_pump: bool = True,
|
||||
) -> None:
|
||||
"""Initialize the run stream.
|
||||
|
||||
Args:
|
||||
graph_iter: Pull-based iterator over the graph's stream,
|
||||
or `None` for nested run streams whose pump is driven
|
||||
by an outer run (e.g. `SubgraphRunStream`).
|
||||
mux: The StreamMux owning projections and the main log.
|
||||
wire_pump: When True (default), bind `_pump_next` as the
|
||||
mux's pump callable. Subclasses that inherit a parent
|
||||
pump via `StreamMux._make_child` should pass False to
|
||||
preserve the parent binding.
|
||||
"""
|
||||
self._graph_iter = graph_iter
|
||||
self._mux = mux
|
||||
self.extensions: Mapping[str, Any] = MappingProxyType(mux.extensions)
|
||||
self._exhausted = False
|
||||
self._latest: dict[str, Any] | None = None
|
||||
self._interrupted = False
|
||||
self._interrupts: list[Any] = []
|
||||
self._scope_list: list[str] = list(mux.scope)
|
||||
for key in mux.native_keys:
|
||||
setattr(self, key, mux.extensions[key])
|
||||
if wire_pump:
|
||||
self._wire_request_more(mux)
|
||||
|
||||
def _wire_request_more(self, mux: StreamMux) -> None:
|
||||
"""Wire the sync pull callback through the mux.
|
||||
|
||||
Routing through `mux.bind_pump` (rather than walking
|
||||
projections directly here) lets child mini-muxes built by
|
||||
`mux._make_child(...)` inherit the same pump callable, so
|
||||
cursors on a subgraph handle's projections drive the root
|
||||
pump just like cursors on `run.values` do.
|
||||
"""
|
||||
mux.bind_pump(self._pump_next)
|
||||
|
||||
def _observe_event(self, event: ProtocolEvent) -> None:
|
||||
"""Track values-event state for output/interrupted/interrupts."""
|
||||
if event["method"] != "values":
|
||||
return
|
||||
params = event["params"]
|
||||
if params["namespace"] != self._scope_list:
|
||||
return
|
||||
self._latest = params["data"]
|
||||
interrupts = params.get("interrupts", ())
|
||||
if interrupts:
|
||||
self._interrupted = True
|
||||
self._interrupts.extend(interrupts)
|
||||
|
||||
def _pump_next(self) -> bool:
|
||||
"""Pull one event from the graph and push it through the mux.
|
||||
|
||||
Returns:
|
||||
True if an event was pulled, False if the graph is exhausted
|
||||
or has raised. Always False when constructed with
|
||||
`graph_iter=None` (the run is driven by an outer pump).
|
||||
"""
|
||||
if self._exhausted or self._graph_iter is None:
|
||||
return False
|
||||
try:
|
||||
part = next(self._graph_iter)
|
||||
event = convert_to_protocol_event(part)
|
||||
self._observe_event(event)
|
||||
self._mux.push(event)
|
||||
return True
|
||||
except StopIteration:
|
||||
self._mux.close()
|
||||
self._exhausted = True
|
||||
return False
|
||||
except Exception as e:
|
||||
self._mux.fail(e)
|
||||
self._exhausted = True
|
||||
return False
|
||||
|
||||
def abort(self) -> None:
|
||||
"""Stop the run early.
|
||||
|
||||
Closes the mux and marks the stream exhausted. The graph
|
||||
iterator is dropped; any in-flight nodes see the closure on
|
||||
their next yield point. Idempotent.
|
||||
"""
|
||||
if self._exhausted:
|
||||
return
|
||||
self._exhausted = True
|
||||
try:
|
||||
self._mux.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def __enter__(self) -> GraphRunStream:
|
||||
return self
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc: BaseException | None,
|
||||
tb: TracebackType | None,
|
||||
) -> None:
|
||||
self.abort()
|
||||
|
||||
@property
|
||||
def output(self) -> dict[str, Any] | None:
|
||||
"""Drive the run to completion and return the final state."""
|
||||
_drive_until_done(self._pump_next)
|
||||
if (err := self._mux._events._error) is not None:
|
||||
raise err
|
||||
return self._latest
|
||||
|
||||
@property
|
||||
def interrupted(self) -> bool:
|
||||
"""Drive the run to completion, then return whether it was
|
||||
interrupted.
|
||||
|
||||
Raises:
|
||||
BaseException: If the run ended with an error.
|
||||
"""
|
||||
_drive_until_done(self._pump_next)
|
||||
if (err := self._mux._events._error) is not None:
|
||||
raise err
|
||||
return self._interrupted
|
||||
|
||||
@property
|
||||
def interrupts(self) -> list[Any]:
|
||||
"""Drive the run to completion, then return interrupt payloads.
|
||||
|
||||
Raises:
|
||||
BaseException: If the run ended with an error.
|
||||
"""
|
||||
_drive_until_done(self._pump_next)
|
||||
if (err := self._mux._events._error) is not None:
|
||||
raise err
|
||||
return self._interrupts
|
||||
|
||||
def __iter__(self) -> Iterator[ProtocolEvent]:
|
||||
"""Subscribe to the main event log and iterate protocol events."""
|
||||
return iter(self._mux._events)
|
||||
|
||||
def interleave(self, *names: str) -> Iterator[tuple[str, Any]]:
|
||||
"""Iterate multiple projections in arrival order, yielding ``(name, item)``.
|
||||
|
||||
Items are ordered by a monotonic push stamp assigned when each
|
||||
transformer pushes into its `StreamChannel`. This gives strict
|
||||
arrival ordering across projections, unlike round-robin.
|
||||
|
||||
Args:
|
||||
*names: Projection keys to interleave. Must match keys in
|
||||
``extensions``.
|
||||
|
||||
Yields:
|
||||
``(name, item)`` tuples in arrival order across the named
|
||||
projections.
|
||||
|
||||
Each named channel is locked for the duration of iteration and
|
||||
released when the generator completes, is closed, or raises.
|
||||
Channels cannot be subscribed concurrently — use `.tee(n)` if
|
||||
you need fan-out.
|
||||
|
||||
Raises:
|
||||
KeyError: If a name doesn't match a registered projection.
|
||||
|
||||
Example:
|
||||
```python
|
||||
for name, item in run.interleave("messages", "values"):
|
||||
if name == "messages":
|
||||
print("msg:", item)
|
||||
else:
|
||||
print("val:", item)
|
||||
```
|
||||
"""
|
||||
from langgraph.stream.stream_channel import StreamChannel
|
||||
|
||||
channels: dict[str, StreamChannel[Any]] = {}
|
||||
try:
|
||||
for name in names:
|
||||
ch = self.extensions[name]
|
||||
if not isinstance(ch, StreamChannel):
|
||||
raise TypeError(
|
||||
f"interleave() requires StreamChannel projections, "
|
||||
f"got {type(ch).__name__} for {name!r}"
|
||||
)
|
||||
if ch._is_async is None:
|
||||
raise TypeError(
|
||||
f"StreamChannel {name!r} has not been bound yet. "
|
||||
"Register the transformer with a StreamMux first."
|
||||
)
|
||||
if ch._is_async:
|
||||
raise TypeError(
|
||||
f"StreamChannel {name!r} is bound to async mode — "
|
||||
"sync interleave() cannot consume async channels."
|
||||
)
|
||||
if ch._subscribed:
|
||||
raise RuntimeError(
|
||||
f"StreamChannel {name!r} already has a subscriber; "
|
||||
"use .tee(n) for fan-out."
|
||||
)
|
||||
ch._subscribed = True
|
||||
channels[name] = ch
|
||||
|
||||
done: set[str] = set()
|
||||
|
||||
while len(done) < len(channels):
|
||||
best: tuple[int, str] | None = None
|
||||
for name, ch in channels.items():
|
||||
if name in done:
|
||||
continue
|
||||
if ch._closed and not ch._items:
|
||||
if ch._error is not None:
|
||||
raise ch._error
|
||||
done.add(name)
|
||||
continue
|
||||
if ch._items:
|
||||
stamp = ch._items[0][0]
|
||||
if best is None or stamp < best[0]:
|
||||
best = (stamp, name)
|
||||
|
||||
if best is not None:
|
||||
_stamp, item = channels[best[1]]._items.popleft()
|
||||
yield (best[1], item)
|
||||
else:
|
||||
pump = self._mux._pump_fn
|
||||
if pump is None or not pump():
|
||||
before = len(done)
|
||||
for name, ch in channels.items():
|
||||
if name not in done and not ch._items:
|
||||
if ch._closed:
|
||||
if ch._error is not None:
|
||||
raise ch._error
|
||||
done.add(name)
|
||||
if len(done) == before:
|
||||
break
|
||||
finally:
|
||||
for ch in channels.values():
|
||||
ch._subscribed = False
|
||||
|
||||
|
||||
@beta(message="The v3 streaming protocol on Pregel is experimental.")
|
||||
class AsyncGraphRunStream:
|
||||
"""Async run stream with caller-driven pumping.
|
||||
|
||||
Async iteration on any projection drives the graph forward — there
|
||||
is no background task. Concurrent consumers share a single-flight
|
||||
pump via an `asyncio.Lock`, so each awaiting cursor contributes one
|
||||
event per acquisition. Backpressure comes from the logs: when a
|
||||
subscribed log's buffer reaches `maxlen`, `apush` awaits the
|
||||
subscriber to drain, which holds back the pump and paces the graph.
|
||||
|
||||
Projections are single-consumer — a second `aiter(run.values)`
|
||||
raises. Use `projection.tee(n)` for fan-out.
|
||||
|
||||
Use as an async context manager to guarantee clean shutdown on
|
||||
early exit:
|
||||
|
||||
```python
|
||||
async with await handler.astream(input) as run:
|
||||
async for msg in run.messages:
|
||||
...
|
||||
```
|
||||
|
||||
!!! warning
|
||||
|
||||
Awaited from `Pregel.astream_events(version="v3")`, which is
|
||||
experimental and may change.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
graph_aiter: AsyncIterator[Any] | None,
|
||||
mux: StreamMux,
|
||||
*,
|
||||
wire_pump: bool = True,
|
||||
) -> None:
|
||||
"""Initialize the async run stream.
|
||||
|
||||
Args:
|
||||
graph_aiter: Async iterator over the graph's stream, or
|
||||
`None` for nested run streams whose pump is driven by
|
||||
an outer run (e.g. `AsyncSubgraphRunStream`).
|
||||
mux: The StreamMux owning projections and the main log.
|
||||
wire_pump: When True (default), bind `_apump_next` as the
|
||||
mux's async pump callable. Subclasses that inherit a
|
||||
parent pump via `StreamMux._make_child` should pass
|
||||
False to preserve the parent binding.
|
||||
"""
|
||||
self._graph_aiter = graph_aiter
|
||||
self._mux = mux
|
||||
self.extensions: Mapping[str, Any] = MappingProxyType(mux.extensions)
|
||||
self._exhausted = False
|
||||
self._latest: dict[str, Any] | None = None
|
||||
self._interrupted = False
|
||||
self._interrupts: list[Any] = []
|
||||
self._scope_list: list[str] = list(mux.scope)
|
||||
self._pump_cond = asyncio.Condition()
|
||||
self._pumping = False
|
||||
for key in mux.native_keys:
|
||||
setattr(self, key, mux.extensions[key])
|
||||
if wire_pump:
|
||||
self._wire_arequest_more(mux)
|
||||
|
||||
def _observe_event(self, event: ProtocolEvent) -> None:
|
||||
"""Track values-event state for output/interrupted/interrupts."""
|
||||
if event["method"] != "values":
|
||||
return
|
||||
params = event["params"]
|
||||
if params["namespace"] != self._scope_list:
|
||||
return
|
||||
self._latest = params["data"]
|
||||
interrupts = params.get("interrupts", ())
|
||||
if interrupts:
|
||||
self._interrupted = True
|
||||
self._interrupts.extend(interrupts)
|
||||
|
||||
def _wire_arequest_more(self, mux: StreamMux) -> None:
|
||||
"""Wire the async pull callback through the mux.
|
||||
|
||||
Mirrors `_wire_request_more`: routing through
|
||||
`mux.bind_apump` lets child mini-muxes inherit the pump
|
||||
callable so cursors on subgraph handles drive the root
|
||||
pump.
|
||||
"""
|
||||
mux.bind_apump(self._apump_next)
|
||||
|
||||
async def _apump_next(self) -> bool:
|
||||
"""Drive one pump step, or wait for the active pumper to drive one.
|
||||
|
||||
"Take-a-number" semantics: at most one task at a time calls
|
||||
`graph_aiter.__anext__()` (asyncio iterators can't be advanced
|
||||
concurrently). Other callers wait on a Condition that the
|
||||
active pumper notifies after each step. This lets a "passive"
|
||||
consumer — one whose projection's buffer is being filled by the
|
||||
active pumper's push — wake up as soon as its data lands,
|
||||
instead of queueing on the pump and only observing its data one
|
||||
graph event late.
|
||||
|
||||
`except Exception` is intentional — `CancelledError` and other
|
||||
`BaseException` subclasses propagate, matching asyncio's
|
||||
cancellation contract.
|
||||
|
||||
Returns:
|
||||
True if a pump step completed (by this task or another),
|
||||
False if the graph is exhausted.
|
||||
"""
|
||||
async with self._pump_cond:
|
||||
if self._exhausted or self._graph_aiter is None:
|
||||
return False
|
||||
if self._pumping:
|
||||
# Another task is pumping; wait for its progress signal.
|
||||
await self._pump_cond.wait()
|
||||
return not self._exhausted
|
||||
self._pumping = True
|
||||
|
||||
try:
|
||||
try:
|
||||
part = await self._graph_aiter.__anext__()
|
||||
event = convert_to_protocol_event(part)
|
||||
self._observe_event(event)
|
||||
await self._mux.apush(event)
|
||||
return True
|
||||
except StopAsyncIteration:
|
||||
self._exhausted = True
|
||||
await self._mux.aclose()
|
||||
return False
|
||||
except Exception as e:
|
||||
self._exhausted = True
|
||||
await self._mux.afail(e)
|
||||
return False
|
||||
finally:
|
||||
async with self._pump_cond:
|
||||
self._pumping = False
|
||||
self._pump_cond.notify_all()
|
||||
|
||||
async def abort(self) -> None:
|
||||
"""Stop the run early.
|
||||
|
||||
Marks the stream exhausted, wakes any pump-waiters, and closes
|
||||
the mux. Any `apush` blocked on backpressure wakes and returns
|
||||
without appending. Idempotent.
|
||||
"""
|
||||
async with self._pump_cond:
|
||||
if self._exhausted:
|
||||
return
|
||||
self._exhausted = True
|
||||
self._pump_cond.notify_all()
|
||||
try:
|
||||
await self._mux.aclose()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def __aenter__(self) -> AsyncGraphRunStream:
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc: BaseException | None,
|
||||
tb: TracebackType | None,
|
||||
) -> None:
|
||||
await self.abort()
|
||||
|
||||
async def output(self) -> dict[str, Any] | None:
|
||||
"""Drive the run to completion and return the final state.
|
||||
|
||||
Methods (not properties) on the async lane so `run.output`
|
||||
without `await` raises at type-check time instead of silently
|
||||
yielding a coroutine object.
|
||||
|
||||
Example:
|
||||
```python
|
||||
output = await run.output()
|
||||
```
|
||||
|
||||
Raises:
|
||||
BaseException: If the run ended with an error.
|
||||
"""
|
||||
await _adrive_until_done(self._apump_next)
|
||||
if (err := self._mux._events._error) is not None:
|
||||
raise err
|
||||
return self._latest
|
||||
|
||||
async def interrupted(self) -> bool:
|
||||
"""Drive the run to completion and return whether it was
|
||||
interrupted.
|
||||
|
||||
Raises:
|
||||
BaseException: If the run ended with an error.
|
||||
"""
|
||||
await _adrive_until_done(self._apump_next)
|
||||
if (err := self._mux._events._error) is not None:
|
||||
raise err
|
||||
return self._interrupted
|
||||
|
||||
async def interrupts(self) -> list[Any]:
|
||||
"""Drive the run to completion and return interrupt payloads.
|
||||
|
||||
Raises:
|
||||
BaseException: If the run ended with an error.
|
||||
"""
|
||||
await _adrive_until_done(self._apump_next)
|
||||
if (err := self._mux._events._error) is not None:
|
||||
raise err
|
||||
return self._interrupts
|
||||
|
||||
def __aiter__(self) -> AsyncIterator[ProtocolEvent]:
|
||||
"""Subscribe to the main event log and iterate protocol events."""
|
||||
return self._mux._events.__aiter__()
|
||||
|
||||
|
||||
class _SubgraphRunStreamMixin:
|
||||
"""Subgraph metadata + parent-pump delegation shared by both lanes.
|
||||
|
||||
Inherits from `GraphRunStream` (or `AsyncGraphRunStream`) with
|
||||
`graph_iter=None` + `wire_pump=False` — the mini-mux is driven
|
||||
by the parent's pump (inherited via `StreamMux._make_child`), and
|
||||
the handle never pulls upstream itself. Pump-driving methods
|
||||
delegate to the parent pump so `handle.output` and friends drive
|
||||
the root run.
|
||||
|
||||
Subclasses set the parent pump function captured at construction
|
||||
(`_parent_pump_fn` / `_parent_apump_fn`) and override
|
||||
`_pump_next` / `_apump_next` to delegate to it.
|
||||
|
||||
Status is updated in place by `SubgraphTransformer`. Iterate
|
||||
`run.subgraphs` to receive handles as subgraphs spawn, then
|
||||
drill into projections inside the loop body **before** the next
|
||||
pump cycle — same lazy-subscribe constraint as root projections.
|
||||
"""
|
||||
|
||||
path: tuple[str, ...]
|
||||
graph_name: str | None
|
||||
trigger_call_id: str | None
|
||||
status: SubgraphStatus
|
||||
error: str | None
|
||||
_seen_terminal: bool
|
||||
|
||||
|
||||
class SubgraphRunStream(GraphRunStream, _SubgraphRunStreamMixin):
|
||||
"""Sync handle for a discovered subgraph (extends `GraphRunStream`)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
mux: StreamMux,
|
||||
*,
|
||||
path: tuple[str, ...],
|
||||
graph_name: str | None = None,
|
||||
trigger_call_id: str | None = None,
|
||||
) -> None:
|
||||
# Capture the parent-inherited pump before super().__init__
|
||||
# touches anything; we delegate to it from `_pump_next`.
|
||||
self._parent_pump_fn: Callable[[], bool] | None = mux._pump_fn
|
||||
super().__init__(
|
||||
graph_iter=None,
|
||||
mux=mux,
|
||||
wire_pump=False,
|
||||
)
|
||||
self.path = path
|
||||
self.graph_name = graph_name
|
||||
self.trigger_call_id = trigger_call_id
|
||||
self.status = "started"
|
||||
self.error = None
|
||||
self._seen_terminal = False
|
||||
|
||||
def _pump_next(self) -> bool:
|
||||
"""Delegate to the parent's pump.
|
||||
|
||||
Cursors on this handle's projections call here when their
|
||||
buffers empty. Driving the parent fans events into our
|
||||
mini-mux, transparently advancing the whole run.
|
||||
"""
|
||||
if (
|
||||
self._exhausted
|
||||
or self._seen_terminal
|
||||
or self._mux._events._closed
|
||||
or self._parent_pump_fn is None
|
||||
):
|
||||
return False
|
||||
return self._parent_pump_fn()
|
||||
|
||||
|
||||
class AsyncSubgraphRunStream(AsyncGraphRunStream, _SubgraphRunStreamMixin):
|
||||
"""Async handle for a discovered subgraph (extends `AsyncGraphRunStream`)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
mux: StreamMux,
|
||||
*,
|
||||
path: tuple[str, ...],
|
||||
graph_name: str | None = None,
|
||||
trigger_call_id: str | None = None,
|
||||
) -> None:
|
||||
self._parent_apump_fn: Callable[[], Awaitable[bool]] | None = mux._apump_fn
|
||||
super().__init__(
|
||||
graph_aiter=None,
|
||||
mux=mux,
|
||||
wire_pump=False,
|
||||
)
|
||||
self.path = path
|
||||
self.graph_name = graph_name
|
||||
self.trigger_call_id = trigger_call_id
|
||||
self.status = "started"
|
||||
self.error = None
|
||||
self._seen_terminal = False
|
||||
|
||||
async def _apump_next(self) -> bool:
|
||||
"""Delegate to the parent's async pump."""
|
||||
if (
|
||||
self._exhausted
|
||||
or self._seen_terminal
|
||||
or self._mux._events._closed
|
||||
or self._parent_apump_fn is None
|
||||
):
|
||||
return False
|
||||
return await self._parent_apump_fn()
|
||||
@@ -0,0 +1,341 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections import deque
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable, Iterator
|
||||
from typing import TYPE_CHECKING, Generic, TypeVar
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from langgraph.stream._mux import StreamMux
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class StreamChannel(Generic[T]):
|
||||
"""Single-consumer drainable queue for streaming events, with optional
|
||||
protocol auto-forwarding.
|
||||
|
||||
When constructed with a `name`, the StreamMux auto-wires every
|
||||
`push()` to also inject a `ProtocolEvent` into the main event stream
|
||||
using the channel's name as the method. When constructed without a
|
||||
name, the channel is local-only — items are only visible to
|
||||
in-process consumers that iterate the channel directly.
|
||||
|
||||
Items are popped off the front as the consumer advances — there is
|
||||
no retention beyond what's currently queued. A channel accepts
|
||||
exactly one subscriber; a second `__iter__` / `__aiter__` call
|
||||
raises. Use `tee(n)` / `atee(n)` for fan-out.
|
||||
|
||||
Starts unbound — neither `__iter__` nor `__aiter__` is available
|
||||
until the StreamMux calls `_bind(is_async)`. After binding, only
|
||||
the matching iteration protocol works; the other raises `TypeError`.
|
||||
|
||||
Pump wiring (set by the run stream, not by `_bind`):
|
||||
- `_request_more`: sync pump callable, returns True if a new
|
||||
event was produced.
|
||||
- `_arequest_more`: async pump coroutine factory, same contract.
|
||||
|
||||
Memory is bounded by caller pace: both sync and async use caller-
|
||||
driven pumps, so each cursor advance produces at most one event.
|
||||
|
||||
Lazy-subscribe: `push` appends to the local buffer only when a
|
||||
subscriber has registered. Auto-forward via `_wire_fn` always fires
|
||||
regardless of subscription state.
|
||||
|
||||
Lifecycle (`close` / `fail`) is managed by the mux — transformers
|
||||
don't need to close their channels manually.
|
||||
"""
|
||||
|
||||
def __init__(self, name: str | None = None, *, maxlen: int | None = None) -> None:
|
||||
"""Initialize the channel.
|
||||
|
||||
Args:
|
||||
name: Optional protocol channel name. When set, the
|
||||
StreamMux wires every `push()` to also inject a
|
||||
`ProtocolEvent` into the main event stream. Surfaced
|
||||
on the wire as `custom:<name>` for user-defined
|
||||
transformers, or as `<name>` for channels owned by a
|
||||
native transformer (`_native = True`). When `None`,
|
||||
the channel is local-only.
|
||||
maxlen: Accepted for forward compatibility; currently
|
||||
unused. The caller-driven pump bounds memory naturally
|
||||
for single-consumer use.
|
||||
|
||||
Raises:
|
||||
ValueError: If `maxlen` is not a positive integer or `None`.
|
||||
"""
|
||||
if maxlen is not None and maxlen <= 0:
|
||||
raise ValueError("StreamChannel maxlen must be a positive int or None")
|
||||
self.name = name
|
||||
self._items: deque[tuple[int, T]] = deque()
|
||||
self._maxlen: int | None = maxlen
|
||||
self._closed = False
|
||||
self._error: BaseException | None = None
|
||||
|
||||
self._is_async: bool | None = None
|
||||
|
||||
self._subscribed = False
|
||||
|
||||
self._request_more: Callable[[], bool] | None = None
|
||||
self._arequest_more: Callable[[], Awaitable[bool]] | None = None
|
||||
|
||||
self._wire_fn: Callable[[T], None] | None = None
|
||||
self._mux: StreamMux | None = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Binding
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _bind_mux(self, mux: StreamMux) -> None:
|
||||
self._mux = mux
|
||||
|
||||
def _bind(self, *, is_async: bool) -> None:
|
||||
"""Bind this channel to sync or async mode.
|
||||
|
||||
Called by the StreamMux after transformer registration. Must be
|
||||
called exactly once before any iteration.
|
||||
|
||||
Args:
|
||||
is_async: True to enable async iteration, False for sync.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the channel has already been bound.
|
||||
"""
|
||||
if self._is_async is not None:
|
||||
raise RuntimeError("StreamChannel is already bound")
|
||||
self._is_async = is_async
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Mux wiring (not called by transformers directly)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _wire(self, fn: Callable[[T], None]) -> None:
|
||||
"""Install the auto-forward callback (called by StreamMux)."""
|
||||
self._wire_fn = fn
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Producer API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def push(self, item: T) -> None:
|
||||
"""Append an item. Auto-forwards if wired.
|
||||
|
||||
The local buffer append is a no-op when no subscriber is
|
||||
registered, but auto-forwarding always fires so wired events
|
||||
reach the main event log regardless of subscription state.
|
||||
|
||||
Items are stored as `(stamp, item)` tuples where stamp is a
|
||||
monotonic counter from the owning mux. Stamps are stripped by
|
||||
the default cursors; raw stamped tuples are visible on `_items`.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the channel is closed (and subscribed).
|
||||
"""
|
||||
if self._subscribed:
|
||||
if self._closed:
|
||||
raise RuntimeError("Cannot push to a closed StreamChannel")
|
||||
stamp = self._mux._next_push_seq() if self._mux is not None else 0
|
||||
self._items.append((stamp, item))
|
||||
if self._wire_fn is not None:
|
||||
self._wire_fn(item)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Mark the channel as complete."""
|
||||
self._closed = True
|
||||
|
||||
def fail(self, err: BaseException) -> None:
|
||||
"""Mark the channel as errored.
|
||||
|
||||
Args:
|
||||
err: The exception to surface to the subscriber.
|
||||
"""
|
||||
self._error = err
|
||||
self._closed = True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Sync iteration (caller-driven pump)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def __iter__(self) -> Iterator[T]:
|
||||
"""Subscribe and return a sync cursor. Can be called only once.
|
||||
|
||||
Raises:
|
||||
TypeError: If the channel is unbound or bound to async mode.
|
||||
RuntimeError: If the channel already has a subscriber.
|
||||
"""
|
||||
if self._is_async is None:
|
||||
raise TypeError(
|
||||
"StreamChannel has not been bound yet. "
|
||||
"Register the transformer with a StreamMux first."
|
||||
)
|
||||
if self._is_async:
|
||||
raise TypeError(
|
||||
"This StreamChannel is bound to async mode — use 'async for' instead."
|
||||
)
|
||||
if self._subscribed:
|
||||
raise RuntimeError(
|
||||
"StreamChannel already has a subscriber; use .tee(n) for fan-out."
|
||||
)
|
||||
self._subscribed = True
|
||||
return self._sync_cursor()
|
||||
|
||||
def _sync_cursor(self) -> Iterator[T]:
|
||||
while True:
|
||||
if self._items:
|
||||
_stamp, item = self._items.popleft()
|
||||
yield item
|
||||
elif self._closed:
|
||||
if self._error is not None:
|
||||
raise self._error
|
||||
return
|
||||
elif self._request_more is not None:
|
||||
if not self._request_more():
|
||||
if not self._items and not self._closed:
|
||||
return
|
||||
else:
|
||||
return
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Async iteration (caller-driven pump)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def __aiter__(self) -> AsyncIterator[T]:
|
||||
"""Subscribe and return an async cursor. Can be called only once.
|
||||
|
||||
Raises:
|
||||
TypeError: If the channel is unbound or bound to sync mode.
|
||||
RuntimeError: If the channel already has a subscriber.
|
||||
"""
|
||||
if self._is_async is None:
|
||||
raise TypeError(
|
||||
"StreamChannel has not been bound yet. "
|
||||
"Register the transformer with a StreamMux first."
|
||||
)
|
||||
if not self._is_async:
|
||||
raise TypeError(
|
||||
"This StreamChannel is bound to sync mode — use 'for' instead."
|
||||
)
|
||||
if self._subscribed:
|
||||
raise RuntimeError(
|
||||
"StreamChannel already has a subscriber; use .atee(n) for fan-out."
|
||||
)
|
||||
self._subscribed = True
|
||||
return self._async_cursor()
|
||||
|
||||
async def _async_cursor(self) -> AsyncIterator[T]:
|
||||
while True:
|
||||
if self._items:
|
||||
_stamp, item = self._items.popleft()
|
||||
yield item
|
||||
elif self._closed:
|
||||
if self._error is not None:
|
||||
raise self._error
|
||||
return
|
||||
elif self._arequest_more is not None:
|
||||
if not await self._arequest_more():
|
||||
if not self._items and not self._closed:
|
||||
return
|
||||
else:
|
||||
return
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Fan-out via tee
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def tee(self, n: int = 2) -> tuple[Iterator[T], ...]:
|
||||
"""Subscribe and return `n` independent sync iterators.
|
||||
|
||||
Each branch has its own buffer; items pulled from the
|
||||
underlying cursor are copied into every branch. Branches are
|
||||
naturally bounded by caller pace since the sync pump is
|
||||
caller-driven.
|
||||
|
||||
Args:
|
||||
n: Number of branches to create. Must be >= 1.
|
||||
|
||||
Returns:
|
||||
A tuple of `n` iterators over the same underlying stream.
|
||||
|
||||
Raises:
|
||||
TypeError: If the channel is unbound or bound to async mode.
|
||||
RuntimeError: If the channel already has a subscriber.
|
||||
ValueError: If `n` < 1.
|
||||
"""
|
||||
if n < 1:
|
||||
raise ValueError("tee() requires n >= 1")
|
||||
source = self.__iter__()
|
||||
buffers: list[deque[T]] = [deque() for _ in range(n)]
|
||||
exhausted = [False]
|
||||
|
||||
def branch(i: int) -> Iterator[T]:
|
||||
buf = buffers[i]
|
||||
while True:
|
||||
if buf:
|
||||
yield buf.popleft()
|
||||
elif exhausted[0]:
|
||||
return
|
||||
else:
|
||||
try:
|
||||
item = next(source)
|
||||
except StopIteration:
|
||||
exhausted[0] = True
|
||||
return
|
||||
for b in buffers:
|
||||
b.append(item)
|
||||
|
||||
return tuple(branch(i) for i in range(n))
|
||||
|
||||
def atee(self, n: int = 2) -> tuple[AsyncIterator[T], ...]:
|
||||
"""Subscribe and return `n` independent async iterators.
|
||||
|
||||
Caller-driven fan-out: each branch's `__anext__` either pops
|
||||
from its own buffer or, under a shared `asyncio.Lock`, pulls
|
||||
one item from the underlying cursor and distributes it to
|
||||
every branch's buffer.
|
||||
|
||||
Args:
|
||||
n: Number of branches to create. Must be >= 1.
|
||||
|
||||
Returns:
|
||||
A tuple of `n` async iterators over the same underlying
|
||||
stream.
|
||||
|
||||
Raises:
|
||||
TypeError: If the channel is unbound or bound to sync mode.
|
||||
RuntimeError: If the channel already has a subscriber.
|
||||
ValueError: If `n` < 1.
|
||||
"""
|
||||
if n < 1:
|
||||
raise ValueError("atee() requires n >= 1")
|
||||
source = self.__aiter__()
|
||||
buffers: list[deque[T]] = [deque() for _ in range(n)]
|
||||
exhausted = [False]
|
||||
error: list[BaseException | None] = [None]
|
||||
lock = asyncio.Lock()
|
||||
|
||||
async def branch(i: int) -> AsyncIterator[T]:
|
||||
buf = buffers[i]
|
||||
while True:
|
||||
if buf:
|
||||
yield buf.popleft()
|
||||
continue
|
||||
if exhausted[0]:
|
||||
if error[0] is not None:
|
||||
raise error[0]
|
||||
return
|
||||
async with lock:
|
||||
if buf or exhausted[0]:
|
||||
continue
|
||||
try:
|
||||
item = await source.__anext__()
|
||||
except StopAsyncIteration:
|
||||
exhausted[0] = True
|
||||
continue
|
||||
except Exception as e:
|
||||
error[0] = e
|
||||
exhausted[0] = True
|
||||
continue
|
||||
for b in buffers:
|
||||
b.append(item)
|
||||
|
||||
return tuple(branch(i) for i in range(n))
|
||||
@@ -0,0 +1,928 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, Literal, cast
|
||||
|
||||
from langchain_core.language_models._compat_bridge import message_to_events
|
||||
from langchain_core.language_models.chat_model_stream import (
|
||||
AsyncChatModelStream,
|
||||
ChatModelStream,
|
||||
)
|
||||
from langchain_core.messages import AIMessageChunk, BaseMessage
|
||||
from langchain_protocol.protocol import MessagesData
|
||||
from typing_extensions import NotRequired, TypedDict
|
||||
|
||||
from langgraph.errors import GraphDrained, GraphInterrupt
|
||||
from langgraph.stream._types import ProtocolEvent, StreamTransformer
|
||||
from langgraph.stream.run_stream import AsyncSubgraphRunStream, SubgraphRunStream
|
||||
from langgraph.stream.stream_channel import StreamChannel
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from langgraph.stream._mux import StreamMux
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ValuesTransformer(StreamTransformer):
|
||||
"""Capture values events as a drainable stream of state snapshots.
|
||||
|
||||
Provides the `run.values` projection. `run.output`,
|
||||
`run.interrupted` and `run.interrupts` are tracked directly
|
||||
by the run stream and do not depend on this transformer.
|
||||
|
||||
Native transformer — projection keys are exposed as direct
|
||||
attributes on the run stream (e.g. `run.values`).
|
||||
|
||||
Only values events at the run's own level are captured; snapshots
|
||||
from deeper subgraphs are left in the main event log but excluded
|
||||
from the projection. "Own level" is defined by `scope`, which
|
||||
`stream_events(version="v3")` / `astream_events(version="v3")` populate from the caller's
|
||||
checkpoint namespace so that a nested `stream_events(version="v3")` call still
|
||||
sees its own root snapshots.
|
||||
"""
|
||||
|
||||
_native = True
|
||||
required_stream_modes = ("values",)
|
||||
|
||||
def __init__(self, scope: tuple[str, ...] = ()) -> None:
|
||||
super().__init__(scope)
|
||||
self._log: StreamChannel[dict[str, Any]] = StreamChannel()
|
||||
self._latest: dict[str, Any] | None = None
|
||||
self._interrupted = False
|
||||
self._interrupts: list[Any] = []
|
||||
# Cached as a list once for cheap equality with the protocol
|
||||
# event's `namespace` field, which is `list[str]`.
|
||||
self._scope_list: list[str] = list(scope)
|
||||
|
||||
def init(self) -> dict[str, Any]:
|
||||
return {"values": self._log}
|
||||
|
||||
@property
|
||||
def error(self) -> BaseException | None:
|
||||
"""The error that ended the run, or `None` if it succeeded.
|
||||
|
||||
Set by the mux when it auto-fails the projection log.
|
||||
"""
|
||||
return self._log._error
|
||||
|
||||
def process(self, event: ProtocolEvent) -> bool:
|
||||
if event["method"] != "values":
|
||||
return True
|
||||
params = event["params"]
|
||||
if params["namespace"] != self._scope_list:
|
||||
return True
|
||||
self._latest = params["data"]
|
||||
interrupts = params.get("interrupts", ())
|
||||
if interrupts:
|
||||
self._interrupted = True
|
||||
self._interrupts.extend(interrupts)
|
||||
self._log.push(params["data"])
|
||||
return True
|
||||
|
||||
|
||||
class CustomTransformer(StreamTransformer):
|
||||
"""Capture custom events as a drainable stream of arbitrary payloads.
|
||||
|
||||
Nodes emit custom data via `get_stream_writer()`. This transformer
|
||||
surfaces those events on `run.custom` as a `StreamChannel[Any]`,
|
||||
preserving payloads in arrival order.
|
||||
|
||||
Only events at the run's own scope are captured; custom data from
|
||||
deeper subgraphs is available on the respective subgraph handle's
|
||||
`.custom` projection.
|
||||
|
||||
Native transformer — `run.custom` is a direct attribute.
|
||||
"""
|
||||
|
||||
_native = True
|
||||
required_stream_modes = ("custom",)
|
||||
|
||||
def __init__(self, scope: tuple[str, ...] = ()) -> None:
|
||||
super().__init__(scope)
|
||||
self._log: StreamChannel[Any] = StreamChannel()
|
||||
self._scope_list: list[str] = list(scope)
|
||||
|
||||
def init(self) -> dict[str, Any]:
|
||||
return {"custom": self._log}
|
||||
|
||||
def process(self, event: ProtocolEvent) -> bool:
|
||||
if event["method"] != "custom":
|
||||
return True
|
||||
params = event["params"]
|
||||
if params["namespace"] != self._scope_list:
|
||||
return True
|
||||
self._log.push(params["data"])
|
||||
return True
|
||||
|
||||
|
||||
class UpdatesTransformer(StreamTransformer):
|
||||
"""Capture updates events as a drainable stream of node outputs.
|
||||
|
||||
Surfaces `stream_mode="updates"` data on `run.updates` as a
|
||||
`StreamChannel[dict[str, Any]]`. Each item is a dict mapping a node
|
||||
(or task) name to the update it returned after a step.
|
||||
|
||||
Only events at the run's own scope are captured; updates from deeper
|
||||
subgraphs are available on the respective subgraph handle's
|
||||
`.updates` projection.
|
||||
|
||||
Native transformer — `run.updates` is a direct attribute.
|
||||
"""
|
||||
|
||||
_native = True
|
||||
required_stream_modes = ("updates",)
|
||||
|
||||
def __init__(self, scope: tuple[str, ...] = ()) -> None:
|
||||
super().__init__(scope)
|
||||
self._log: StreamChannel[dict[str, Any]] = StreamChannel()
|
||||
self._scope_list: list[str] = list(scope)
|
||||
|
||||
def init(self) -> dict[str, Any]:
|
||||
return {"updates": self._log}
|
||||
|
||||
def process(self, event: ProtocolEvent) -> bool:
|
||||
if event["method"] != "updates":
|
||||
return True
|
||||
params = event["params"]
|
||||
if params["namespace"] != self._scope_list:
|
||||
return True
|
||||
self._log.push(params["data"])
|
||||
return True
|
||||
|
||||
|
||||
class MessagesTransformer(StreamTransformer):
|
||||
"""Capture messages events as ChatModelStream objects.
|
||||
|
||||
The messages projection yields one `ChatModelStream` (or
|
||||
`AsyncChatModelStream`) per LLM call. Consumers iterate
|
||||
`run.messages` to get stream handles, then use each handle's typed
|
||||
projections (`.text`, `.reasoning`, `.tool_calls`, `.usage`,
|
||||
`.output`) for per-message content.
|
||||
|
||||
Two input shapes are handled (via `params["data"] = (payload,
|
||||
metadata)` from `StreamMessagesHandler`):
|
||||
|
||||
1. Protocol event (dict with `"event"` key) — emitted by
|
||||
`stream_events(version="v3")` / `astream_events(version="v3")` via the `on_stream_event`
|
||||
callback. Routed to an existing `ChatModelStream` by
|
||||
`metadata["run_id"]`. A `message-start` event creates a new
|
||||
stream; `message-finish` closes it.
|
||||
2. Whole `AIMessage` — emitted from `on_chain_end` when a node
|
||||
returns a finalized message. Replayed as a synthetic protocol
|
||||
event lifecycle via `message_to_events`, then the
|
||||
already-complete stream is pushed to the log.
|
||||
|
||||
V1 `AIMessageChunk` tuples (from `on_llm_new_token`) are not
|
||||
streamed into this projection: chat models that want to populate
|
||||
`run.messages` with content-block streaming must use
|
||||
`stream_events(version="v3")` / `astream_events(version="v3")`. Models called via the legacy
|
||||
`stream()` method still surface their final `AIMessage` via
|
||||
`on_chain_end` when a node returns it as state.
|
||||
|
||||
Only events at the run's own level are projected; tokens from
|
||||
deeper subgraphs are left in the main event log but excluded from
|
||||
`.messages`. "Own level" is defined by `scope`, which
|
||||
`stream_events(version="v3")` / `astream_events(version="v3")` populate from the caller's checkpoint
|
||||
namespace so that a `stream_events(version="v3")` call inside a node still sees its
|
||||
own root chat model streams on `.messages`. Consumers that need
|
||||
subgraph tokens should iterate the raw event stream or register a
|
||||
custom transformer.
|
||||
|
||||
Native transformer — the `messages` projection is exposed as a
|
||||
direct attribute on the run stream.
|
||||
"""
|
||||
|
||||
_native = True
|
||||
required_stream_modes = ("messages",)
|
||||
|
||||
def __init__(self, scope: tuple[str, ...] = ()) -> None:
|
||||
super().__init__(scope)
|
||||
self._log: StreamChannel[ChatModelStream] = StreamChannel()
|
||||
# Correlate protocol events back to a ChatModelStream by run_id
|
||||
# (attached to the event's metadata by StreamMessagesHandler).
|
||||
self._by_run: dict[str, ChatModelStream] = {}
|
||||
self._pump_fn: Callable[[], bool] | None = None
|
||||
self._apump_fn: Callable[[], Awaitable[bool]] | None = None
|
||||
# Cached as a list once for cheap equality with the protocol
|
||||
# event's `namespace` field, which is `list[str]`.
|
||||
self._scope_list: list[str] = list(scope)
|
||||
|
||||
def init(self) -> dict[str, Any]:
|
||||
return {"messages": self._log}
|
||||
|
||||
def _bind_pump(self, fn: Callable[[], bool]) -> None:
|
||||
"""Wire the sync pull callback. Called by GraphRunStream._wire_request_more."""
|
||||
self._pump_fn = fn
|
||||
|
||||
def _bind_apump(self, fn: Callable[[], Awaitable[bool]]) -> None:
|
||||
"""Wire the async pull callback.
|
||||
|
||||
Called by `AsyncGraphRunStream._wire_arequest_more` so each
|
||||
`AsyncChatModelStream` this transformer creates can drive the
|
||||
shared graph pump from its projection cursors.
|
||||
"""
|
||||
self._apump_fn = fn
|
||||
|
||||
def _make_stream(
|
||||
self,
|
||||
*,
|
||||
namespace: list[str],
|
||||
node: str | None,
|
||||
message_id: str | None,
|
||||
) -> ChatModelStream:
|
||||
"""Create a ChatModelStream (sync) or AsyncChatModelStream (async).
|
||||
|
||||
Wires whichever pump is bound. Prefers the async pump so nested
|
||||
iteration under `AsyncGraphRunStream` drives the graph forward
|
||||
without a background task. The unwired fallback (no pump bound)
|
||||
is used by unit tests that dispatch events manually.
|
||||
"""
|
||||
if self._apump_fn is not None:
|
||||
astream = AsyncChatModelStream(
|
||||
namespace=namespace,
|
||||
node=node,
|
||||
message_id=message_id,
|
||||
)
|
||||
astream.set_arequest_more(self._apump_fn)
|
||||
return astream
|
||||
if self._pump_fn is not None:
|
||||
stream: ChatModelStream = ChatModelStream(
|
||||
namespace=namespace,
|
||||
node=node,
|
||||
message_id=message_id,
|
||||
)
|
||||
stream.set_request_more(self._pump_fn)
|
||||
return stream
|
||||
return AsyncChatModelStream(
|
||||
namespace=namespace,
|
||||
node=node,
|
||||
message_id=message_id,
|
||||
)
|
||||
|
||||
def process(self, event: ProtocolEvent) -> bool:
|
||||
if event["method"] != "messages":
|
||||
return True
|
||||
params = event["params"]
|
||||
if params["namespace"] != self._scope_list:
|
||||
return True
|
||||
|
||||
payload, metadata = params["data"]
|
||||
node: str | None = metadata.get("langgraph_node")
|
||||
run_id = str(metadata.get("run_id", "")) if metadata else ""
|
||||
|
||||
if isinstance(payload, dict) and "event" in payload:
|
||||
self._route_protocol_event(
|
||||
cast("MessagesData", payload), run_id=run_id, node=node
|
||||
)
|
||||
elif isinstance(payload, BaseMessage) and not isinstance(
|
||||
payload, AIMessageChunk
|
||||
):
|
||||
self._route_whole_message(payload, node=node)
|
||||
# Legacy AIMessageChunk tuples (from on_llm_new_token) are ignored;
|
||||
# v1 streaming callers must switch to stream_events(version="v3") to populate this
|
||||
# projection.
|
||||
|
||||
return True
|
||||
|
||||
def _route_protocol_event(
|
||||
self,
|
||||
event: MessagesData,
|
||||
*,
|
||||
run_id: str,
|
||||
node: str | None,
|
||||
) -> None:
|
||||
event_type = event.get("event")
|
||||
if event_type == "message-start":
|
||||
message_id = event.get("message_id")
|
||||
stream = self._make_stream(
|
||||
namespace=[],
|
||||
node=node,
|
||||
message_id=str(message_id) if message_id is not None else None,
|
||||
)
|
||||
self._by_run[run_id] = stream
|
||||
self._log.push(stream)
|
||||
stream.dispatch(event)
|
||||
elif run_id in self._by_run:
|
||||
stream = self._by_run[run_id]
|
||||
stream.dispatch(event)
|
||||
if event_type == "message-finish":
|
||||
del self._by_run[run_id]
|
||||
|
||||
def _route_whole_message(self, message: BaseMessage, *, node: str | None) -> None:
|
||||
stream = self._make_stream(namespace=[], node=node, message_id=message.id)
|
||||
for evt in message_to_events(message, message_id=message.id):
|
||||
stream.dispatch(evt)
|
||||
self._log.push(stream)
|
||||
|
||||
def finalize(self) -> None:
|
||||
"""Clear any routing state — streams close themselves via `message-finish`."""
|
||||
self._by_run.clear()
|
||||
|
||||
def fail(self, err: BaseException) -> None:
|
||||
"""Propagate run error to any streams still open when the graph fails."""
|
||||
for stream in list(self._by_run.values()):
|
||||
stream.fail(err)
|
||||
self._by_run.clear()
|
||||
|
||||
|
||||
SubgraphStatus = Literal["started", "completed", "failed", "interrupted", "drained"]
|
||||
|
||||
|
||||
def _parse_ns_segment(segment: str) -> tuple[str, str | None]:
|
||||
"""Split a namespace segment into `(graph_name, trigger_call_id)`.
|
||||
|
||||
Segments are formatted `node_name:task_id` by `prepare_next_tasks`.
|
||||
Returns `(segment, None)` if no `:` is present.
|
||||
"""
|
||||
name, sep, task_id = segment.partition(":")
|
||||
return name, task_id if sep else None
|
||||
|
||||
|
||||
class LifecyclePayload(TypedDict, total=False):
|
||||
"""Payload of a lifecycle event surfaced on the `lifecycle` channel.
|
||||
|
||||
Auto-forwarded as `lifecycle` protocol events (no `custom:` prefix
|
||||
because `LifecycleTransformer` is a native transformer) so remote
|
||||
SDK clients receive the same data in-process consumers see via
|
||||
`run.lifecycle`.
|
||||
"""
|
||||
|
||||
event: SubgraphStatus
|
||||
namespace: list[str]
|
||||
graph_name: NotRequired[str]
|
||||
trigger_call_id: NotRequired[str]
|
||||
error: NotRequired[str]
|
||||
|
||||
|
||||
class _TasksLifecycleBase(StreamTransformer):
|
||||
"""Shared bookkeeping for `tasks`-event-driven lifecycle inference.
|
||||
|
||||
Both `LifecycleTransformer` (wire-serializable channel) and
|
||||
`SubgraphTransformer` (in-process navigation handles) discover
|
||||
subgraphs by watching the same `tasks` stream — `started` on the
|
||||
first event at a tracked namespace, terminal status when the
|
||||
parent's `TaskResultPayload` arrives. Centralizing the dispatch
|
||||
+ open-set bookkeeping here keeps the inference rules from
|
||||
drifting between the two surfaces.
|
||||
|
||||
Subclasses provide three template-method hooks:
|
||||
|
||||
- `_should_track(ns)` — scope filter (e.g. multi-depth vs
|
||||
direct-children-only).
|
||||
- `_on_started(ns, graph_name, trigger_call_id)` — first sighting
|
||||
action (push payload / build handle / etc.). Called once per
|
||||
discovered namespace.
|
||||
- `_on_terminal(ns, status, error)` — terminal action (push
|
||||
terminal payload / mark handle status). Called once per
|
||||
tracked namespace at result time, or via `finalize` / `fail`
|
||||
sweeps if no parent result arrived.
|
||||
|
||||
Tasks events are suppressed from the main event log (`process`
|
||||
returns False) — they're folded into whichever projection the
|
||||
subclass populates; consumers iterating the raw protocol stream
|
||||
see the higher-level view.
|
||||
"""
|
||||
|
||||
required_stream_modes = ("tasks",)
|
||||
|
||||
def __init__(self, scope: tuple[str, ...] = ()) -> None:
|
||||
super().__init__(scope)
|
||||
self._seen: set[tuple[str, ...]] = set()
|
||||
# Maps tracked namespace -> task_id of the parent task whose
|
||||
# `TaskResultPayload` will close it.
|
||||
self._open: dict[tuple[str, ...], str] = {}
|
||||
|
||||
# --- Template-method hooks (subclass overrides) ---
|
||||
|
||||
def _should_track(self, ns: tuple[str, ...]) -> bool:
|
||||
"""Scope filter — return True iff `ns` is in this transformer's region."""
|
||||
raise NotImplementedError
|
||||
|
||||
def _on_started(
|
||||
self,
|
||||
ns: tuple[str, ...],
|
||||
graph_name: str | None,
|
||||
trigger_call_id: str | None,
|
||||
) -> None:
|
||||
"""Fired once per discovered namespace (first observed task event)."""
|
||||
raise NotImplementedError
|
||||
|
||||
def _on_terminal(
|
||||
self,
|
||||
ns: tuple[str, ...],
|
||||
status: SubgraphStatus,
|
||||
error: str | None,
|
||||
) -> None:
|
||||
"""Fired once per tracked namespace when its parent's result arrives,
|
||||
or via finalize/fail safety-net sweeps.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
# --- Dispatch + bookkeeping (shared) ---
|
||||
|
||||
def process(self, event: ProtocolEvent) -> bool:
|
||||
if event["method"] != "tasks":
|
||||
return True
|
||||
ns = tuple(event["params"]["namespace"])
|
||||
data = event["params"]["data"]
|
||||
if "result" in data:
|
||||
self._handle_task_result(ns, data)
|
||||
else:
|
||||
self._handle_task_start(ns)
|
||||
# Tasks events are folded into the synthesized projections;
|
||||
# suppress from the main event log so iterators don't double-see
|
||||
# the same information in two shapes.
|
||||
return False
|
||||
|
||||
def _handle_task_start(self, ns: tuple[str, ...]) -> None:
|
||||
if not self._should_track(ns) or ns in self._seen:
|
||||
return
|
||||
self._seen.add(ns)
|
||||
graph_name, trigger_call_id = _parse_ns_segment(ns[-1])
|
||||
self._on_started(ns, graph_name or None, trigger_call_id)
|
||||
if trigger_call_id is not None:
|
||||
self._open[ns] = trigger_call_id
|
||||
|
||||
def _pop_terminal_transitions(
|
||||
self, ns: tuple[str, ...], data: dict[str, Any]
|
||||
) -> list[tuple[tuple[str, ...], SubgraphStatus, str | None]]:
|
||||
"""Return and remove tracked children closed by this task result."""
|
||||
result_id = data.get("id")
|
||||
if not result_id:
|
||||
return []
|
||||
transitions: list[tuple[tuple[str, ...], SubgraphStatus, str | None]] = []
|
||||
for child_ns, parent_task_id in list(self._open.items()):
|
||||
if child_ns[:-1] != ns or parent_task_id != result_id:
|
||||
continue
|
||||
status, error = _terminal_from_result(data)
|
||||
transitions.append((child_ns, status, error))
|
||||
del self._open[child_ns]
|
||||
return transitions
|
||||
|
||||
def _handle_task_result(self, ns: tuple[str, ...], data: dict[str, Any]) -> None:
|
||||
for child_ns, status, error in self._pop_terminal_transitions(ns, data):
|
||||
self._on_terminal(child_ns, status, error)
|
||||
|
||||
def finalize(self) -> None:
|
||||
"""Emit `completed` for any tracked namespace still open at run end."""
|
||||
for ns in list(self._open):
|
||||
self._on_terminal(ns, "completed", None)
|
||||
self._open.clear()
|
||||
|
||||
def fail(self, err: BaseException) -> None:
|
||||
"""Emit terminal status for any tracked namespace still open."""
|
||||
status, error_str = _status_from_exception(err)
|
||||
for ns in list(self._open):
|
||||
self._on_terminal(ns, status, error_str)
|
||||
self._open.clear()
|
||||
|
||||
|
||||
def _status_from_exception(err: BaseException) -> tuple[SubgraphStatus, str | None]:
|
||||
"""Map a run exception to a subgraph terminal status and error string."""
|
||||
if isinstance(err, GraphDrained):
|
||||
return "drained", None
|
||||
if isinstance(err, GraphInterrupt):
|
||||
return "interrupted", None
|
||||
return "failed", str(err)
|
||||
|
||||
|
||||
def _terminal_from_result(
|
||||
payload: dict[str, Any],
|
||||
) -> tuple[SubgraphStatus, str | None]:
|
||||
"""Map a `TaskResultPayload` to a `(status, error)` pair.
|
||||
|
||||
Order matters: a result with both `error` and `interrupts` prefers
|
||||
the interrupt classification, since `GraphInterrupt` manifests as
|
||||
a populated `interrupts` list, not as `error`.
|
||||
"""
|
||||
if payload.get("interrupts"):
|
||||
return "interrupted", None
|
||||
error = payload.get("error")
|
||||
if error:
|
||||
return "failed", str(error)
|
||||
return "completed", None
|
||||
|
||||
|
||||
class LifecycleTransformer(_TasksLifecycleBase):
|
||||
"""Surface subgraph lifecycle as `lifecycle` protocol events.
|
||||
|
||||
Pushes `LifecyclePayload` to a `StreamChannel` named `lifecycle`.
|
||||
The channel is auto-forwarded by the mux so payloads land in the
|
||||
main event log under `method = "lifecycle"` (native transformer —
|
||||
no `custom:` prefix) — visible to remote SDK clients over the
|
||||
wire and to in-process consumers via `run.lifecycle`.
|
||||
|
||||
Tracks subgraphs at every depth strictly below the transformer's
|
||||
scope, so a graph → subgraph → subgraph chain produces lifecycle
|
||||
events for both nested levels in a flat stream.
|
||||
|
||||
Native transformer — projection key `lifecycle` is exposed as
|
||||
`run.lifecycle`.
|
||||
"""
|
||||
|
||||
_native = True
|
||||
|
||||
def __init__(self, scope: tuple[str, ...] = ()) -> None:
|
||||
super().__init__(scope)
|
||||
self._channel: StreamChannel[LifecyclePayload] = StreamChannel("lifecycle")
|
||||
|
||||
def init(self) -> dict[str, Any]:
|
||||
return {"lifecycle": self._channel}
|
||||
|
||||
def _should_track(self, ns: tuple[str, ...]) -> bool:
|
||||
depth = len(self.scope)
|
||||
return len(ns) > depth and ns[:depth] == self.scope
|
||||
|
||||
def _on_started(
|
||||
self,
|
||||
ns: tuple[str, ...],
|
||||
graph_name: str | None,
|
||||
trigger_call_id: str | None,
|
||||
) -> None:
|
||||
if trigger_call_id is None:
|
||||
# Without a task id we can't correlate a parent-result
|
||||
# event back to this namespace — skip the started payload
|
||||
# and rely on finalize/fail to close.
|
||||
return
|
||||
payload: LifecyclePayload = {"event": "started", "namespace": list(ns)}
|
||||
if graph_name:
|
||||
payload["graph_name"] = graph_name
|
||||
payload["trigger_call_id"] = trigger_call_id
|
||||
self._channel.push(payload)
|
||||
|
||||
def _on_terminal(
|
||||
self,
|
||||
ns: tuple[str, ...],
|
||||
status: SubgraphStatus,
|
||||
error: str | None,
|
||||
) -> None:
|
||||
payload: LifecyclePayload = {"event": status, "namespace": list(ns)}
|
||||
if error is not None:
|
||||
payload["error"] = error
|
||||
self._channel.push(payload)
|
||||
|
||||
|
||||
class SubgraphTransformer(_TasksLifecycleBase):
|
||||
"""Discover subgraph invocations as in-process navigation handles.
|
||||
|
||||
Per discovered direct-child subgraph, builds a `SubgraphRunStream`
|
||||
(or `AsyncSubgraphRunStream`) wrapping a child mini-mux scoped to
|
||||
the subgraph's namespace. Consumers iterate `run.subgraphs` to
|
||||
receive handles, then drill into `handle.values` / `handle.messages`
|
||||
/ `handle.subgraphs` (recursive grandchildren) / `handle.lifecycle`.
|
||||
|
||||
Each mini-mux owns its own scope and uses its own
|
||||
`SubgraphTransformer` to discover its direct children, so
|
||||
grandchildren live on the child handle — never on the root's
|
||||
`subgraphs` log. Forwarding events into the matching child mini-mux
|
||||
is what keeps the child's projections populated.
|
||||
|
||||
Native transformer — `subgraphs` is exposed as `run.subgraphs`.
|
||||
"""
|
||||
|
||||
_native = True
|
||||
supports_sync = True
|
||||
|
||||
def __init__(self, scope: tuple[str, ...] = ()) -> None:
|
||||
super().__init__(scope)
|
||||
self._log: StreamChannel[SubgraphRunStream | AsyncSubgraphRunStream] = (
|
||||
StreamChannel()
|
||||
)
|
||||
self._handles: dict[
|
||||
tuple[str, ...], SubgraphRunStream | AsyncSubgraphRunStream
|
||||
] = {}
|
||||
self._mux: StreamMux | None = None
|
||||
|
||||
def init(self) -> dict[str, Any]:
|
||||
return {"subgraphs": self._log}
|
||||
|
||||
def _on_register(self, mux: Any) -> None:
|
||||
self._mux = mux
|
||||
|
||||
def _should_track(self, ns: tuple[str, ...]) -> bool:
|
||||
# Direct children only — grandchildren are picked up by the
|
||||
# child mini-mux's own SubgraphTransformer.
|
||||
depth = len(self.scope)
|
||||
return len(ns) == depth + 1 and ns[:depth] == self.scope
|
||||
|
||||
def _on_started(
|
||||
self,
|
||||
ns: tuple[str, ...],
|
||||
graph_name: str | None,
|
||||
trigger_call_id: str | None,
|
||||
) -> None:
|
||||
if self._mux is None:
|
||||
return
|
||||
try:
|
||||
child_mux = self._mux._make_child(ns)
|
||||
except RuntimeError:
|
||||
return
|
||||
handle_cls = AsyncSubgraphRunStream if child_mux.is_async else SubgraphRunStream
|
||||
handle = handle_cls(
|
||||
mux=child_mux,
|
||||
path=ns,
|
||||
graph_name=graph_name,
|
||||
trigger_call_id=trigger_call_id,
|
||||
)
|
||||
self._handles[ns] = handle
|
||||
self._log.push(handle)
|
||||
|
||||
def _on_terminal(
|
||||
self,
|
||||
ns: tuple[str, ...],
|
||||
status: SubgraphStatus,
|
||||
error: str | None,
|
||||
) -> None:
|
||||
handle = self._handles.get(ns)
|
||||
if handle is None or not self._mark_terminal(handle, status, error):
|
||||
return
|
||||
self._close_or_fail_handle(handle, status, error)
|
||||
|
||||
async def _aon_terminal(
|
||||
self,
|
||||
ns: tuple[str, ...],
|
||||
status: SubgraphStatus,
|
||||
error: str | None,
|
||||
) -> None:
|
||||
handle = self._handles.get(ns)
|
||||
if handle is None or not self._mark_terminal(handle, status, error):
|
||||
return
|
||||
await self._aclose_or_fail_handle(handle, status, error)
|
||||
|
||||
def _mark_terminal(
|
||||
self,
|
||||
handle: SubgraphRunStream | AsyncSubgraphRunStream,
|
||||
status: SubgraphStatus,
|
||||
error: str | None,
|
||||
) -> bool:
|
||||
"""Mark a handle terminal once. Returns True on first transition."""
|
||||
if handle._seen_terminal:
|
||||
return False
|
||||
handle.status = status
|
||||
if error is not None and handle.error is None:
|
||||
handle.error = error
|
||||
handle._seen_terminal = True
|
||||
return True
|
||||
|
||||
def _close_or_fail_handle(
|
||||
self,
|
||||
handle: SubgraphRunStream | AsyncSubgraphRunStream,
|
||||
status: SubgraphStatus,
|
||||
error: str | None,
|
||||
) -> None:
|
||||
if handle._mux is None or handle._mux._events._closed:
|
||||
return
|
||||
if status == "failed":
|
||||
handle._mux.fail(RuntimeError(error or "Subgraph failed"))
|
||||
else:
|
||||
handle._mux.close()
|
||||
|
||||
async def _aclose_or_fail_handle(
|
||||
self,
|
||||
handle: SubgraphRunStream | AsyncSubgraphRunStream,
|
||||
status: SubgraphStatus,
|
||||
error: str | None,
|
||||
) -> None:
|
||||
if handle._mux is None or handle._mux._events._closed:
|
||||
return
|
||||
if status == "failed":
|
||||
await handle._mux.afail(RuntimeError(error or "Subgraph failed"))
|
||||
else:
|
||||
await handle._mux.aclose()
|
||||
|
||||
def _handle_for_event(
|
||||
self, event: ProtocolEvent
|
||||
) -> SubgraphRunStream | AsyncSubgraphRunStream | None:
|
||||
ns = tuple(event["params"]["namespace"])
|
||||
depth = len(self.scope)
|
||||
if len(ns) < depth + 1:
|
||||
return None
|
||||
handle = self._handles.get(ns[: depth + 1])
|
||||
if handle is None or handle._mux is None or handle._mux._events._closed:
|
||||
return None
|
||||
return handle
|
||||
|
||||
def process(self, event: ProtocolEvent) -> bool:
|
||||
# Run tasks bookkeeping first so a `started` handle exists
|
||||
# by the time we forward the event to the child mini-mux.
|
||||
keep = super().process(event)
|
||||
handle = self._handle_for_event(event)
|
||||
if handle is not None:
|
||||
handle._observe_event(event)
|
||||
handle._mux.push(event)
|
||||
return keep
|
||||
|
||||
async def aprocess(self, event: ProtocolEvent) -> bool:
|
||||
# Async counterpart: repeats the tasks bookkeeping here so
|
||||
# child mini-muxes receive events through their async lane.
|
||||
if event["method"] == "tasks":
|
||||
ns = tuple(event["params"]["namespace"])
|
||||
data = event["params"]["data"]
|
||||
if "result" in data:
|
||||
for child_ns, status, error in self._pop_terminal_transitions(ns, data):
|
||||
await self._aon_terminal(child_ns, status, error)
|
||||
else:
|
||||
self._handle_task_start(ns)
|
||||
keep = False
|
||||
else:
|
||||
keep = True
|
||||
handle = self._handle_for_event(event)
|
||||
if handle is not None:
|
||||
handle._observe_event(event)
|
||||
await handle._mux.apush(event)
|
||||
return keep
|
||||
|
||||
def _complete_open_handles(self) -> BaseException | None:
|
||||
first_error: BaseException | None = None
|
||||
for ns in list(self._open):
|
||||
try:
|
||||
self._on_terminal(ns, "completed", None)
|
||||
except BaseException as e:
|
||||
if first_error is None:
|
||||
first_error = e
|
||||
self._open.clear()
|
||||
for handle in self._handles.values():
|
||||
if self._mark_terminal(handle, "completed", None):
|
||||
try:
|
||||
self._close_or_fail_handle(handle, "completed", None)
|
||||
except BaseException as e:
|
||||
if first_error is None:
|
||||
first_error = e
|
||||
return first_error
|
||||
|
||||
async def _acomplete_open_handles(self) -> BaseException | None:
|
||||
first_error: BaseException | None = None
|
||||
for ns in list(self._open):
|
||||
try:
|
||||
await self._aon_terminal(ns, "completed", None)
|
||||
except BaseException as e:
|
||||
if first_error is None:
|
||||
first_error = e
|
||||
self._open.clear()
|
||||
for handle in self._handles.values():
|
||||
if self._mark_terminal(handle, "completed", None):
|
||||
try:
|
||||
await self._aclose_or_fail_handle(handle, "completed", None)
|
||||
except BaseException as e:
|
||||
if first_error is None:
|
||||
first_error = e
|
||||
return first_error
|
||||
|
||||
def finalize(self) -> None:
|
||||
first_error = self._complete_open_handles()
|
||||
if first_error is not None:
|
||||
raise first_error
|
||||
|
||||
async def afinalize(self) -> None:
|
||||
first_error = await self._acomplete_open_handles()
|
||||
if first_error is not None:
|
||||
raise first_error
|
||||
|
||||
def fail(self, err: BaseException) -> None:
|
||||
status, error_str = _status_from_exception(err)
|
||||
self._open.clear()
|
||||
for handle in self._handles.values():
|
||||
self._mark_terminal(handle, status, error_str)
|
||||
if handle._mux is not None and not handle._mux._events._closed:
|
||||
try:
|
||||
handle._mux.fail(err)
|
||||
except Exception:
|
||||
_logger.warning(
|
||||
"Error failing subgraph mini-mux at %s; "
|
||||
"subscribers may not see the terminal error.",
|
||||
handle.path,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
async def afail(self, err: BaseException) -> None:
|
||||
status, error_str = _status_from_exception(err)
|
||||
self._open.clear()
|
||||
for handle in self._handles.values():
|
||||
self._mark_terminal(handle, status, error_str)
|
||||
if handle._mux is not None and not handle._mux._events._closed:
|
||||
try:
|
||||
await handle._mux.afail(err)
|
||||
except Exception:
|
||||
_logger.warning(
|
||||
"Error failing subgraph mini-mux at %s; "
|
||||
"subscribers may not see the terminal error.",
|
||||
handle.path,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
class CheckpointsTransformer(StreamTransformer):
|
||||
"""Capture checkpoint events as a drainable stream.
|
||||
|
||||
Surfaces `stream_mode="checkpoints"` data on `run.checkpoints` as
|
||||
a `StreamChannel[dict[str, Any]]`. Each item is in the same format
|
||||
as returned by `get_state()`.
|
||||
|
||||
Checkpoint events are only emitted when a checkpointer is configured
|
||||
on the graph. When no checkpointer is present, the projection exists
|
||||
but receives no events.
|
||||
|
||||
Only events at the run's own scope are captured; checkpoint data from
|
||||
deeper subgraphs is available on the respective subgraph handle's
|
||||
`.checkpoints` projection.
|
||||
|
||||
Native transformer — `run.checkpoints` is a direct attribute.
|
||||
"""
|
||||
|
||||
_native = True
|
||||
required_stream_modes = ("checkpoints",)
|
||||
|
||||
def __init__(self, scope: tuple[str, ...] = ()) -> None:
|
||||
super().__init__(scope)
|
||||
self._log: StreamChannel[dict[str, Any]] = StreamChannel()
|
||||
self._scope_list: list[str] = list(scope)
|
||||
|
||||
def init(self) -> dict[str, Any]:
|
||||
return {"checkpoints": self._log}
|
||||
|
||||
def process(self, event: ProtocolEvent) -> bool:
|
||||
if event["method"] != "checkpoints":
|
||||
return True
|
||||
params = event["params"]
|
||||
if params["namespace"] != self._scope_list:
|
||||
return True
|
||||
self._log.push(params["data"])
|
||||
return True
|
||||
|
||||
|
||||
class DebugTransformer(StreamTransformer):
|
||||
"""Capture debug events as a drainable stream.
|
||||
|
||||
Surfaces `stream_mode="debug"` data on `run.debug` as a
|
||||
`StreamChannel[dict[str, Any]]`. Each item is a debug event with
|
||||
step-level detail (checkpoint snapshots, task payloads, and
|
||||
task results wrapped with step number and timestamp).
|
||||
|
||||
Only events at the run's own scope are captured; debug data from
|
||||
deeper subgraphs is available on the respective subgraph handle's
|
||||
`.debug` projection.
|
||||
|
||||
Native transformer — `run.debug` is a direct attribute.
|
||||
"""
|
||||
|
||||
_native = True
|
||||
required_stream_modes = ("debug",)
|
||||
|
||||
def __init__(self, scope: tuple[str, ...] = ()) -> None:
|
||||
super().__init__(scope)
|
||||
self._log: StreamChannel[dict[str, Any]] = StreamChannel()
|
||||
self._scope_list: list[str] = list(scope)
|
||||
|
||||
def init(self) -> dict[str, Any]:
|
||||
return {"debug": self._log}
|
||||
|
||||
def process(self, event: ProtocolEvent) -> bool:
|
||||
if event["method"] != "debug":
|
||||
return True
|
||||
params = event["params"]
|
||||
if params["namespace"] != self._scope_list:
|
||||
return True
|
||||
self._log.push(params["data"])
|
||||
return True
|
||||
|
||||
|
||||
class TasksTransformer(StreamTransformer):
|
||||
"""Capture raw task events as a drainable stream.
|
||||
|
||||
Surfaces `stream_mode="tasks"` data on `run.tasks` as a
|
||||
`StreamChannel[dict[str, Any]]`. Each item is a task payload
|
||||
(start or result).
|
||||
|
||||
`LifecycleTransformer` and `SubgraphTransformer` also consume
|
||||
`tasks` events for subgraph discovery and lifecycle tracking.
|
||||
This transformer captures the raw payloads independently for
|
||||
consumers who need task-level detail.
|
||||
|
||||
Only events at the run's own scope are captured; task data from
|
||||
deeper subgraphs is available on the respective subgraph handle's
|
||||
`.tasks` projection.
|
||||
|
||||
Native transformer — `run.tasks` is a direct attribute.
|
||||
"""
|
||||
|
||||
_native = True
|
||||
required_stream_modes = ("tasks",)
|
||||
|
||||
def __init__(self, scope: tuple[str, ...] = ()) -> None:
|
||||
super().__init__(scope)
|
||||
self._log: StreamChannel[dict[str, Any]] = StreamChannel()
|
||||
self._scope_list: list[str] = list(scope)
|
||||
|
||||
def init(self) -> dict[str, Any]:
|
||||
return {"tasks": self._log}
|
||||
|
||||
def process(self, event: ProtocolEvent) -> bool:
|
||||
if event["method"] != "tasks":
|
||||
return True
|
||||
params = event["params"]
|
||||
if params["namespace"] != self._scope_list:
|
||||
return True
|
||||
self._log.push(params["data"])
|
||||
return True
|
||||
@@ -4,6 +4,7 @@ import sys
|
||||
from collections import deque
|
||||
from collections.abc import Callable, Hashable, Sequence
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import timedelta
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
@@ -67,6 +68,7 @@ __all__ = (
|
||||
"CheckpointPayload",
|
||||
"DebugPayload",
|
||||
"RetryPolicy",
|
||||
"TimeoutPolicy",
|
||||
"CachePolicy",
|
||||
"Interrupt",
|
||||
"StateUpdate",
|
||||
@@ -423,6 +425,83 @@ class RetryPolicy(NamedTuple):
|
||||
"""List of exception classes that should trigger a retry, or a callable that returns `True` for exceptions that should trigger a retry."""
|
||||
|
||||
|
||||
def _coerce_timeout_seconds(
|
||||
value: float | timedelta | None, *, field: str
|
||||
) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
seconds = value.total_seconds() if isinstance(value, timedelta) else float(value)
|
||||
if seconds <= 0:
|
||||
raise ValueError(f"{field} must be greater than 0")
|
||||
return seconds
|
||||
|
||||
|
||||
@dataclass(**_DC_KWARGS)
|
||||
class TimeoutPolicy:
|
||||
"""Configuration for timing out node attempts.
|
||||
|
||||
!!! note "Cooperative cancellation"
|
||||
|
||||
Timeouts rely on asyncio cancellation. If your node uses synchronous
|
||||
time.sleep() or other CPU-bound work that blocks the GIL, the timeout will not
|
||||
be fired until after the event loop has been released.
|
||||
|
||||
!!! note "Inline callback dispatch"
|
||||
|
||||
Under `refresh_on="auto"`, an internal handler refreshes the timeout on any
|
||||
callback event that occurs in the execution of the node or its nested descendants.
|
||||
"""
|
||||
|
||||
run_timeout: float | timedelta | None = None
|
||||
"""Hard wall-clock cap (in seconds) for a single node attempt.
|
||||
|
||||
This timeout is never refreshed by progress signals or `runtime.heartbeat()`.
|
||||
"""
|
||||
|
||||
idle_timeout: float | timedelta | None = None
|
||||
"""Maximum time (in seconds) a single node attempt may go without observable progress."""
|
||||
|
||||
refresh_on: Literal["auto", "heartbeat"] = "auto"
|
||||
"""Which signals refresh `idle_timeout`.
|
||||
|
||||
`"auto"` refreshes on standard graph progress signals and explicit heartbeats.
|
||||
`"heartbeat"` refreshes only on explicit `runtime.heartbeat()` calls.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def coerce(
|
||||
cls, value: float | timedelta | TimeoutPolicy | None
|
||||
) -> TimeoutPolicy | None:
|
||||
"""Normalize a timeout value to positive-second policy fields."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, TimeoutPolicy):
|
||||
# Fast path: a policy already produced by coerce() has float
|
||||
# timeouts and a validated refresh_on, so we can return it as-is.
|
||||
# `frozen=True` makes this safe to share.
|
||||
rt, it = value.run_timeout, value.idle_timeout
|
||||
if (
|
||||
value.refresh_on in ("auto", "heartbeat")
|
||||
and (rt is None or (type(rt) is float and rt > 0))
|
||||
and (it is None or (type(it) is float and it > 0))
|
||||
and (rt is not None or it is not None)
|
||||
):
|
||||
return value
|
||||
else:
|
||||
value = cls(run_timeout=value)
|
||||
if value.refresh_on not in ("auto", "heartbeat"):
|
||||
raise ValueError("refresh_on must be 'auto' or 'heartbeat'")
|
||||
run_timeout = _coerce_timeout_seconds(value.run_timeout, field="run_timeout")
|
||||
idle_timeout = _coerce_timeout_seconds(value.idle_timeout, field="idle_timeout")
|
||||
if run_timeout is None and idle_timeout is None:
|
||||
return None
|
||||
return cls(
|
||||
run_timeout=run_timeout,
|
||||
idle_timeout=idle_timeout,
|
||||
refresh_on=value.refresh_on,
|
||||
)
|
||||
|
||||
|
||||
KeyFuncT = TypeVar("KeyFuncT", bound=Callable[..., str | bytes])
|
||||
|
||||
|
||||
@@ -548,6 +627,7 @@ class PregelExecutableTask:
|
||||
path: tuple[str | int | tuple, ...]
|
||||
writers: Sequence[Runnable] = ()
|
||||
subgraphs: Sequence[PregelProtocol] = ()
|
||||
timeout: TimeoutPolicy | None = None
|
||||
|
||||
|
||||
class StateSnapshot(NamedTuple):
|
||||
@@ -587,6 +667,8 @@ class Send:
|
||||
Attributes:
|
||||
node (str): The name of the target node to send the message to.
|
||||
arg (Any): The state or message to send to the target node.
|
||||
timeout (TimeoutPolicy | None): Optional timeout policy for this specific
|
||||
pushed task. If omitted, the target node's timeout policy is used.
|
||||
|
||||
!!! example
|
||||
|
||||
@@ -616,33 +698,47 @@ class Send:
|
||||
```
|
||||
"""
|
||||
|
||||
__slots__ = ("node", "arg")
|
||||
__slots__ = ("node", "arg", "timeout")
|
||||
|
||||
node: str
|
||||
arg: Any
|
||||
timeout: TimeoutPolicy | None
|
||||
|
||||
def __init__(self, /, node: str, arg: Any) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
/,
|
||||
node: str,
|
||||
arg: Any,
|
||||
*,
|
||||
timeout: float | timedelta | TimeoutPolicy | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Initialize a new instance of the `Send` class.
|
||||
|
||||
Args:
|
||||
node: The name of the target node to send the message to.
|
||||
arg: The state or message to send to the target node.
|
||||
timeout: Optional timeout policy for this specific pushed task. A
|
||||
number or `timedelta` is treated as a hard `run_timeout`.
|
||||
"""
|
||||
self.node = node
|
||||
self.arg = arg
|
||||
self.timeout = TimeoutPolicy.coerce(timeout)
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash((self.node, self.arg))
|
||||
return hash((self.node, self.arg, self.timeout))
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"Send(node={self.node!r}, arg={self.arg!r})"
|
||||
if self.timeout is None:
|
||||
return f"Send(node={self.node!r}, arg={self.arg!r})"
|
||||
return f"Send(node={self.node!r}, arg={self.arg!r}, timeout={self.timeout!r})"
|
||||
|
||||
def __eq__(self, value: object) -> bool:
|
||||
return (
|
||||
isinstance(value, Send)
|
||||
and self.node == value.node
|
||||
and self.arg == value.arg
|
||||
and self.timeout == value.timeout
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "langgraph"
|
||||
version = "1.1.9"
|
||||
version = "1.2.0a7"
|
||||
description = "Building stateful, multi-actor applications with LLMs"
|
||||
authors = []
|
||||
requires-python = ">=3.10"
|
||||
@@ -24,10 +24,10 @@ classifiers = [
|
||||
'Programming Language :: Python :: 3.13',
|
||||
]
|
||||
dependencies = [
|
||||
"langchain-core>=1.3.0,<2",
|
||||
"langgraph-checkpoint>=2.1.0,<5.0.0",
|
||||
"langchain-core>=1.4.0a2,<2",
|
||||
"langgraph-checkpoint>=4.1.0a4,<5.0.0",
|
||||
"langgraph-sdk>=0.3.0,<0.4.0",
|
||||
"langgraph-prebuilt>=1.0.9,<1.1.0",
|
||||
"langgraph-prebuilt>=1.1.0a2,<1.2.0",
|
||||
"xxhash>=3.5.0",
|
||||
"pydantic>=2.7.4",
|
||||
]
|
||||
@@ -38,7 +38,7 @@ Homepage = "https://docs.langchain.com/oss/python/langgraph/overview"
|
||||
Documentation = "https://reference.langchain.com/python/langgraph/"
|
||||
Source = "https://github.com/langchain-ai/langgraph/tree/main/libs/langgraph"
|
||||
Changelog = "https://github.com/langchain-ai/langgraph/releases"
|
||||
Twitter = "https://x.com/LangChain"
|
||||
Twitter = "https://x.com/langchain_oss"
|
||||
Slack = "https://www.langchain.com/join-community"
|
||||
Reddit = "https://www.reddit.com/r/LangChain/"
|
||||
|
||||
|
||||
@@ -1,18 +1,33 @@
|
||||
import operator
|
||||
from collections.abc import Sequence
|
||||
from typing import Annotated
|
||||
|
||||
import pytest
|
||||
from langchain_core.messages import AIMessage, HumanMessage, RemoveMessage
|
||||
from langgraph.checkpoint.memory import InMemorySaver
|
||||
from langgraph.checkpoint.serde.types import _DeltaSnapshot
|
||||
from typing_extensions import NotRequired, TypedDict
|
||||
|
||||
from langgraph._internal._typing import MISSING
|
||||
from langgraph.channels.binop import BinaryOperatorAggregate
|
||||
from langgraph.channels.delta import DeltaChannel
|
||||
from langgraph.channels.last_value import LastValue
|
||||
from langgraph.channels.topic import Topic
|
||||
from langgraph.channels.untracked_value import UntrackedValue
|
||||
from langgraph.errors import EmptyChannelError, InvalidUpdateError
|
||||
from langgraph.graph import START, StateGraph
|
||||
from langgraph.graph.message import _messages_delta_reducer
|
||||
from langgraph.graph.state import _get_channel
|
||||
from langgraph.types import Overwrite
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core channel primitives
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_last_value() -> None:
|
||||
channel = LastValue(int).from_checkpoint(MISSING)
|
||||
assert channel.ValueType is int
|
||||
@@ -95,25 +110,563 @@ def test_untracked_value() -> None:
|
||||
assert channel.ValueType is dict
|
||||
assert channel.UpdateType is dict
|
||||
|
||||
# UntrackedValue should start empty
|
||||
with pytest.raises(EmptyChannelError):
|
||||
channel.get()
|
||||
|
||||
# Should be able to update with a value
|
||||
test_data = {"session": "test", "temp": "dir"}
|
||||
channel.update([test_data])
|
||||
assert channel.get() == test_data
|
||||
|
||||
# Update with new value
|
||||
new_data = {"session": "updated", "temp": "newdir"}
|
||||
channel.update([new_data])
|
||||
assert channel.get() == new_data
|
||||
|
||||
# On checkpoint, UntrackedValue should return MISSING
|
||||
checkpoint = channel.checkpoint()
|
||||
assert checkpoint is MISSING
|
||||
|
||||
# Creating from checkpoint with MISSING should start empty
|
||||
new_channel = UntrackedValue(dict).from_checkpoint(checkpoint)
|
||||
with pytest.raises(EmptyChannelError):
|
||||
new_channel.get()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DeltaChannel — message reducer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_delta_channel_basic_two_steps() -> None:
|
||||
ch = DeltaChannel(_messages_delta_reducer, list).from_checkpoint(MISSING)
|
||||
|
||||
ch.update([HumanMessage(content="hi", id="h1")])
|
||||
d1 = ch.checkpoint()
|
||||
assert d1 is MISSING
|
||||
|
||||
ch.update([AIMessage(content="hello", id="a1")])
|
||||
d2 = ch.checkpoint()
|
||||
assert d2 is MISSING
|
||||
|
||||
assert len(ch.get()) == 2
|
||||
assert ch.get()[0].content == "hi"
|
||||
assert ch.get()[1].content == "hello"
|
||||
|
||||
|
||||
def test_delta_channel_from_checkpoint_writes_list() -> None:
|
||||
"""replay_writes on a fresh channel replays through the operator."""
|
||||
spec = DeltaChannel(_messages_delta_reducer, list)
|
||||
ch = spec.from_checkpoint(MISSING)
|
||||
ch.replay_writes(
|
||||
[
|
||||
("t0", "messages", HumanMessage(content="hi", id="h1")),
|
||||
("t1", "messages", AIMessage(content="hello", id="a1")),
|
||||
("t2", "messages", HumanMessage(content="bye", id="h2")),
|
||||
]
|
||||
)
|
||||
msgs = ch.get()
|
||||
assert len(msgs) == 3
|
||||
assert msgs[0].content == "hi"
|
||||
assert msgs[1].content == "hello"
|
||||
assert msgs[2].content == "bye"
|
||||
|
||||
|
||||
def test_delta_channel_from_checkpoint_backwards_compat() -> None:
|
||||
spec = DeltaChannel(_messages_delta_reducer, list)
|
||||
old_value = [HumanMessage(content="old", id="h1")]
|
||||
ch = spec.from_checkpoint(old_value)
|
||||
assert ch.get() == old_value
|
||||
|
||||
|
||||
def test_delta_channel_overwrite() -> None:
|
||||
ch = DeltaChannel(_messages_delta_reducer, list).from_checkpoint(MISSING)
|
||||
ch.update([HumanMessage(content="old", id="h1")])
|
||||
|
||||
ch.update([Overwrite([HumanMessage(content="new", id="h2")])])
|
||||
d = ch.checkpoint()
|
||||
assert d is MISSING
|
||||
assert len(ch.get()) == 1
|
||||
assert ch.get()[0].content == "new"
|
||||
|
||||
|
||||
def test_delta_channel_remove_message_and_replay() -> None:
|
||||
"""RemoveMessage must round-trip correctly when writes are replayed."""
|
||||
spec = DeltaChannel(_messages_delta_reducer, list)
|
||||
ch = spec.from_checkpoint(MISSING)
|
||||
|
||||
ch.update([HumanMessage(content="hi", id="h1")])
|
||||
ch.update([AIMessage(content="hello", id="a1")])
|
||||
assert ch.get() == [
|
||||
HumanMessage(content="hi", id="h1"),
|
||||
AIMessage(content="hello", id="a1"),
|
||||
]
|
||||
|
||||
ch.update([RemoveMessage(id="a1")])
|
||||
assert ch.get() == [HumanMessage(content="hi", id="h1")]
|
||||
|
||||
ch2 = spec.from_checkpoint(MISSING)
|
||||
ch2.replay_writes(
|
||||
[
|
||||
("t0", "messages", HumanMessage(content="hi", id="h1")),
|
||||
("t1", "messages", AIMessage(content="hello", id="a1")),
|
||||
("t2", "messages", RemoveMessage(id="a1")),
|
||||
]
|
||||
)
|
||||
assert ch2.get() == [HumanMessage(content="hi", id="h1")]
|
||||
|
||||
|
||||
def test_delta_channel_update_by_id_and_replay() -> None:
|
||||
"""Updating a message by ID must round-trip correctly through writes replay."""
|
||||
spec = DeltaChannel(_messages_delta_reducer, list)
|
||||
ch = spec.from_checkpoint(MISSING)
|
||||
|
||||
ch.update([HumanMessage(content="original", id="h1")])
|
||||
ch.update([HumanMessage(content="updated", id="h1")])
|
||||
assert ch.get() == [HumanMessage(content="updated", id="h1")]
|
||||
|
||||
ch2 = spec.from_checkpoint(MISSING)
|
||||
ch2.replay_writes(
|
||||
[
|
||||
("t0", "messages", HumanMessage(content="original", id="h1")),
|
||||
("t1", "messages", HumanMessage(content="updated", id="h1")),
|
||||
]
|
||||
)
|
||||
assert len(ch2.get()) == 1
|
||||
assert ch2.get()[0].content == "updated"
|
||||
|
||||
|
||||
def test_delta_channel_dict_coercion() -> None:
|
||||
"""_messages_delta_reducer coerces dict writes to BaseMessage objects.
|
||||
|
||||
HTTP-driven input always arrives as JSON dicts. The reducer must coerce
|
||||
them (same contract as add_messages) so graphs work without a separate
|
||||
coercion step.
|
||||
"""
|
||||
ch = DeltaChannel(_messages_delta_reducer, list).from_checkpoint(MISSING)
|
||||
|
||||
# dict input — simulates what arrives from the HTTP API
|
||||
ch.update([{"role": "human", "content": "hello", "id": "h1"}])
|
||||
assert len(ch.get()) == 1
|
||||
assert isinstance(ch.get()[0], HumanMessage)
|
||||
assert ch.get()[0].content == "hello"
|
||||
assert ch.get()[0].id == "h1"
|
||||
|
||||
# update by ID via dict
|
||||
ch.update([{"role": "ai", "content": "world", "id": "h1"}])
|
||||
assert len(ch.get()) == 1
|
||||
assert ch.get()[0].content == "world"
|
||||
|
||||
# remove via RemoveMessage instance (same contract as add_messages)
|
||||
ch.update([RemoveMessage(id="h1")])
|
||||
assert ch.get() == []
|
||||
|
||||
|
||||
def test_messages_delta_reducer_coerces_state() -> None:
|
||||
"""State (left side) is coerced when raw — supports raw initial input
|
||||
and deserialized blobs. The steady-state path (state already typed)
|
||||
short-circuits and skips coercion.
|
||||
"""
|
||||
state = [{"role": "human", "content": "hello", "id": "h1"}]
|
||||
writes = [[{"role": "ai", "content": "world", "id": "h1"}]]
|
||||
result = _messages_delta_reducer(state, writes) # type: ignore[arg-type]
|
||||
assert len(result) == 1
|
||||
assert isinstance(result[0], AIMessage)
|
||||
assert result[0].content == "world"
|
||||
assert result[0].id == "h1"
|
||||
|
||||
|
||||
def test_messages_delta_reducer_tuple_write_is_one_message() -> None:
|
||||
"""A top-level tuple write is one message-like, not a sequence to flatten.
|
||||
|
||||
`("user", "hi")` is a valid `MessageLikeRepresentation`; flattening it
|
||||
would produce two HumanMessages ("user", "hi") instead of one.
|
||||
"""
|
||||
result = _messages_delta_reducer([], [("user", "hi")]) # type: ignore[arg-type]
|
||||
assert len(result) == 1
|
||||
assert isinstance(result[0], HumanMessage)
|
||||
assert result[0].content == "hi"
|
||||
|
||||
|
||||
def test_delta_channel_checkpoint_returns_missing() -> None:
|
||||
"""checkpoint() always returns MISSING regardless of state.
|
||||
|
||||
Pregel writes `_DeltaSnapshot(ch.get())` directly into `channel_values`
|
||||
on snapshot steps; the channel itself never participates in snapshot
|
||||
serialization, so its `checkpoint()` is always the absence sentinel.
|
||||
"""
|
||||
ch = DeltaChannel(_messages_delta_reducer, list).from_checkpoint(MISSING)
|
||||
assert ch.checkpoint() is MISSING
|
||||
|
||||
ch.update([HumanMessage(content="hi", id="h1")])
|
||||
assert ch.checkpoint() is MISSING
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DeltaChannel — snapshot frequency
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_delta_channel_snapshot_version_based() -> None:
|
||||
"""Snapshots fire when a channel accumulates `snapshot_frequency` updates.
|
||||
|
||||
Under the version-delta cadence, every time the channel's
|
||||
`current_version - last_snapshot_version >= snapshot_frequency` a
|
||||
`_DeltaSnapshot` blob is written. Bounds the ancestor walk to at most
|
||||
`snapshot_frequency` steps on any read for that channel.
|
||||
"""
|
||||
|
||||
class State(TypedDict):
|
||||
messages: Annotated[
|
||||
list, DeltaChannel(_messages_delta_reducer, snapshot_frequency=5)
|
||||
]
|
||||
other: str
|
||||
|
||||
def node_a(state: State) -> dict:
|
||||
i = len(state["messages"]) // 2
|
||||
return {"messages": [AIMessage(content=f"a{i}", id=f"a{i}")]}
|
||||
|
||||
def node_b(state: State) -> dict:
|
||||
return {"other": "y"}
|
||||
|
||||
g = StateGraph(State)
|
||||
g.add_node("a", node_a)
|
||||
g.add_node("b", node_b)
|
||||
g.add_edge(START, "a")
|
||||
g.add_edge("a", "b")
|
||||
saver = InMemorySaver()
|
||||
graph = g.compile(checkpointer=saver)
|
||||
|
||||
config = {"configurable": {"thread_id": "t1"}}
|
||||
for i in range(6):
|
||||
graph.invoke(
|
||||
{"messages": [HumanMessage(content=f"h{i}", id=f"h{i}")], "other": ""},
|
||||
config,
|
||||
)
|
||||
|
||||
msg_blob_values = [
|
||||
saver.serde.loads_typed((type_tag, blob))
|
||||
for k, (type_tag, blob) in saver.blobs.items()
|
||||
if k[2] == "messages" and type_tag == "msgpack" and blob
|
||||
]
|
||||
snapshots = [v for v in msg_blob_values if isinstance(v, _DeltaSnapshot)]
|
||||
assert snapshots, "expected at least one _DeltaSnapshot blob for messages"
|
||||
|
||||
state = graph.get_state(config)
|
||||
assert len(state.values["messages"]) == 12 # 6 human + 6 AI
|
||||
|
||||
|
||||
# TODO(delta-channel-cadence): the previous "snapshot fires even when channel
|
||||
# was not written" test asserted eager step-based snapshotting; under the new
|
||||
# version-delta cadence (`should_snapshot` triggers on per-channel update
|
||||
# count, not superstep count), no snapshot fires for an unwritten channel.
|
||||
# Replace with a test that exercises the version-delta trigger plus the
|
||||
# durability="exit" force-snapshot branch — see
|
||||
# `docs/superpowers/specs/2026-05-04-delta-channel-batched-reads-design.md`
|
||||
# section "Snapshot cadence".
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DeltaChannel — end-to-end (InMemorySaver)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_delta_channel_inmemory_saver_assembles_writes() -> None:
|
||||
"""InMemorySaver assembles writes from checkpoint_writes inside get_tuple."""
|
||||
|
||||
class State(TypedDict):
|
||||
messages: Annotated[list, DeltaChannel(_messages_delta_reducer, list)]
|
||||
|
||||
n = {"v": 0}
|
||||
|
||||
def respond(state: State) -> dict:
|
||||
n["v"] += 1
|
||||
return {"messages": [AIMessage(content=f"ok{n['v']}", id=f"ai{n['v']}")]}
|
||||
|
||||
builder = StateGraph(State)
|
||||
builder.add_node("respond", respond)
|
||||
builder.add_edge(START, "respond")
|
||||
saver = InMemorySaver()
|
||||
graph = builder.compile(checkpointer=saver)
|
||||
config = {"configurable": {"thread_id": "t1"}}
|
||||
|
||||
graph.invoke({"messages": [HumanMessage(content="hi", id="h1")]}, config)
|
||||
graph.invoke({"messages": [HumanMessage(content="bye", id="h2")]}, config)
|
||||
|
||||
saved = saver.get_tuple(config)
|
||||
assert saved is not None
|
||||
assert "messages" not in saved.checkpoint["channel_values"]
|
||||
|
||||
state = graph.get_state(config)
|
||||
assert len(state.values["messages"]) == 4 # 2 human + 2 AI
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DeltaChannel — dict reducer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _delta_channel_with_type(op, typ):
|
||||
"""Build a DeltaChannel with an explicit type via the Annotated injection path."""
|
||||
return _get_channel("_test", Annotated[typ, DeltaChannel(op)])
|
||||
|
||||
|
||||
def test_delta_channel_dict_reducer_fresh_channel() -> None:
|
||||
"""DeltaChannel with a dict reducer starts as empty dict on MISSING checkpoint."""
|
||||
|
||||
def merge_dicts(state: dict, writes: list) -> dict:
|
||||
result = dict(state)
|
||||
for w in writes:
|
||||
result.update(w)
|
||||
return result
|
||||
|
||||
ch = _delta_channel_with_type(merge_dicts, dict).from_checkpoint(MISSING)
|
||||
assert ch.is_available()
|
||||
assert ch.get() == {}
|
||||
|
||||
|
||||
def test_delta_channel_dict_reducer_basic_updates() -> None:
|
||||
"""DeltaChannel with a dict reducer accumulates key/value pairs across steps."""
|
||||
|
||||
def merge_dicts(state: dict, writes: list) -> dict:
|
||||
result = dict(state)
|
||||
for w in writes:
|
||||
result.update(w)
|
||||
return result
|
||||
|
||||
ch = _delta_channel_with_type(merge_dicts, dict).from_checkpoint(MISSING)
|
||||
|
||||
ch.update([{"a": 1}])
|
||||
d1 = ch.checkpoint()
|
||||
assert d1 is MISSING
|
||||
|
||||
ch.update([{"b": 2}])
|
||||
d2 = ch.checkpoint()
|
||||
assert d2 is MISSING
|
||||
|
||||
assert ch.get() == {"a": 1, "b": 2}
|
||||
|
||||
|
||||
def test_delta_channel_dict_reducer_writes_reconstruction() -> None:
|
||||
"""replay_writes on a fresh channel replays through a dict merge reducer."""
|
||||
|
||||
def merge_dicts(state: dict, writes: list) -> dict:
|
||||
result = dict(state)
|
||||
for w in writes:
|
||||
result.update(w)
|
||||
return result
|
||||
|
||||
spec = _delta_channel_with_type(merge_dicts, dict)
|
||||
ch = spec.from_checkpoint(MISSING)
|
||||
ch.replay_writes(
|
||||
[
|
||||
("t0", "files", {"a": 1}),
|
||||
("t1", "files", {"b": 2}),
|
||||
("t2", "files", {"c": 3}),
|
||||
]
|
||||
)
|
||||
assert ch.get() == {"a": 1, "b": 2, "c": 3}
|
||||
|
||||
|
||||
def test_delta_channel_dict_reducer_with_deletions() -> None:
|
||||
"""Dict reducer that treats None values as deletions works end-to-end."""
|
||||
|
||||
def merge_files(state: dict, writes: list) -> dict:
|
||||
result = dict(state)
|
||||
for w in writes:
|
||||
for k, v in w.items():
|
||||
if v is None:
|
||||
result.pop(k, None)
|
||||
else:
|
||||
result[k] = v
|
||||
return result
|
||||
|
||||
ch = _delta_channel_with_type(merge_files, dict).from_checkpoint(MISSING)
|
||||
ch.update([{"file1.py": "content1", "file2.py": "content2"}])
|
||||
ch.update([{"file1.py": None, "file3.py": "content3"}])
|
||||
assert ch.get() == {"file2.py": "content2", "file3.py": "content3"}
|
||||
|
||||
spec = _delta_channel_with_type(merge_files, dict)
|
||||
ch2 = spec.from_checkpoint(MISSING)
|
||||
ch2.replay_writes(
|
||||
[
|
||||
("t0", "files", {"file1.py": "content1", "file2.py": "content2"}),
|
||||
("t1", "files", {"file1.py": None, "file3.py": "content3"}),
|
||||
]
|
||||
)
|
||||
assert ch2.get() == {"file2.py": "content2", "file3.py": "content3"}
|
||||
|
||||
|
||||
def test_delta_channel_dict_reducer_overwrite_in_update() -> None:
|
||||
"""Overwrite(dict) in update() must preserve dict shape, not coerce to list."""
|
||||
|
||||
def merge_dicts(state: dict, writes: list) -> dict:
|
||||
result = dict(state)
|
||||
for w in writes:
|
||||
result.update(w)
|
||||
return result
|
||||
|
||||
ch = _delta_channel_with_type(merge_dicts, dict).from_checkpoint(MISSING)
|
||||
ch.update([{"a": 1}])
|
||||
ch.update([Overwrite({"b": 2, "c": 3})])
|
||||
assert ch.get() == {"b": 2, "c": 3}
|
||||
|
||||
|
||||
def test_delta_channel_dict_reducer_overwrite_in_writes_replay() -> None:
|
||||
"""Overwrite(dict) embedded in replayed writes must reconstruct as dict."""
|
||||
|
||||
def merge_dicts(state: dict, writes: list) -> dict:
|
||||
result = dict(state)
|
||||
for w in writes:
|
||||
result.update(w)
|
||||
return result
|
||||
|
||||
spec = _delta_channel_with_type(merge_dicts, dict)
|
||||
ch = spec.from_checkpoint(MISSING)
|
||||
ch.replay_writes(
|
||||
[
|
||||
("t0", "files", {"a": 1}),
|
||||
("t1", "files", Overwrite({"x": 10, "y": 20})),
|
||||
("t2", "files", {"z": 30}),
|
||||
]
|
||||
)
|
||||
assert ch.get() == {"x": 10, "y": 20, "z": 30}
|
||||
|
||||
|
||||
def test_delta_channel_dict_reducer_with_notrequired_annotation() -> None:
|
||||
"""DeltaChannel infers dict type through `Annotated[NotRequired[dict[...]], ch]`."""
|
||||
|
||||
def merge_dicts(state: dict, writes: list) -> dict:
|
||||
result = dict(state)
|
||||
for w in writes:
|
||||
result.update(w)
|
||||
return result
|
||||
|
||||
annotation = Annotated[NotRequired[dict[str, int]], DeltaChannel(merge_dicts)]
|
||||
ch = _get_channel("files", annotation).from_checkpoint(MISSING)
|
||||
assert ch.get() == {}
|
||||
ch.update([{"a": 1}])
|
||||
ch.update([{"b": 2}])
|
||||
assert ch.get() == {"a": 1, "b": 2}
|
||||
|
||||
|
||||
def test_delta_channel_dict_reducer_end_to_end_filesystem() -> None:
|
||||
"""End-to-end: graph with dict-reducer (filesystem-style) channel wrapped in DeltaChannel."""
|
||||
|
||||
def merge_files(state: dict, writes: list) -> dict:
|
||||
result = dict(state)
|
||||
for w in writes:
|
||||
for k, v in w.items():
|
||||
if v is None:
|
||||
result.pop(k, None)
|
||||
else:
|
||||
result[k] = v
|
||||
return result
|
||||
|
||||
class State(TypedDict):
|
||||
files: Annotated[dict[str, str], DeltaChannel(merge_files)]
|
||||
|
||||
turn = {"v": 0}
|
||||
|
||||
def write_file(state: State) -> dict:
|
||||
turn["v"] += 1
|
||||
n = turn["v"]
|
||||
return {"files": {f"/doc_{n}.txt": f"content for turn {n}"}}
|
||||
|
||||
builder = StateGraph(State)
|
||||
builder.add_node("write_file", write_file)
|
||||
builder.add_edge(START, "write_file")
|
||||
saver = InMemorySaver()
|
||||
graph = builder.compile(checkpointer=saver)
|
||||
config = {"configurable": {"thread_id": "fs"}}
|
||||
|
||||
for _ in range(3):
|
||||
graph.invoke({"files": {}}, config)
|
||||
|
||||
saved = saver.get_tuple(config)
|
||||
assert saved is not None
|
||||
assert "files" not in saved.checkpoint["channel_values"]
|
||||
state = graph.get_state(config)
|
||||
assert state.values["files"] == {
|
||||
"/doc_1.txt": "content for turn 1",
|
||||
"/doc_2.txt": "content for turn 2",
|
||||
"/doc_3.txt": "content for turn 3",
|
||||
}
|
||||
|
||||
def delete_file(state: State) -> dict:
|
||||
return {"files": {"/doc_1.txt": None}}
|
||||
|
||||
builder2 = StateGraph(State)
|
||||
builder2.add_node("write_file", write_file)
|
||||
builder2.add_node("delete_file", delete_file)
|
||||
builder2.add_edge(START, "write_file")
|
||||
builder2.add_edge("write_file", "delete_file")
|
||||
turn["v"] = 0
|
||||
saver2 = InMemorySaver()
|
||||
graph2 = builder2.compile(checkpointer=saver2)
|
||||
config2 = {"configurable": {"thread_id": "fs2"}}
|
||||
graph2.invoke({"files": {}}, config2)
|
||||
state2 = graph2.get_state(config2)
|
||||
assert state2.values["files"] == {}
|
||||
|
||||
|
||||
def test_delta_channel_dict_reducer_backwards_compat() -> None:
|
||||
"""A pre-DeltaChannel dict checkpoint must load as a dict, not be listified."""
|
||||
|
||||
def merge_dicts(state: dict, writes: list) -> dict:
|
||||
result = dict(state)
|
||||
for w in writes:
|
||||
result.update(w)
|
||||
return result
|
||||
|
||||
spec = _delta_channel_with_type(merge_dicts, dict)
|
||||
old_value = {"a": 1, "b": 2}
|
||||
ch = spec.from_checkpoint(old_value)
|
||||
assert ch.get() == {"a": 1, "b": 2}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DeltaChannel — seed / pre-delta migration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_delta_channel_from_checkpoint_honors_seed() -> None:
|
||||
"""A non-sentinel value to from_checkpoint is used as the pre-delta seed.
|
||||
|
||||
Guards the pre-delta migration path: when the saver's ancestor walk hits
|
||||
a pre-DeltaChannel blob it passes it as `seed` so replay reconstructs
|
||||
the post-migration state correctly rather than replaying from empty.
|
||||
"""
|
||||
spec = DeltaChannel(_messages_delta_reducer, list)
|
||||
seed = [HumanMessage(content="pre-delta", id="p1")]
|
||||
ch = spec.from_checkpoint(seed)
|
||||
ch.replay_writes(
|
||||
[
|
||||
("t0", "messages", AIMessage(content="delta-1", id="d1")),
|
||||
("t1", "messages", HumanMessage(content="delta-2", id="d2")),
|
||||
]
|
||||
)
|
||||
msgs = ch.get()
|
||||
assert [m.content for m in msgs] == ["pre-delta", "delta-1", "delta-2"]
|
||||
|
||||
|
||||
def test_delta_channel_from_checkpoint_seed_without_writes() -> None:
|
||||
"""Reconstruction at a pre-delta ancestor with no newer deltas returns
|
||||
just the seed — the saver's terminator fired immediately."""
|
||||
spec = DeltaChannel(_messages_delta_reducer, list)
|
||||
seed = [HumanMessage(content="only-snap", id="s1")]
|
||||
ch = spec.from_checkpoint(seed)
|
||||
ch.replay_writes([])
|
||||
assert ch.get() == seed
|
||||
|
||||
|
||||
def test_delta_channel_from_checkpoint_seed_none_is_distinct_from_sentinel() -> None:
|
||||
"""`seed=None` must start replay from None, not from an empty channel.
|
||||
|
||||
The `MISSING` absence sentinel means 'no seed'; passing `None`
|
||||
explicitly should feed None to the reducer as the left operand.
|
||||
"""
|
||||
|
||||
def replace(state, writes):
|
||||
return writes[-1] if writes else state
|
||||
|
||||
spec = DeltaChannel(replace, list)
|
||||
ch = spec.from_checkpoint(None)
|
||||
ch.replay_writes([("t0", "x", "after")])
|
||||
assert ch.get() == "after"
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
"""Benchmark: DeltaChannel — multi-channel reads, mixed snapshot frequencies.
|
||||
|
||||
Run directly: python tests/test_delta_channel_benchmark.py
|
||||
Run via pytest: pytest tests/test_delta_channel_benchmark.py -s
|
||||
|
||||
Sweeps `(K delta channels, snapshot_frequency strategy, turn count)` and
|
||||
reports per-scenario read latency, write latency, storage, and peak Python
|
||||
heap usage during `get_state`.
|
||||
|
||||
Scenarios cover the dimensions where this branch's optimizations matter:
|
||||
|
||||
* K-channel batching — varying K (number of `DeltaChannel`s the graph
|
||||
reads on hydrate) shows the effect of merging
|
||||
per-channel reads into a single saver call.
|
||||
* Mixed frequencies — channels with very different snapshot cadences
|
||||
in one graph exercise the per-channel chain
|
||||
bound in stage-2.
|
||||
* Turn count — chain depth shows how paged stage-1 holds up.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import gc
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import tracemalloc
|
||||
from typing import Annotated, Any
|
||||
|
||||
import pytest
|
||||
from langchain_core.messages import HumanMessage
|
||||
from langgraph.checkpoint.memory import MemorySaver
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from langgraph.channels.delta import DeltaChannel
|
||||
from langgraph.graph import END, StateGraph
|
||||
from langgraph.graph.message import _messages_delta_reducer
|
||||
|
||||
try:
|
||||
from langgraph.checkpoint.postgres import PostgresSaver
|
||||
|
||||
_POSTGRES_AVAILABLE = True
|
||||
_POSTGRES_URI = os.environ.get(
|
||||
"LANGGRAPH_BENCH_POSTGRES_URI",
|
||||
"postgres://postgres@localhost:5432/postgres?sslmode=disable",
|
||||
)
|
||||
except ImportError:
|
||||
_POSTGRES_AVAILABLE = False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Realistic message payload (~100 tokens / ~400 chars each)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_HUMAN_TEMPLATE = (
|
||||
"I need help understanding the implications of {topic} on our system "
|
||||
"architecture. Specifically, I'm concerned about how this interacts with "
|
||||
"our existing {concern} and whether we need to refactor the {component} "
|
||||
"layer before proceeding."
|
||||
)
|
||||
|
||||
_TOPICS = [
|
||||
"distributed tracing",
|
||||
"eventual consistency",
|
||||
"schema migration",
|
||||
"backpressure handling",
|
||||
"idempotency guarantees",
|
||||
]
|
||||
_CONCERNS = ["concurrency model", "retry semantics", "ordering guarantees"]
|
||||
_COMPONENTS = ["persistence", "ingestion", "routing"]
|
||||
|
||||
|
||||
def _human_content(i: int) -> str:
|
||||
return _HUMAN_TEMPLATE.format(
|
||||
topic=_TOPICS[i % len(_TOPICS)],
|
||||
concern=_CONCERNS[i % len(_CONCERNS)],
|
||||
component=_COMPONENTS[i % len(_COMPONENTS)],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# State / graph factory: K DeltaChannel fields with per-channel freqs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_state_cls(freqs: list[int]) -> type:
|
||||
"""Build a TypedDict with one DeltaChannel per entry in `freqs`."""
|
||||
fields: dict[str, Any] = {}
|
||||
for i, freq in enumerate(freqs):
|
||||
ch = DeltaChannel(_messages_delta_reducer, snapshot_frequency=freq)
|
||||
fields[f"ch{i}"] = Annotated[list, ch]
|
||||
return TypedDict( # type: ignore[return-value]
|
||||
"_BenchState_" + "-".join(str(f) for f in freqs),
|
||||
fields,
|
||||
)
|
||||
|
||||
|
||||
def _make_graph(state_cls: type, K: int, checkpointer: Any = None) -> Any:
|
||||
"""Graph: one node, writes a fresh message into every channel each turn."""
|
||||
|
||||
def fanout(state: Any) -> dict[str, Any]:
|
||||
i = max(len(state.get(f"ch{j}", [])) for j in range(K))
|
||||
# Each channel gets its own copy with a unique id so the reducer
|
||||
# does meaningful per-channel state accumulation.
|
||||
return {
|
||||
f"ch{j}": [HumanMessage(content=_human_content(i), id=f"c{j}_{i}")]
|
||||
for j in range(K)
|
||||
}
|
||||
|
||||
g = StateGraph(state_cls)
|
||||
g.add_node("fanout", fanout)
|
||||
g.set_entry_point("fanout")
|
||||
g.add_edge("fanout", END)
|
||||
return g.compile(checkpointer=checkpointer or MemorySaver())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Storage / memory measurement
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _inmemory_blob_bytes(saver: MemorySaver) -> int:
|
||||
return sum(
|
||||
len(blob) for (_, _, _, _), (_, blob) in saver.blobs.items() if blob is not None
|
||||
)
|
||||
|
||||
|
||||
def _postgres_storage_bytes(saver: Any, thread_id: str) -> int:
|
||||
"""Total bytes across checkpoints / checkpoint_blobs / checkpoint_writes
|
||||
rows for this `thread_id`. Uses `pg_column_size` for an in-row payload
|
||||
estimate; faster than full-table size and scoped to the thread."""
|
||||
sql = """
|
||||
SELECT COALESCE(SUM(pg_column_size(c.*)), 0)
|
||||
+ COALESCE((SELECT SUM(pg_column_size(b.*)) FROM checkpoint_blobs b
|
||||
WHERE b.thread_id = %s), 0)
|
||||
+ COALESCE((SELECT SUM(pg_column_size(w.*)) FROM checkpoint_writes w
|
||||
WHERE w.thread_id = %s), 0)
|
||||
AS total
|
||||
FROM checkpoints c
|
||||
WHERE c.thread_id = %s
|
||||
"""
|
||||
with saver._cursor() as cur:
|
||||
cur.execute(sql, (thread_id, thread_id, thread_id))
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
return 0
|
||||
if isinstance(row, dict):
|
||||
return int(row.get("total") or 0)
|
||||
return int(row[0] or 0)
|
||||
|
||||
|
||||
def _run_scenario(
|
||||
freqs: list[int],
|
||||
n_turns: int,
|
||||
checkpointer: Any,
|
||||
thread_id: str,
|
||||
) -> dict[str, float | int]:
|
||||
"""Drive `n_turns` invocations of a K-channel graph, measure
|
||||
write/read/storage/peak-memory."""
|
||||
K = len(freqs)
|
||||
state_cls = _make_state_cls(freqs)
|
||||
graph = _make_graph(state_cls, K, checkpointer)
|
||||
config = {"configurable": {"thread_id": thread_id}}
|
||||
|
||||
# Write phase
|
||||
t0 = time.perf_counter()
|
||||
for i in range(n_turns):
|
||||
graph.invoke({}, config)
|
||||
write_elapsed = time.perf_counter() - t0
|
||||
|
||||
# Read phase + tracemalloc peak across get_state calls
|
||||
gc.collect()
|
||||
tracemalloc.start()
|
||||
t1 = time.perf_counter()
|
||||
for _ in range(5):
|
||||
graph.get_state(config)
|
||||
read_elapsed = (time.perf_counter() - t1) / 5
|
||||
_, peak_bytes = tracemalloc.get_traced_memory()
|
||||
tracemalloc.stop()
|
||||
|
||||
# Storage
|
||||
if isinstance(graph.checkpointer, MemorySaver):
|
||||
storage = _inmemory_blob_bytes(graph.checkpointer)
|
||||
elif _POSTGRES_AVAILABLE and isinstance(graph.checkpointer, PostgresSaver):
|
||||
storage = _postgres_storage_bytes(graph.checkpointer, thread_id)
|
||||
else:
|
||||
storage = -1
|
||||
|
||||
return {
|
||||
"K": K,
|
||||
"turns": n_turns,
|
||||
"write_total_s": write_elapsed,
|
||||
"write_per_invoke_ms": (write_elapsed / n_turns) * 1000,
|
||||
"read_avg_ms": read_elapsed * 1000,
|
||||
"storage_bytes": storage,
|
||||
"peak_mem_bytes": peak_bytes,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Postgres helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _pg_saver(thread_id: str):
|
||||
with PostgresSaver.from_conn_string(_POSTGRES_URI) as saver:
|
||||
saver.setup()
|
||||
with saver._cursor() as cur:
|
||||
for tbl in ("checkpoints", "checkpoint_blobs", "checkpoint_writes"):
|
||||
cur.execute(f"DELETE FROM {tbl} WHERE thread_id = %s", (thread_id,))
|
||||
yield saver
|
||||
with saver._cursor() as cur:
|
||||
for tbl in ("checkpoints", "checkpoint_blobs", "checkpoint_writes"):
|
||||
cur.execute(f"DELETE FROM {tbl} WHERE thread_id = %s", (thread_id,))
|
||||
|
||||
|
||||
def _checkpointers() -> list[tuple[str, Any]]:
|
||||
result: list[tuple[str, Any]] = [("InMemory", None)]
|
||||
if _POSTGRES_AVAILABLE:
|
||||
try:
|
||||
import psycopg
|
||||
|
||||
psycopg.connect(_POSTGRES_URI).close()
|
||||
result.append(("Postgres", "postgres"))
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scenarios
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SCENARIOS: list[tuple[str, list[int]]] = [
|
||||
("K=1, freq=50", [50]),
|
||||
("K=3, freq=50 uniform", [50, 50, 50]),
|
||||
("K=3, freq=mixed", [50, 200, 1000]),
|
||||
("K=8, freq=50 uniform", [50] * 8),
|
||||
("K=8, freq=mixed", [25, 50, 100, 200, 500, 1000, 1000, 1000]),
|
||||
]
|
||||
|
||||
TURN_COUNTS = [100, 500]
|
||||
|
||||
|
||||
def _fmt_bytes(n: int) -> str:
|
||||
if n < 0:
|
||||
return "n/a"
|
||||
if n >= 1_000_000:
|
||||
return f"{n / 1_000_000:.1f} MB"
|
||||
if n >= 1_000:
|
||||
return f"{n / 1_000:.1f} KB"
|
||||
return f"{n} B"
|
||||
|
||||
|
||||
def _print_scenario_table(cp_label: str, rows: list[dict]) -> None:
|
||||
print(f"\n [{cp_label}]")
|
||||
header = (
|
||||
f" {'scenario':<28}{'turns':>8}{'write_ms':>11}"
|
||||
f"{'read_ms':>10}{'storage':>12}{'peak_mem':>12}"
|
||||
)
|
||||
print(header)
|
||||
print(" " + "-" * (len(header) - 2))
|
||||
for row in rows:
|
||||
print(
|
||||
f" {row['scenario']:<28}"
|
||||
f"{row['turns']:>8}"
|
||||
f"{row['write_per_invoke_ms']:>10.1f}"
|
||||
f"{row['read_avg_ms']:>10.2f}"
|
||||
f"{_fmt_bytes(row['storage_bytes']):>12}"
|
||||
f"{_fmt_bytes(row['peak_mem_bytes']):>12}"
|
||||
)
|
||||
|
||||
|
||||
def run_benchmark() -> list[dict]:
|
||||
"""Run the full sweep and return all measurement rows."""
|
||||
all_rows: list[dict] = []
|
||||
for cp_label, cp_hint in _checkpointers():
|
||||
rows: list[dict] = []
|
||||
for scenario_label, freqs in SCENARIOS:
|
||||
for turns in TURN_COUNTS:
|
||||
thread_id = f"bench-{scenario_label.replace(' ', '_')}-{turns}".lower()
|
||||
if cp_hint is None:
|
||||
saver_ctx: Any = contextlib.nullcontext(None)
|
||||
else:
|
||||
saver_ctx = _pg_saver(thread_id)
|
||||
with saver_ctx as saver:
|
||||
measured = _run_scenario(freqs, turns, saver, thread_id)
|
||||
measured["scenario"] = scenario_label
|
||||
measured["saver"] = cp_label
|
||||
rows.append(measured)
|
||||
all_rows.append(measured)
|
||||
_print_scenario_table(cp_label, rows)
|
||||
return all_rows
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pytest entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.skip(
|
||||
reason="slow benchmark — run manually with: python tests/test_delta_channel_benchmark.py"
|
||||
)
|
||||
def test_delta_channel_benchmark(capsys: Any) -> None:
|
||||
"""Manual benchmark — see module docstring."""
|
||||
with capsys.disabled():
|
||||
run_benchmark()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Script entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("DeltaChannel benchmark — multi-channel reads, mixed frequencies")
|
||||
print("=" * 78)
|
||||
run_benchmark()
|
||||
sys.exit(0)
|
||||
@@ -0,0 +1,618 @@
|
||||
"""Tests for the BinaryOperatorAggregate -> DeltaChannel migration path.
|
||||
|
||||
A thread written under `BinaryOperatorAggregate(...)` must keep working
|
||||
after its annotation is swapped to `DeltaChannel(...)` on the same
|
||||
checkpointer — pre-migration state visible at each *settled* ancestor
|
||||
checkpoint is preserved, and post-migration writes fold on top through
|
||||
the reducer.
|
||||
|
||||
Mechanism under test: the saver's public `get_delta_channel_history(config,
|
||||
channels)` walks the parent chain; when it encounters an ancestor whose
|
||||
`channel_values[channel]` is a real value, it populates that channel's
|
||||
`seed` in the returned `DeltaChannelHistory`. If the walk reaches the root
|
||||
without finding a stored value, the `seed` key is omitted (TypedDict
|
||||
absence indicates "start empty"). `DeltaChannel.from_checkpoint(seed)`
|
||||
uses it as the base value, and `replay_writes(writes)` folds on-path
|
||||
deltas.
|
||||
|
||||
Scenarios covered:
|
||||
|
||||
1. **Basic migration (sync + async)**: build pre-migration state with
|
||||
`BinaryOperatorAggregate`, swap the annotation to `DeltaChannel` on
|
||||
the same checkpointer, and verify that every settled pre-migration
|
||||
super-step boundary (`next=('__start__',)`) round-trips exactly
|
||||
under the delta-channel view.
|
||||
2. **Time travel into a pre-migration checkpoint** after migration —
|
||||
`graph.get_state(pre_migration_config)` at a settled ancestor
|
||||
returns the same state as under the binop channel.
|
||||
3. **Continuing a migrated thread**: driving one more super-step after
|
||||
migration produces a state that includes the pre-migration settled
|
||||
prefix plus the new delta write — proving `from_checkpoint(seed)` +
|
||||
`replay_writes` correctly fold post-migration deltas onto the
|
||||
pre-migration seed.
|
||||
4. **Base-saver fallback path**: a third-party-style subclass that
|
||||
removes the optimized `InMemorySaver` override and falls back to
|
||||
`BaseCheckpointSaver.get_delta_channel_history` must produce the same
|
||||
result as the optimized path.
|
||||
5. **Channel-type isolation across threads**: two threads on the same
|
||||
checkpointer under the delta-channel graph — one freshly-started,
|
||||
one migrated from pre-migration state — don't cross-contaminate.
|
||||
The parent-chain walk is scoped to the thread.
|
||||
|
||||
TODO: add postgres variants in the existing `libs/checkpoint-postgres`
|
||||
test files (different fixture setup; not this file).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import operator
|
||||
from typing import Annotated, Any
|
||||
|
||||
import pytest
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
from langgraph.checkpoint.memory import InMemorySaver
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from langgraph.channels.binop import BinaryOperatorAggregate
|
||||
from langgraph.channels.delta import DeltaChannel
|
||||
from langgraph.graph import END, START, StateGraph
|
||||
from langgraph.graph.message import _messages_delta_reducer, add_messages
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Graph factories
|
||||
#
|
||||
# A minimal reducer (`operator.add` on lists of str) with a noop node keeps
|
||||
# state change localized to the HumanMessage-like payload passed through
|
||||
# `invoke`. That isolates the pre/post-migration parity assertions to
|
||||
# channel-hydration semantics.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _noop(_state: Any) -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
def _list_concat(state: list, writes: list) -> list:
|
||||
result = list(state)
|
||||
for w in writes:
|
||||
result.extend(w if isinstance(w, list) else [w])
|
||||
return result
|
||||
|
||||
|
||||
def _binop_graph(checkpointer: Any) -> Any:
|
||||
class BinopState(TypedDict):
|
||||
items: Annotated[list, BinaryOperatorAggregate(list, operator.add)]
|
||||
|
||||
return (
|
||||
StateGraph(BinopState)
|
||||
.add_node("noop", _noop)
|
||||
.add_edge(START, "noop")
|
||||
.add_edge("noop", END)
|
||||
.compile(checkpointer=checkpointer)
|
||||
)
|
||||
|
||||
|
||||
def _delta_graph(checkpointer: Any) -> Any:
|
||||
class DeltaState(TypedDict):
|
||||
items: Annotated[list, DeltaChannel(_list_concat)]
|
||||
|
||||
return (
|
||||
StateGraph(DeltaState)
|
||||
.add_node("noop", _noop)
|
||||
.add_edge(START, "noop")
|
||||
.add_edge("noop", END)
|
||||
.compile(checkpointer=checkpointer)
|
||||
)
|
||||
|
||||
|
||||
def _drive(graph: Any, config: dict, tag: str, n: int) -> None:
|
||||
for i in range(n):
|
||||
graph.invoke({"items": [f"{tag}{i}"]}, config)
|
||||
|
||||
|
||||
async def _adrive(graph: Any, config: dict, tag: str, n: int) -> None:
|
||||
for i in range(n):
|
||||
await graph.ainvoke({"items": [f"{tag}{i}"]}, config)
|
||||
|
||||
|
||||
def _settled_boundaries(history: list) -> list[tuple[dict, list]]:
|
||||
"""Return `[(config, items), ...]` for every checkpoint in `history`
|
||||
whose `next == ('__start__',)` — the stable boundaries between invokes.
|
||||
"""
|
||||
return [
|
||||
(s.config, list(s.values.get("items", [])))
|
||||
for s in history
|
||||
if s.next == ("__start__",)
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Basic migration (sync + async)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_basic_migration_preserves_pre_migration_state() -> None:
|
||||
"""Build state under `BinaryOperatorAggregate`, migrate to
|
||||
`DeltaChannel` on the same checkpointer, and verify that every
|
||||
settled pre-migration super-step boundary round-trips exactly.
|
||||
|
||||
Settled boundaries (`next=('__start__',)`) are the stable hydration
|
||||
targets for the migration path: writes that produced the NEXT
|
||||
super-step are kept as `pending_writes` on the ancestor, so walking
|
||||
from a descendant finds the ancestor's blob as the seed and
|
||||
reconstructs the correct state.
|
||||
"""
|
||||
|
||||
checkpointer = InMemorySaver()
|
||||
config = {"configurable": {"thread_id": "basic-sync"}}
|
||||
|
||||
# Pre-migration: accumulate items across 3 invokes.
|
||||
binop = _binop_graph(checkpointer)
|
||||
_drive(binop, config, "u", 3)
|
||||
|
||||
pre_boundaries = _settled_boundaries(list(binop.get_state_history(config)))
|
||||
assert len(pre_boundaries) >= 2, "expected multiple settled boundaries"
|
||||
|
||||
# Migrate: swap the annotation on the same checkpointer.
|
||||
delta = _delta_graph(checkpointer)
|
||||
|
||||
for cfg, items in pre_boundaries:
|
||||
snap = delta.get_state(cfg)
|
||||
assert list(snap.values.get("items", [])) == items, (
|
||||
f"snapshot mismatch at {cfg['configurable']['checkpoint_id']}: "
|
||||
f"expected {items}, got {snap.values.get('items', [])}"
|
||||
)
|
||||
|
||||
|
||||
async def test_basic_migration_preserves_pre_migration_state_async() -> None:
|
||||
"""Async variant of the basic migration scenario."""
|
||||
|
||||
checkpointer = InMemorySaver()
|
||||
config = {"configurable": {"thread_id": "basic-async"}}
|
||||
|
||||
binop = _binop_graph(checkpointer)
|
||||
await _adrive(binop, config, "u", 3)
|
||||
|
||||
pre_history = [s async for s in binop.aget_state_history(config)]
|
||||
pre_boundaries = _settled_boundaries(pre_history)
|
||||
assert len(pre_boundaries) >= 2
|
||||
|
||||
delta = _delta_graph(checkpointer)
|
||||
|
||||
for cfg, items in pre_boundaries:
|
||||
snap = await delta.aget_state(cfg)
|
||||
assert list(snap.values.get("items", [])) == items, (
|
||||
f"async snapshot mismatch at {cfg['configurable']['checkpoint_id']}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Time travel into a pre-migration checkpoint after migration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_time_travel_into_pre_migration_checkpoint() -> None:
|
||||
"""After migration, `graph.get_state(pre_migration_config)` at a
|
||||
settled ancestor returns the state as stored at that point."""
|
||||
|
||||
checkpointer = InMemorySaver()
|
||||
config = {"configurable": {"thread_id": "time-travel"}}
|
||||
|
||||
binop = _binop_graph(checkpointer)
|
||||
_drive(binop, config, "u", 3)
|
||||
|
||||
pre_boundaries = _settled_boundaries(list(binop.get_state_history(config)))
|
||||
assert pre_boundaries, "no settled ancestors to time-travel to"
|
||||
|
||||
delta = _delta_graph(checkpointer)
|
||||
|
||||
# Pick the oldest non-empty boundary — a long distance to walk back.
|
||||
non_empty = [(cfg, items) for cfg, items in pre_boundaries if items]
|
||||
assert non_empty, "expected at least one non-empty boundary"
|
||||
target_cfg, expected_items = non_empty[-1]
|
||||
|
||||
snap = delta.get_state(target_cfg)
|
||||
assert list(snap.values.get("items", [])) == expected_items
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Continuing a migrated thread: deltas fold onto pre-migration seed
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_continuing_migrated_thread_folds_deltas_on_seed() -> None:
|
||||
"""Resume a pre-migration settled ancestor via `invoke(None, cfg)`
|
||||
under the delta-channel graph. Since the pre-migration checkpoint
|
||||
has an existing `pending_writes` entry (the input for the NEXT
|
||||
super-step), re-running from that ancestor reproduces the same
|
||||
post-ancestor state as the original binop run.
|
||||
|
||||
This proves the seed-terminator + write-replay pipeline works
|
||||
end-to-end across the migration boundary.
|
||||
"""
|
||||
|
||||
checkpointer = InMemorySaver()
|
||||
config = {"configurable": {"thread_id": "continue"}}
|
||||
|
||||
binop = _binop_graph(checkpointer)
|
||||
_drive(binop, config, "u", 2)
|
||||
|
||||
# Pick the oldest settled boundary with non-empty state.
|
||||
pre_boundaries = _settled_boundaries(list(binop.get_state_history(config)))
|
||||
target_cfg, seed_items = next(
|
||||
(cfg, items) for cfg, items in reversed(pre_boundaries) if items
|
||||
)
|
||||
assert seed_items, "need a non-empty seed boundary"
|
||||
|
||||
# Migrate and resume from the pre-migration ancestor. `invoke(None,
|
||||
# cfg)` replays the pending writes staged at `cfg` under the new
|
||||
# channel; the reducer folds those deltas onto the seed.
|
||||
delta = _delta_graph(checkpointer)
|
||||
result = delta.invoke(None, target_cfg)
|
||||
|
||||
# The resumed state must include the pre-migration seed items in order.
|
||||
result_items = list(result.get("items", []))
|
||||
for idx, prefix_item in enumerate(seed_items):
|
||||
assert result_items[idx] == prefix_item, (
|
||||
f"pre-migration seed item at {idx} not preserved: "
|
||||
f"got {result_items[: idx + 1]}, expected {seed_items}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Base-saver fallback path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _ThirdPartyStyleSaver(InMemorySaver):
|
||||
"""Simulates a third-party saver that inherits the reference
|
||||
`get_delta_channel_history` implementation from `BaseCheckpointSaver`
|
||||
rather than overriding it.
|
||||
|
||||
We rebind the two methods to the base-class versions (via MRO) so
|
||||
the fallback path is exercised even though the storage layer is
|
||||
still the in-memory one.
|
||||
"""
|
||||
|
||||
# MRO: [_ThirdPartyStyleSaver, InMemorySaver, BaseCheckpointSaver, ...]
|
||||
get_delta_channel_history = ( # type: ignore[assignment]
|
||||
InMemorySaver.__mro__[1].get_delta_channel_history # type: ignore[attr-defined]
|
||||
)
|
||||
aget_delta_channel_history = ( # type: ignore[assignment]
|
||||
InMemorySaver.__mro__[1].aget_delta_channel_history # type: ignore[attr-defined]
|
||||
)
|
||||
|
||||
|
||||
def test_base_saver_fallback_matches_optimized_override() -> None:
|
||||
"""The reference `BaseCheckpointSaver` implementation must produce
|
||||
the same migration behavior as the optimized `InMemorySaver`
|
||||
override. We drive the same migration scenario through both savers
|
||||
and assert per-snapshot parity in the delta-channel view."""
|
||||
|
||||
# Fast path: optimized InMemorySaver override.
|
||||
fast_saver = InMemorySaver()
|
||||
fast_config = {"configurable": {"thread_id": "fast"}}
|
||||
fast_binop = _binop_graph(fast_saver)
|
||||
_drive(fast_binop, fast_config, "u", 3)
|
||||
fast_delta = _delta_graph(fast_saver)
|
||||
fast_history = [
|
||||
(s.next, list(s.values.get("items", [])))
|
||||
for s in fast_delta.get_state_history(fast_config)
|
||||
]
|
||||
|
||||
# Slow path: base-class fallback.
|
||||
slow_saver = _ThirdPartyStyleSaver()
|
||||
slow_config = {"configurable": {"thread_id": "slow"}}
|
||||
slow_binop = _binop_graph(slow_saver)
|
||||
_drive(slow_binop, slow_config, "u", 3)
|
||||
slow_delta = _delta_graph(slow_saver)
|
||||
slow_history = [
|
||||
(s.next, list(s.values.get("items", [])))
|
||||
for s in slow_delta.get_state_history(slow_config)
|
||||
]
|
||||
|
||||
assert slow_history == fast_history, (
|
||||
"base-saver fallback should match optimized-override behavior; "
|
||||
f"fast={fast_history}, slow={slow_history}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Thread isolation under mixed-generation storage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_delta_and_migrated_threads_do_not_cross_contaminate() -> None:
|
||||
"""Two threads sharing a checkpointer — one migrated from
|
||||
pre-migration state, one freshly-started under DeltaChannel — must
|
||||
maintain independent state. The parent-chain walk in
|
||||
`get_delta_channel_history` must be scoped to the target thread.
|
||||
"""
|
||||
|
||||
checkpointer = InMemorySaver()
|
||||
migrated_cfg = {"configurable": {"thread_id": "migrated"}}
|
||||
fresh_cfg = {"configurable": {"thread_id": "fresh"}}
|
||||
|
||||
# Thread A: pre-migration build-up.
|
||||
binop = _binop_graph(checkpointer)
|
||||
_drive(binop, migrated_cfg, "m", 2)
|
||||
|
||||
# Thread B: fresh delta-channel run.
|
||||
delta = _delta_graph(checkpointer)
|
||||
_drive(delta, fresh_cfg, "f", 2)
|
||||
|
||||
# Thread A: migrate and confirm its state is anchored in its own
|
||||
# thread's pre-migration history (tag 'm'), never mixing in tag 'f'.
|
||||
migrated_boundaries = _settled_boundaries(
|
||||
list(delta.get_state_history(migrated_cfg))
|
||||
)
|
||||
assert migrated_boundaries, "migrated thread has no settled boundaries"
|
||||
for _, items in migrated_boundaries:
|
||||
for it in items:
|
||||
assert it.startswith("m"), (
|
||||
f"migrated thread leaked item from other thread: {it}"
|
||||
)
|
||||
|
||||
# Thread B: settled boundaries must only contain 'f' tags.
|
||||
fresh_boundaries = _settled_boundaries(list(delta.get_state_history(fresh_cfg)))
|
||||
assert fresh_boundaries, "fresh thread has no settled boundaries"
|
||||
for _, items in fresh_boundaries:
|
||||
for it in items:
|
||||
assert it.startswith("f"), (
|
||||
f"fresh thread leaked item from migrated thread: {it}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Tip-of-pre-migration hydration: the latest checkpoint from a binop-run
|
||||
# thread has a real accumulated value in its own `channel_values["items"]`.
|
||||
# When hydrated under the delta-channel graph via `get_state(config)` with no
|
||||
# `checkpoint_id`, the short-circuit must use that value directly instead of
|
||||
# walking ancestors (which would skip the tip's own blob).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_tip_of_pre_migration_hydrates_directly() -> None:
|
||||
"""`graph.get_state(config)` at the latest (pre-migration) checkpoint
|
||||
returns the full accumulated list stored in that checkpoint's own
|
||||
`channel_values`. The hydration must not walk ancestors past it."""
|
||||
|
||||
checkpointer = InMemorySaver()
|
||||
config = {"configurable": {"thread_id": "tip-sync"}}
|
||||
|
||||
binop = _binop_graph(checkpointer)
|
||||
_drive(binop, config, "u", 3)
|
||||
|
||||
binop_tip = binop.get_state(config)
|
||||
expected_items = list(binop_tip.values.get("items", []))
|
||||
assert expected_items == ["u0", "u1", "u2"], (
|
||||
f"sanity: pre-migration tip should accumulate all 3 items, got {expected_items}"
|
||||
)
|
||||
|
||||
delta = _delta_graph(checkpointer)
|
||||
|
||||
snap = delta.get_state(config)
|
||||
assert list(snap.values.get("items", [])) == expected_items, (
|
||||
f"tip hydration mismatch: expected {expected_items}, "
|
||||
f"got {snap.values.get('items', [])}"
|
||||
)
|
||||
|
||||
|
||||
async def test_tip_of_pre_migration_hydrates_directly_async() -> None:
|
||||
"""Async variant of the tip-of-pre-migration hydration scenario."""
|
||||
|
||||
checkpointer = InMemorySaver()
|
||||
config = {"configurable": {"thread_id": "tip-async"}}
|
||||
|
||||
binop = _binop_graph(checkpointer)
|
||||
await _adrive(binop, config, "u", 3)
|
||||
|
||||
binop_tip = await binop.aget_state(config)
|
||||
expected_items = list(binop_tip.values.get("items", []))
|
||||
assert expected_items == ["u0", "u1", "u2"]
|
||||
|
||||
delta = _delta_graph(checkpointer)
|
||||
|
||||
snap = await delta.aget_state(config)
|
||||
assert list(snap.values.get("items", [])) == expected_items, (
|
||||
f"async tip hydration mismatch: expected {expected_items}, "
|
||||
f"got {snap.values.get('items', [])}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. `update_state` after migration writes a real value to the new
|
||||
# checkpoint's `channel_values` (not a sentinel). Hydration must use it
|
||||
# directly — the ancestor walk would skip this blob and return stale state.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_update_state_after_migration_uses_written_value() -> None:
|
||||
"""After migrating and running at least one post-migration super-step
|
||||
(so the thread's tip has its delta channel absent from `channel_values`,
|
||||
or stored as a `_DeltaSnapshot` on a snapshot step), `update_state`
|
||||
writes a concrete value into a new checkpoint's `channel_values`.
|
||||
`get_state` must reflect that concrete value."""
|
||||
|
||||
checkpointer = InMemorySaver()
|
||||
config = {"configurable": {"thread_id": "update-state"}}
|
||||
|
||||
# Pre-migration: accumulate a little state.
|
||||
binop = _binop_graph(checkpointer)
|
||||
_drive(binop, config, "u", 2)
|
||||
|
||||
# Migrate and run one more super-step so the tip is a post-migration
|
||||
# checkpoint where the delta channel is absent from `channel_values`
|
||||
# (no snapshot fired this step).
|
||||
delta = _delta_graph(checkpointer)
|
||||
delta.invoke({"items": ["post"]}, config)
|
||||
|
||||
# `update_state` writes a concrete value into a new checkpoint's blob
|
||||
# via the reducer against the hydrated prior state.
|
||||
delta.update_state(config, {"items": ["x", "y"]})
|
||||
|
||||
snap = delta.get_state(config)
|
||||
updated_items = list(snap.values.get("items", []))
|
||||
# Must include the "x","y" update; without the hydration fix, the
|
||||
# update_state-written blob would be skipped in favor of an ancestor
|
||||
# walk, and the update values would disappear.
|
||||
assert "x" in updated_items and "y" in updated_items, (
|
||||
f"update_state values missing from snapshot: {updated_items}"
|
||||
)
|
||||
# The "x","y" items should be folded onto the prior accumulated state,
|
||||
# not stand alone. This verifies the update-written blob is used
|
||||
# directly by `get_state` (no ancestor walk past it).
|
||||
assert len(updated_items) >= 4, (
|
||||
f"update_state snapshot should preserve pre-update state, got {updated_items}"
|
||||
)
|
||||
assert updated_items[-2:] == ["x", "y"], (
|
||||
f"update_state deltas should be at the tail, got {updated_items}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. Fork from an `update_state` checkpoint: a new run branched off the
|
||||
# update_state-produced checkpoint must see that checkpoint's concrete
|
||||
# `channel_values` as its base, with new deltas folded on top.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_fork_from_update_state_checkpoint() -> None:
|
||||
"""Branching a new run from the checkpoint produced by `update_state`
|
||||
must use that checkpoint's concrete blob as the base. Additional
|
||||
deltas from the forked run fold onto it through the reducer."""
|
||||
|
||||
checkpointer = InMemorySaver()
|
||||
config = {"configurable": {"thread_id": "fork"}}
|
||||
|
||||
# Pre-migration build-up, then migrate and add one post-migration step.
|
||||
binop = _binop_graph(checkpointer)
|
||||
_drive(binop, config, "u", 2)
|
||||
delta = _delta_graph(checkpointer)
|
||||
delta.invoke({"items": ["post"]}, config)
|
||||
|
||||
# Apply `update_state` and capture the returned config (references
|
||||
# the new checkpoint produced by the update).
|
||||
update_cfg = delta.update_state(config, {"items": ["x", "y"]})
|
||||
|
||||
update_snap = delta.get_state(update_cfg)
|
||||
base_items = list(update_snap.values.get("items", []))
|
||||
assert "x" in base_items and "y" in base_items, (
|
||||
f"update_state values missing from snapshot: {base_items}"
|
||||
)
|
||||
assert base_items[-2:] == ["x", "y"], (
|
||||
f"sanity: update_state deltas should be at the tail, got {base_items}"
|
||||
)
|
||||
|
||||
# Fork: invoke from the update_state checkpoint with a new delta.
|
||||
forked = delta.invoke({"items": ["fork0"]}, update_cfg)
|
||||
forked_items = list(forked.get("items", []))
|
||||
# The fork must see the update_state-written blob as its base (not
|
||||
# walk past it), and the new delta must fold on top of it.
|
||||
assert forked_items[: len(base_items)] == base_items, (
|
||||
f"fork lost update_state base: base={base_items}, forked={forked_items}"
|
||||
)
|
||||
assert forked_items[-1] == "fork0", f"fork delta not appended: {forked_items}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 9. Migration from `add_messages` → `DeltaChannel(_messages_delta_reducer)`
|
||||
#
|
||||
# `add_messages` is the primary real-world use case: it creates a
|
||||
# BinaryOperatorAggregate with dedup-by-ID and RemoveMessage semantics.
|
||||
# After swapping the annotation to DeltaChannel, pre-migration blobs
|
||||
# (plain lists of Message objects) must be used directly as the seed.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _add_messages_graph(checkpointer: Any) -> Any:
|
||||
class MessagesState(TypedDict):
|
||||
messages: Annotated[list, add_messages]
|
||||
|
||||
return (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("noop", _noop)
|
||||
.add_edge(START, "noop")
|
||||
.add_edge("noop", END)
|
||||
.compile(checkpointer=checkpointer)
|
||||
)
|
||||
|
||||
|
||||
def _delta_messages_graph(checkpointer: Any) -> Any:
|
||||
class DeltaMessagesState(TypedDict):
|
||||
messages: Annotated[list, DeltaChannel(_messages_delta_reducer)]
|
||||
|
||||
return (
|
||||
StateGraph(DeltaMessagesState)
|
||||
.add_node("noop", _noop)
|
||||
.add_edge(START, "noop")
|
||||
.add_edge("noop", END)
|
||||
.compile(checkpointer=checkpointer)
|
||||
)
|
||||
|
||||
|
||||
def test_add_messages_to_delta_migration_preserves_message_history() -> None:
|
||||
"""Migration from `add_messages` to `DeltaChannel(_messages_delta_reducer)`
|
||||
preserves message ordering and IDs at both the tip and settled ancestor
|
||||
boundaries.
|
||||
|
||||
The pre-migration blob is a plain list of Message objects; DeltaChannel
|
||||
must use it directly as the seed without walking ancestors past it.
|
||||
"""
|
||||
checkpointer = InMemorySaver()
|
||||
config = {"configurable": {"thread_id": "add-messages-migration"}}
|
||||
|
||||
pre_graph = _add_messages_graph(checkpointer)
|
||||
pre_graph.invoke({"messages": [HumanMessage(content="hello", id="h1")]}, config)
|
||||
pre_graph.invoke({"messages": [AIMessage(content="hi", id="a1")]}, config)
|
||||
pre_graph.invoke({"messages": [HumanMessage(content="thanks", id="h2")]}, config)
|
||||
|
||||
pre_tip = pre_graph.get_state(config)
|
||||
assert [m.id for m in pre_tip.values["messages"]] == ["h1", "a1", "h2"]
|
||||
|
||||
delta_graph = _delta_messages_graph(checkpointer)
|
||||
|
||||
# Tip: latest checkpoint has a full list blob — must use it directly.
|
||||
snap = delta_graph.get_state(config)
|
||||
assert [m.id for m in snap.values["messages"]] == ["h1", "a1", "h2"], (
|
||||
f"tip hydration mismatch: got {[m.id for m in snap.values['messages']]}"
|
||||
)
|
||||
|
||||
# Settled ancestor boundaries must also match.
|
||||
pre_settled = [
|
||||
[m.id for m in s.values.get("messages", [])]
|
||||
for s in pre_graph.get_state_history(config)
|
||||
if s.next == ("__start__",)
|
||||
]
|
||||
delta_settled = [
|
||||
[m.id for m in s.values.get("messages", [])]
|
||||
for s in delta_graph.get_state_history(config)
|
||||
if s.next == ("__start__",)
|
||||
]
|
||||
assert delta_settled == pre_settled, (
|
||||
f"settled boundary mismatch after migration: "
|
||||
f"pre={pre_settled}, delta={delta_settled}"
|
||||
)
|
||||
|
||||
|
||||
async def test_add_messages_to_delta_migration_preserves_message_history_async() -> (
|
||||
None
|
||||
):
|
||||
"""Async variant of the add_messages migration test."""
|
||||
checkpointer = InMemorySaver()
|
||||
config = {"configurable": {"thread_id": "add-messages-migration-async"}}
|
||||
|
||||
pre_graph = _add_messages_graph(checkpointer)
|
||||
await pre_graph.ainvoke(
|
||||
{"messages": [HumanMessage(content="hello", id="h1")]}, config
|
||||
)
|
||||
await pre_graph.ainvoke({"messages": [AIMessage(content="hi", id="a1")]}, config)
|
||||
|
||||
delta_graph = _delta_messages_graph(checkpointer)
|
||||
snap = await delta_graph.aget_state(config)
|
||||
assert [m.id for m in snap.values["messages"]] == ["h1", "a1"], (
|
||||
f"async tip hydration mismatch: got {[m.id for m in snap.values['messages']]}"
|
||||
)
|
||||
@@ -0,0 +1,359 @@
|
||||
"""Tests for arrival-ordered interleave and push stamps."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import operator
|
||||
from typing import Annotated, Any
|
||||
|
||||
import pytest
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from langgraph.constants import END, START
|
||||
from langgraph.graph import StateGraph
|
||||
from langgraph.stream import StreamChannel, StreamTransformer
|
||||
from langgraph.stream._mux import StreamMux
|
||||
from langgraph.stream._types import ProtocolEvent
|
||||
from langgraph.stream.run_stream import GraphRunStream
|
||||
from langgraph.stream.transformers import ValuesTransformer
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _TwoChannelTransformer(StreamTransformer):
|
||||
"""Transformer that exposes two named channels for testing interleave."""
|
||||
|
||||
_native = True
|
||||
|
||||
def __init__(self, scope: tuple[str, ...] = ()) -> None:
|
||||
super().__init__(scope)
|
||||
self._alpha: StreamChannel[str] = StreamChannel("alpha")
|
||||
self._beta: StreamChannel[str] = StreamChannel("beta")
|
||||
|
||||
def init(self) -> dict[str, Any]:
|
||||
return {"alpha": self._alpha, "beta": self._beta}
|
||||
|
||||
def process(self, event: ProtocolEvent) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
class SimpleState(TypedDict):
|
||||
value: str
|
||||
items: Annotated[list[str], operator.add]
|
||||
|
||||
|
||||
def _build_simple_graph():
|
||||
def node_a(state: SimpleState) -> dict:
|
||||
return {"value": state["value"] + "A", "items": ["a"]}
|
||||
|
||||
def node_b(state: SimpleState) -> dict:
|
||||
return {"value": state["value"] + "B", "items": ["b"]}
|
||||
|
||||
builder = StateGraph(SimpleState)
|
||||
builder.add_node("node_a", node_a)
|
||||
builder.add_node("node_b", node_b)
|
||||
builder.add_edge(START, "node_a")
|
||||
builder.add_edge("node_a", "node_b")
|
||||
builder.add_edge("node_b", END)
|
||||
return builder.compile()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests: push stamps on StreamChannel
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPushStamps:
|
||||
def test_stamps_are_monotonic_across_channels(self) -> None:
|
||||
mux = StreamMux(
|
||||
factories=[ValuesTransformer, _TwoChannelTransformer],
|
||||
is_async=False,
|
||||
)
|
||||
alpha = mux.extensions["alpha"]
|
||||
beta = mux.extensions["beta"]
|
||||
|
||||
alpha._subscribed = True
|
||||
beta._subscribed = True
|
||||
|
||||
alpha.push("a1")
|
||||
beta.push("b1")
|
||||
alpha.push("a2")
|
||||
beta.push("b2")
|
||||
|
||||
all_stamped = list(alpha._items) + list(beta._items)
|
||||
stamps = [s for s, _ in all_stamped]
|
||||
assert len(set(stamps)) == 4
|
||||
items_by_arrival = [item for _, item in sorted(all_stamped)]
|
||||
assert items_by_arrival == ["a1", "b1", "a2", "b2"]
|
||||
|
||||
def test_regular_iter_strips_stamps(self) -> None:
|
||||
mux = StreamMux(
|
||||
factories=[ValuesTransformer, _TwoChannelTransformer],
|
||||
is_async=False,
|
||||
)
|
||||
alpha = mux.extensions["alpha"]
|
||||
it = iter(alpha)
|
||||
alpha.push("a1")
|
||||
alpha.push("a2")
|
||||
alpha.close()
|
||||
items = list(it)
|
||||
assert items == ["a1", "a2"]
|
||||
assert all(isinstance(item, str) for item in items)
|
||||
|
||||
def test_events_channel_gets_real_stamps(self) -> None:
|
||||
mux = StreamMux(
|
||||
factories=[ValuesTransformer, _TwoChannelTransformer],
|
||||
is_async=False,
|
||||
)
|
||||
alpha = mux.extensions["alpha"]
|
||||
|
||||
alpha._subscribed = True
|
||||
alpha.push("a1")
|
||||
|
||||
mux._events._subscribed = True
|
||||
mux._events.push({"method": "test", "data": "x"})
|
||||
|
||||
alpha.push("a2")
|
||||
|
||||
all_stamps = [s for s, _ in alpha._items] + [s for s, _ in mux._events._items]
|
||||
assert len(set(all_stamps)) == len(all_stamps), "all stamps should be unique"
|
||||
assert all(s > 0 for s in all_stamps), "no stamp should be zero"
|
||||
|
||||
def test_channel_without_mux_gets_zero_stamp(self) -> None:
|
||||
ch: StreamChannel[str] = StreamChannel()
|
||||
ch._bind(is_async=False)
|
||||
ch._subscribed = True
|
||||
ch.push("x")
|
||||
assert list(ch._items) == [(0, "x")]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests: interleave arrival order
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestInterleaveArrivalOrder:
|
||||
def test_arrival_order_not_round_robin(self) -> None:
|
||||
mux = StreamMux(
|
||||
factories=[ValuesTransformer, _TwoChannelTransformer],
|
||||
is_async=False,
|
||||
)
|
||||
alpha = mux.extensions["alpha"]
|
||||
beta = mux.extensions["beta"]
|
||||
run = GraphRunStream(None, mux, wire_pump=False)
|
||||
|
||||
# interleave() subscribes channels directly and reads _items
|
||||
# for stamp-ordered iteration. We simulate the pump by wiring
|
||||
# a custom callback that pushes items in a known order.
|
||||
push_script = [
|
||||
("alpha", "a1"),
|
||||
("alpha", "a2"),
|
||||
("beta", "b1"),
|
||||
("alpha", "a3"),
|
||||
("beta", "b2"),
|
||||
]
|
||||
push_iter = iter(push_script)
|
||||
channels = {"alpha": alpha, "beta": beta}
|
||||
|
||||
def fake_pump() -> bool:
|
||||
try:
|
||||
name, item = next(push_iter)
|
||||
channels[name].push(item)
|
||||
return True
|
||||
except StopIteration:
|
||||
mux.close()
|
||||
return False
|
||||
|
||||
mux.bind_pump(fake_pump)
|
||||
|
||||
result = list(run.interleave("alpha", "beta"))
|
||||
names = [name for name, _ in result]
|
||||
items = [item for _, item in result]
|
||||
|
||||
assert items == ["a1", "a2", "b1", "a3", "b2"]
|
||||
assert names == ["alpha", "alpha", "beta", "alpha", "beta"]
|
||||
|
||||
def test_single_projection(self) -> None:
|
||||
mux = StreamMux(
|
||||
factories=[ValuesTransformer, _TwoChannelTransformer],
|
||||
is_async=False,
|
||||
)
|
||||
alpha = mux.extensions["alpha"]
|
||||
run = GraphRunStream(None, mux, wire_pump=False)
|
||||
|
||||
push_script = [("alpha", "a1"), ("alpha", "a2")]
|
||||
push_iter = iter(push_script)
|
||||
|
||||
def fake_pump() -> bool:
|
||||
try:
|
||||
_, item = next(push_iter)
|
||||
alpha.push(item)
|
||||
return True
|
||||
except StopIteration:
|
||||
mux.close()
|
||||
return False
|
||||
|
||||
mux.bind_pump(fake_pump)
|
||||
|
||||
result = list(run.interleave("alpha"))
|
||||
assert result == [("alpha", "a1"), ("alpha", "a2")]
|
||||
|
||||
def test_empty_projection(self) -> None:
|
||||
mux = StreamMux(
|
||||
factories=[ValuesTransformer, _TwoChannelTransformer],
|
||||
is_async=False,
|
||||
)
|
||||
alpha = mux.extensions["alpha"]
|
||||
run = GraphRunStream(None, mux, wire_pump=False)
|
||||
|
||||
push_script = [("alpha", "a1"), ("alpha", "a2")]
|
||||
push_iter = iter(push_script)
|
||||
channels = {"alpha": alpha}
|
||||
|
||||
def fake_pump() -> bool:
|
||||
try:
|
||||
name, item = next(push_iter)
|
||||
channels[name].push(item)
|
||||
return True
|
||||
except StopIteration:
|
||||
mux.close()
|
||||
return False
|
||||
|
||||
mux.bind_pump(fake_pump)
|
||||
|
||||
result = list(run.interleave("alpha", "beta"))
|
||||
assert result == [("alpha", "a1"), ("alpha", "a2")]
|
||||
|
||||
def test_unknown_projection_raises(self) -> None:
|
||||
mux = StreamMux(
|
||||
factories=[ValuesTransformer, _TwoChannelTransformer],
|
||||
is_async=False,
|
||||
)
|
||||
run = GraphRunStream(None, mux, wire_pump=False)
|
||||
mux.close()
|
||||
with pytest.raises((KeyError, AttributeError)):
|
||||
list(run.interleave("alpha", "does_not_exist"))
|
||||
|
||||
def test_all_empty(self) -> None:
|
||||
mux = StreamMux(
|
||||
factories=[ValuesTransformer, _TwoChannelTransformer],
|
||||
is_async=False,
|
||||
)
|
||||
run = GraphRunStream(None, mux, wire_pump=False)
|
||||
|
||||
def fake_pump() -> bool:
|
||||
mux.close()
|
||||
return False
|
||||
|
||||
mux.bind_pump(fake_pump)
|
||||
|
||||
result = list(run.interleave("alpha", "beta"))
|
||||
assert result == []
|
||||
|
||||
def test_error_propagation(self) -> None:
|
||||
mux = StreamMux(
|
||||
factories=[ValuesTransformer, _TwoChannelTransformer],
|
||||
is_async=False,
|
||||
)
|
||||
alpha = mux.extensions["alpha"]
|
||||
beta = mux.extensions["beta"]
|
||||
run = GraphRunStream(None, mux, wire_pump=False)
|
||||
|
||||
err = RuntimeError("boom")
|
||||
|
||||
push_script = [
|
||||
("alpha", "a1"),
|
||||
("beta", "b1"),
|
||||
]
|
||||
push_iter = iter(push_script)
|
||||
channels = {"alpha": alpha, "beta": beta}
|
||||
|
||||
def fake_pump() -> bool:
|
||||
try:
|
||||
name, item = next(push_iter)
|
||||
channels[name].push(item)
|
||||
return True
|
||||
except StopIteration:
|
||||
alpha.fail(err)
|
||||
beta.close()
|
||||
return False
|
||||
|
||||
mux.bind_pump(fake_pump)
|
||||
|
||||
collected = []
|
||||
with pytest.raises(RuntimeError, match="boom"):
|
||||
for pair in run.interleave("alpha", "beta"):
|
||||
collected.append(pair)
|
||||
|
||||
assert ("alpha", "a1") in collected
|
||||
assert ("beta", "b1") in collected
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration test: interleave with stream_events(version="v3")
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestInterleaveIntegration:
|
||||
def test_interleave_values_and_messages(self) -> None:
|
||||
run = _build_simple_graph().stream_events(
|
||||
{"value": "x", "items": []}, version="v3"
|
||||
)
|
||||
tagged = list(run.interleave("values", "messages"))
|
||||
names = [name for name, _ in tagged]
|
||||
assert set(names).issubset({"values", "messages"})
|
||||
assert names.count("values") >= 1
|
||||
|
||||
def test_interleave_rejects_already_subscribed(self) -> None:
|
||||
mux = StreamMux(
|
||||
factories=[ValuesTransformer, _TwoChannelTransformer],
|
||||
is_async=False,
|
||||
)
|
||||
alpha = mux.extensions["alpha"]
|
||||
run = GraphRunStream(None, mux, wire_pump=False)
|
||||
|
||||
# Subscribe alpha via iter first
|
||||
_ = iter(alpha)
|
||||
mux.close()
|
||||
|
||||
with pytest.raises(RuntimeError, match="already has a subscriber"):
|
||||
list(run.interleave("alpha"))
|
||||
|
||||
def test_interleave_releases_projections_on_completion(self) -> None:
|
||||
run = _build_simple_graph().stream_events(
|
||||
{"value": "x", "items": []}, version="v3"
|
||||
)
|
||||
list(run.interleave("values", "messages"))
|
||||
# Subscriptions should be released after the generator completes,
|
||||
# so the channels can be re-iterated (they'll be empty / closed).
|
||||
assert run.extensions["values"]._subscribed is False
|
||||
assert run.extensions["messages"]._subscribed is False
|
||||
|
||||
def test_interleave_releases_projections_on_early_break(self) -> None:
|
||||
run = _build_simple_graph().stream_events(
|
||||
{"value": "x", "items": []}, version="v3"
|
||||
)
|
||||
gen = run.interleave("values", "messages")
|
||||
next(gen)
|
||||
gen.close()
|
||||
assert run.extensions["values"]._subscribed is False
|
||||
assert run.extensions["messages"]._subscribed is False
|
||||
|
||||
def test_interleave_releases_projections_on_validation_failure(self) -> None:
|
||||
mux = StreamMux(
|
||||
factories=[ValuesTransformer, _TwoChannelTransformer],
|
||||
is_async=False,
|
||||
)
|
||||
alpha = mux.extensions["alpha"]
|
||||
# Pre-subscribe alpha so that interleave will fail validation when
|
||||
# it gets to the second name. The first (already-validated) channel
|
||||
# should still be released.
|
||||
run = GraphRunStream(None, mux, wire_pump=False)
|
||||
mux.close()
|
||||
alpha._subscribed = True
|
||||
|
||||
with pytest.raises(RuntimeError, match="already has a subscriber"):
|
||||
list(run.interleave("values", "alpha"))
|
||||
|
||||
assert mux.extensions["values"]._subscribed is False
|
||||
@@ -16,7 +16,7 @@ from typing import Annotated, Any, Literal, get_type_hints
|
||||
|
||||
import pytest
|
||||
from langchain_core.language_models import GenericFakeChatModel
|
||||
from langchain_core.messages import AIMessage, AnyMessage, HumanMessage
|
||||
from langchain_core.messages import AIMessage, AnyMessage, HumanMessage, RemoveMessage
|
||||
from langchain_core.runnables import (
|
||||
RunnableConfig,
|
||||
RunnableLambda,
|
||||
@@ -41,6 +41,7 @@ from typing_extensions import NotRequired, TypedDict
|
||||
|
||||
from langgraph._internal._constants import CONFIG_KEY_NODE_FINISHED, ERROR, PULL
|
||||
from langgraph.channels.binop import BinaryOperatorAggregate
|
||||
from langgraph.channels.delta import DeltaChannel
|
||||
from langgraph.channels.ephemeral_value import EphemeralValue
|
||||
from langgraph.channels.last_value import LastValue
|
||||
from langgraph.channels.topic import Topic
|
||||
@@ -49,7 +50,7 @@ from langgraph.config import get_stream_writer
|
||||
from langgraph.errors import GraphRecursionError, InvalidUpdateError, ParentCommand
|
||||
from langgraph.func import entrypoint, task
|
||||
from langgraph.graph import END, START, StateGraph
|
||||
from langgraph.graph.message import MessagesState, add_messages
|
||||
from langgraph.graph.message import MessagesState, _messages_delta_reducer, add_messages
|
||||
from langgraph.pregel import (
|
||||
NodeBuilder,
|
||||
Pregel,
|
||||
@@ -120,6 +121,29 @@ def test_graph_validation() -> None:
|
||||
graph.invoke({"hello": "there"})
|
||||
|
||||
|
||||
def test_request_drain_allows_inflight_call_scheduling(
|
||||
sync_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
from langgraph.runtime import RunControl
|
||||
|
||||
@task
|
||||
def child(x: int) -> int:
|
||||
return x + 1
|
||||
|
||||
control = RunControl()
|
||||
|
||||
@entrypoint(checkpointer=sync_checkpointer)
|
||||
def graph(x: int) -> int:
|
||||
control.request_drain()
|
||||
fut = child(x)
|
||||
return fut.result()
|
||||
|
||||
config = {"configurable": {"thread_id": "drain-call-sync"}}
|
||||
|
||||
assert graph.invoke(1, config=config, control=control) == 2
|
||||
assert control.drain_requested
|
||||
|
||||
|
||||
def test_invalid_checkpointer_type() -> None:
|
||||
class State(TypedDict):
|
||||
foo: str
|
||||
@@ -9400,3 +9424,263 @@ def test_fork_does_not_apply_pending_writes(
|
||||
|
||||
# Should be: 1 (input) + 20 (forked node_a) + 100 (node_b) = 121
|
||||
assert result == {"value": 121}
|
||||
|
||||
|
||||
async def test_delta_channel_end_to_end_inmemory() -> None:
|
||||
"""Full graph run: DeltaChannel accumulates correctly across multiple turns."""
|
||||
|
||||
class State(TypedDict):
|
||||
messages: Annotated[list, DeltaChannel(_messages_delta_reducer)]
|
||||
|
||||
def respond(state: State) -> dict:
|
||||
n = len(state["messages"])
|
||||
return {"messages": [AIMessage(content=f"reply-{n}", id=f"ai-{n}")]}
|
||||
|
||||
builder = StateGraph(State)
|
||||
builder.add_node("respond", respond)
|
||||
builder.add_edge(START, "respond")
|
||||
graph = builder.compile(checkpointer=InMemorySaver())
|
||||
|
||||
config = {"configurable": {"thread_id": "diff-test-1"}}
|
||||
|
||||
# Turn 1
|
||||
graph.invoke({"messages": [HumanMessage(content="hello", id="h1")]}, config)
|
||||
# Turn 2
|
||||
graph.invoke({"messages": [HumanMessage(content="world", id="h2")]}, config)
|
||||
# Turn 3
|
||||
graph.invoke({"messages": [HumanMessage(content="bye", id="h3")]}, config)
|
||||
|
||||
state = graph.get_state(config)
|
||||
msgs = state.values["messages"]
|
||||
# 3 human + 3 AI = 6 total
|
||||
assert len(msgs) == 6, f"expected 6 messages, got {len(msgs)}: {msgs}"
|
||||
assert msgs[0].content == "hello"
|
||||
assert msgs[2].content == "world"
|
||||
assert msgs[4].content == "bye"
|
||||
assert msgs[1].content == "reply-1"
|
||||
assert msgs[3].content == "reply-3"
|
||||
assert msgs[5].content == "reply-5"
|
||||
|
||||
|
||||
async def test_delta_channel_time_travel() -> None:
|
||||
"""Time-travel back to turn-1 checkpoint and resume; continuation must not include turn-2 deltas."""
|
||||
|
||||
class State(TypedDict):
|
||||
messages: Annotated[list, DeltaChannel(_messages_delta_reducer)]
|
||||
|
||||
counter = {"n": 0}
|
||||
|
||||
def respond(state: State) -> dict:
|
||||
counter["n"] += 1
|
||||
return {
|
||||
"messages": [
|
||||
AIMessage(content=f"ai-{counter['n']}", id=f"ai-{counter['n']}")
|
||||
]
|
||||
}
|
||||
|
||||
builder = StateGraph(State)
|
||||
builder.add_node("respond", respond)
|
||||
builder.add_edge(START, "respond")
|
||||
saver = InMemorySaver()
|
||||
graph = builder.compile(checkpointer=saver)
|
||||
|
||||
config = {"configurable": {"thread_id": "diff-time-travel"}}
|
||||
|
||||
# Run 2 turns: h1→ai-1, h2→ai-2
|
||||
graph.invoke({"messages": [HumanMessage(content="h1", id="h1")]}, config)
|
||||
graph.invoke({"messages": [HumanMessage(content="h2", id="h2")]}, config)
|
||||
|
||||
# Find the checkpoint after turn 1 (2 messages: h1 + ai-1)
|
||||
history = list(graph.get_state_history(config))
|
||||
after_turn1 = next(h for h in history if len(h.values.get("messages", [])) == 2)
|
||||
|
||||
assert len(after_turn1.values["messages"]) == 2
|
||||
assert after_turn1.values["messages"][0].content == "h1"
|
||||
assert after_turn1.values["messages"][1].content == "ai-1"
|
||||
|
||||
# Resume from turn-1 checkpoint: inject h3, expect 3 messages total (h1, ai-1, ai-N)
|
||||
# NOT 5 messages (turn-2 deltas must not bleed into the resumed run)
|
||||
result = graph.invoke(
|
||||
{"messages": [HumanMessage(content="h3", id="h3")]},
|
||||
after_turn1.config,
|
||||
)
|
||||
msgs = result["messages"]
|
||||
# Should be: h1, ai-1, h3, ai-N — 4 messages total
|
||||
assert len(msgs) == 4, (
|
||||
f"expected 4 messages after time-travel resume, got {len(msgs)}: {msgs}"
|
||||
)
|
||||
assert msgs[0].content == "h1"
|
||||
assert msgs[1].content == "ai-1"
|
||||
assert msgs[2].content == "h3"
|
||||
|
||||
|
||||
async def test_delta_channel_remove_message_end_to_end() -> None:
|
||||
"""RemoveMessage inside a DeltaChannel graph must persist and reload correctly."""
|
||||
|
||||
class State(TypedDict):
|
||||
messages: Annotated[list, DeltaChannel(_messages_delta_reducer)]
|
||||
|
||||
def respond(state: State) -> dict:
|
||||
return {"messages": [AIMessage(content="reply", id="ai-1")]}
|
||||
|
||||
def delete_first(state: State) -> dict:
|
||||
# removes the first message
|
||||
return {"messages": [RemoveMessage(id=state["messages"][0].id)]}
|
||||
|
||||
builder = StateGraph(State)
|
||||
builder.add_node("respond", respond)
|
||||
builder.add_node("delete_first", delete_first)
|
||||
builder.add_edge(START, "respond")
|
||||
builder.add_edge("respond", "delete_first")
|
||||
graph = builder.compile(checkpointer=InMemorySaver())
|
||||
|
||||
config = {"configurable": {"thread_id": "diff-remove-test"}}
|
||||
graph.invoke({"messages": [HumanMessage(content="hello", id="h1")]}, config)
|
||||
|
||||
state = graph.get_state(config)
|
||||
msgs = state.values["messages"]
|
||||
# h1 was removed, only ai-1 should remain
|
||||
assert len(msgs) == 1, f"expected 1 message, got {len(msgs)}: {msgs}"
|
||||
assert msgs[0].id == "ai-1"
|
||||
|
||||
# A subsequent turn must reconstruct from the checkpoint correctly
|
||||
graph.invoke({"messages": [HumanMessage(content="again", id="h2")]}, config)
|
||||
state = graph.get_state(config)
|
||||
msgs = state.values["messages"]
|
||||
# ai-1 + h2 + ai-1(second reply, same id overwrites) + h2 removed
|
||||
# more simply: after second run we expect ai-1 updated + h2 remaining minus deleted h2
|
||||
# just assert h1 is still gone
|
||||
assert all(m.id != "h1" for m in msgs), (
|
||||
"h1 should still be absent after second turn"
|
||||
)
|
||||
|
||||
|
||||
async def test_delta_channel_update_by_id_end_to_end() -> None:
|
||||
"""Updating a message by ID via DeltaChannel must persist and reload correctly."""
|
||||
|
||||
class State(TypedDict):
|
||||
messages: Annotated[list, DeltaChannel(_messages_delta_reducer)]
|
||||
|
||||
def update_msg(state: State) -> dict:
|
||||
# re-send h1 with updated content
|
||||
return {"messages": [HumanMessage(content="updated", id="h1")]}
|
||||
|
||||
builder = StateGraph(State)
|
||||
builder.add_node("update_msg", update_msg)
|
||||
builder.add_edge(START, "update_msg")
|
||||
graph = builder.compile(checkpointer=InMemorySaver())
|
||||
|
||||
config = {"configurable": {"thread_id": "diff-update-id-test"}}
|
||||
graph.invoke({"messages": [HumanMessage(content="original", id="h1")]}, config)
|
||||
|
||||
state = graph.get_state(config)
|
||||
msgs = state.values["messages"]
|
||||
assert len(msgs) == 1, f"expected 1 message, got {len(msgs)}: {msgs}"
|
||||
assert msgs[0].content == "updated"
|
||||
assert msgs[0].id == "h1"
|
||||
|
||||
# Second turn: verify the updated state is the base for further accumulation
|
||||
graph.invoke({"messages": [HumanMessage(content="new", id="h2")]}, config)
|
||||
state = graph.get_state(config)
|
||||
msgs = state.values["messages"]
|
||||
ids = [m.id for m in msgs]
|
||||
assert "h1" in ids # h1 persists (updated, not duplicated)
|
||||
assert "h2" in ids
|
||||
assert ids.count("h1") == 1, "h1 must not be duplicated"
|
||||
|
||||
|
||||
async def test_delta_channel_durability_exit_stores_snapshot() -> None:
|
||||
"""DeltaChannel must reload from a durability='exit' checkpoint."""
|
||||
|
||||
class State(TypedDict):
|
||||
messages: Annotated[list, DeltaChannel(_messages_delta_reducer)]
|
||||
|
||||
def respond(state: State) -> dict:
|
||||
return {"messages": [AIMessage(content="reply", id="ai1")]}
|
||||
|
||||
builder = StateGraph(State)
|
||||
builder.add_node("respond", respond)
|
||||
builder.add_edge(START, "respond")
|
||||
graph = builder.compile(checkpointer=InMemorySaver())
|
||||
config = {"configurable": {"thread_id": "delta-exit-test"}}
|
||||
|
||||
result = graph.invoke(
|
||||
{"messages": [HumanMessage(content="hello", id="h1")]},
|
||||
config,
|
||||
durability="exit",
|
||||
)
|
||||
assert [m.content for m in result["messages"]] == ["hello", "reply"]
|
||||
|
||||
state = graph.get_state(config)
|
||||
assert [m.content for m in state.values["messages"]] == ["hello", "reply"]
|
||||
|
||||
|
||||
async def test_delta_channel_async_write_ordering() -> None:
|
||||
"""In async mode, DeltaChannel write futures are awaited before the checkpoint
|
||||
is committed, so aput_writes always precedes aput for delta-channel
|
||||
checkpoints (those where the delta channel had a versioned write but
|
||||
is absent from `channel_values`, i.e. no snapshot fired this step)."""
|
||||
|
||||
class State(TypedDict):
|
||||
messages: Annotated[list, DeltaChannel(_messages_delta_reducer)]
|
||||
|
||||
def respond(state: State) -> dict:
|
||||
i = len(state["messages"])
|
||||
return {"messages": [AIMessage(content=f"r{i}", id=f"ai{i}")]}
|
||||
|
||||
order: list[str] = []
|
||||
original_aput_writes = InMemorySaver.aput_writes
|
||||
original_aput = InMemorySaver.aput
|
||||
|
||||
async def tracked_aput_writes(self, config, writes, task_id, task_path=""):
|
||||
result = await original_aput_writes(self, config, writes, task_id, task_path)
|
||||
order.append("aput_writes")
|
||||
return result
|
||||
|
||||
async def tracked_aput(self, config, checkpoint, metadata, new_versions):
|
||||
# A "delta" checkpoint here = `messages` versioned but absent from
|
||||
# `channel_values` (no snapshot fired). When a snapshot does fire,
|
||||
# `channel_values["messages"]` is a `_DeltaSnapshot` — also a delta
|
||||
# checkpoint shape, since the writes still have to be persisted
|
||||
# before the parent checkpoint commits.
|
||||
channel_values = checkpoint.get("channel_values", {})
|
||||
is_delta_step = (
|
||||
"messages" in checkpoint.get("channel_versions", {})
|
||||
and "messages" not in channel_values
|
||||
)
|
||||
order.append("aput_delta" if is_delta_step else "aput_other")
|
||||
return await original_aput(self, config, checkpoint, metadata, new_versions)
|
||||
|
||||
InMemorySaver.aput_writes = tracked_aput_writes
|
||||
InMemorySaver.aput = tracked_aput
|
||||
try:
|
||||
builder = StateGraph(State)
|
||||
builder.add_node("respond", respond)
|
||||
builder.add_edge(START, "respond")
|
||||
graph = builder.compile(checkpointer=InMemorySaver())
|
||||
config = {"configurable": {"thread_id": "async-ordering-test"}}
|
||||
|
||||
for i in range(3):
|
||||
await graph.ainvoke(
|
||||
{"messages": [HumanMessage(content=f"h{i}", id=f"h{i}")]}, config
|
||||
)
|
||||
|
||||
# Every aput_delta must be preceded by at least one aput_writes
|
||||
for i, event in enumerate(order):
|
||||
if event == "aput_delta":
|
||||
preceding = order[:i]
|
||||
assert "aput_writes" in preceding, (
|
||||
f"aput_delta at {i} had no preceding aput_writes: {order}"
|
||||
)
|
||||
last_write_idx = max(
|
||||
j for j, e in enumerate(order[:i]) if e == "aput_writes"
|
||||
)
|
||||
assert last_write_idx < i, (
|
||||
f"aput_writes at {last_write_idx} should precede aput_delta at {i}: {order}"
|
||||
)
|
||||
finally:
|
||||
InMemorySaver.aput_writes = original_aput_writes
|
||||
InMemorySaver.aput = original_aput
|
||||
|
||||
state = await graph.aget_state(config)
|
||||
assert len(state.values["messages"]) == 6 # 3 human + 3 AI
|
||||
|
||||
@@ -16,6 +16,7 @@ from typing import (
|
||||
Literal,
|
||||
Optional,
|
||||
)
|
||||
from unittest.mock import patch
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
@@ -48,6 +49,7 @@ from langgraph.channels.topic import Topic
|
||||
from langgraph.errors import (
|
||||
GraphRecursionError,
|
||||
InvalidUpdateError,
|
||||
NodeError,
|
||||
ParentCommand,
|
||||
)
|
||||
from langgraph.func import entrypoint, task
|
||||
@@ -215,6 +217,30 @@ async def test_checkpoint_errors() -> None:
|
||||
pass
|
||||
|
||||
|
||||
@NEEDS_CONTEXTVARS
|
||||
async def test_request_drain_allows_inflight_acall_scheduling(
|
||||
async_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
from langgraph.runtime import RunControl
|
||||
|
||||
@task
|
||||
async def child(x: int) -> int:
|
||||
return x + 1
|
||||
|
||||
control = RunControl()
|
||||
|
||||
@entrypoint(checkpointer=async_checkpointer)
|
||||
async def graph(x: int) -> int:
|
||||
control.request_drain()
|
||||
fut = child(x)
|
||||
return await fut
|
||||
|
||||
config = {"configurable": {"thread_id": "drain-call-async"}}
|
||||
|
||||
assert await graph.ainvoke(1, config=config, control=control) == 2
|
||||
assert control.drain_requested
|
||||
|
||||
|
||||
async def test_py_async_with_cancel_behavior() -> None:
|
||||
"""This test confirms that in all versions of Python we support, __aexit__
|
||||
is not cancelled when the coroutine containing the async with block is cancelled."""
|
||||
@@ -6101,6 +6127,36 @@ async def test_parent_command(
|
||||
)
|
||||
|
||||
|
||||
async def test_delta_channel_durability_exit_stores_snapshot_async() -> None:
|
||||
"""DeltaChannel must reload from an async durability='exit' checkpoint."""
|
||||
from langchain_core.messages import AIMessage
|
||||
|
||||
from langgraph.channels.delta import DeltaChannel
|
||||
from langgraph.graph.message import _messages_delta_reducer
|
||||
|
||||
class State(TypedDict):
|
||||
messages: Annotated[list, DeltaChannel(_messages_delta_reducer)]
|
||||
|
||||
async def respond(state: State) -> dict:
|
||||
return {"messages": [AIMessage(content="reply", id="ai1")]}
|
||||
|
||||
builder = StateGraph(State)
|
||||
builder.add_node("respond", respond)
|
||||
builder.add_edge(START, "respond")
|
||||
graph = builder.compile(checkpointer=InMemorySaver())
|
||||
config = {"configurable": {"thread_id": "delta-exit-async-test"}}
|
||||
|
||||
result = await graph.ainvoke(
|
||||
{"messages": [HumanMessage(content="hello", id="h1")]},
|
||||
config,
|
||||
durability="exit",
|
||||
)
|
||||
assert [m.content for m in result["messages"]] == ["hello", "reply"]
|
||||
|
||||
state = await graph.aget_state(config)
|
||||
assert [m.content for m in state.values["messages"]] == ["hello", "reply"]
|
||||
|
||||
|
||||
@NEEDS_CONTEXTVARS
|
||||
async def test_interrupt_subgraph(async_checkpointer: BaseCheckpointSaver) -> None:
|
||||
class State(TypedDict):
|
||||
@@ -9709,3 +9765,126 @@ async def test_fork_does_not_apply_pending_writes(
|
||||
|
||||
# 1 (input) + 20 (forked node_a) + 100 (node_b) = 121
|
||||
assert result == {"value": 121}
|
||||
|
||||
|
||||
async def test_graph_error_handler_async_runtime_info() -> None:
|
||||
class State(TypedDict):
|
||||
foo: str
|
||||
|
||||
attempts = 0
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
async def always_failing_node(state: State) -> State:
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
raise ValueError("Always fails async")
|
||||
|
||||
async def err_handler_node(state: State, error: NodeError) -> State:
|
||||
captured["from_node_name"] = error.node
|
||||
captured["from_node_error"] = error.error
|
||||
return {"foo": "handled_async"}
|
||||
|
||||
graph = (
|
||||
StateGraph(State)
|
||||
.add_node(
|
||||
"always_failing",
|
||||
always_failing_node,
|
||||
retry_policy=RetryPolicy(
|
||||
max_attempts=2,
|
||||
initial_interval=0.01,
|
||||
jitter=False,
|
||||
retry_on=ValueError,
|
||||
),
|
||||
error_handler=err_handler_node,
|
||||
)
|
||||
.add_edge(START, "always_failing")
|
||||
.compile()
|
||||
)
|
||||
|
||||
with patch("asyncio.sleep"):
|
||||
result = await graph.ainvoke({"foo": ""})
|
||||
|
||||
assert attempts == 2
|
||||
assert result["foo"] == "handled_async"
|
||||
assert captured["from_node_name"] == "always_failing"
|
||||
assert isinstance(captured["from_node_error"], BaseException)
|
||||
|
||||
|
||||
@NEEDS_CONTEXTVARS
|
||||
async def test_graph_error_handler_does_not_swallow_interrupt_concurrent() -> None:
|
||||
"""When a graph error handler is configured and a node calls interrupt()
|
||||
concurrently with other nodes, the interrupt must still be raised — not
|
||||
silently swallowed."""
|
||||
|
||||
class State(TypedDict):
|
||||
foo: str
|
||||
|
||||
async def node_a(state: State) -> State:
|
||||
val = interrupt("need human input")
|
||||
return {"foo": f"a_{val}"}
|
||||
|
||||
async def node_b(state: State) -> State:
|
||||
return {}
|
||||
|
||||
async def err_handler(state: State) -> State:
|
||||
return {"foo": "handled"}
|
||||
|
||||
checkpointer = InMemorySaver()
|
||||
graph = (
|
||||
StateGraph(State)
|
||||
.add_node("node_a", node_a, error_handler=err_handler)
|
||||
.add_node("node_b", node_b)
|
||||
.add_edge(START, "node_a")
|
||||
.add_edge(START, "node_b")
|
||||
.compile(checkpointer=checkpointer)
|
||||
)
|
||||
|
||||
config = {"configurable": {"thread_id": "test-interrupt-concurrent-async"}}
|
||||
|
||||
await graph.ainvoke({"foo": ""}, config)
|
||||
|
||||
state = await graph.aget_state(config)
|
||||
assert len(state.tasks) > 0
|
||||
|
||||
interrupts = [t for t in state.tasks if hasattr(t, "interrupts") and t.interrupts]
|
||||
assert len(interrupts) > 0, (
|
||||
"GraphInterrupt was swallowed — interrupt() in node_a "
|
||||
"should have paused execution"
|
||||
)
|
||||
|
||||
|
||||
async def test_node_error_handler_handles_subgraph_internal_failure_async() -> None:
|
||||
class SubState(TypedDict):
|
||||
foo: str
|
||||
|
||||
class ParentState(TypedDict):
|
||||
foo: str
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
async def sub_fail_node(state: SubState) -> SubState:
|
||||
raise ValueError("async subgraph boom")
|
||||
|
||||
async def parent_handler(state: ParentState, error: NodeError) -> ParentState:
|
||||
captured["from_node_name"] = error.node
|
||||
captured["from_node_error"] = error.error
|
||||
return {"foo": "handled_async_subgraph"}
|
||||
|
||||
subgraph = (
|
||||
StateGraph(SubState)
|
||||
.add_node("sub_fail_node", sub_fail_node)
|
||||
.add_edge(START, "sub_fail_node")
|
||||
.compile()
|
||||
)
|
||||
|
||||
parent_graph = (
|
||||
StateGraph(ParentState)
|
||||
.add_node("subgraph_node", subgraph, error_handler=parent_handler)
|
||||
.add_edge(START, "subgraph_node")
|
||||
.compile()
|
||||
)
|
||||
|
||||
result = await parent_graph.ainvoke({"foo": ""})
|
||||
assert result["foo"] == "handled_async_subgraph"
|
||||
assert captured["from_node_name"] == "subgraph_node"
|
||||
assert isinstance(captured["from_node_error"], BaseException)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,6 @@
|
||||
import asyncio
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
@@ -6,8 +9,15 @@ from langgraph.checkpoint.memory import MemorySaver
|
||||
from pydantic import BaseModel, ValidationError
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from langgraph.errors import GraphDrained
|
||||
from langgraph.graph import END, START, StateGraph
|
||||
from langgraph.runtime import ExecutionInfo, Runtime, ServerInfo, get_runtime
|
||||
from langgraph.runtime import (
|
||||
ExecutionInfo,
|
||||
RunControl,
|
||||
Runtime,
|
||||
ServerInfo,
|
||||
get_runtime,
|
||||
)
|
||||
|
||||
|
||||
def test_injected_runtime() -> None:
|
||||
@@ -79,6 +89,183 @@ def test_merge_runtime() -> None:
|
||||
assert runtime1.merge(runtime3).context.api_key == "abc" # type: ignore
|
||||
|
||||
|
||||
def test_merge_runtime_preserves_run_control() -> None:
|
||||
control = RunControl()
|
||||
runtime1 = Runtime(control=control)
|
||||
runtime2 = Runtime(context=None)
|
||||
|
||||
assert runtime1.merge(runtime2).control is control
|
||||
|
||||
|
||||
def test_run_control_request_drain_stops_future_steps() -> None:
|
||||
class State(TypedDict, total=False):
|
||||
first: str
|
||||
second: str
|
||||
|
||||
control = RunControl()
|
||||
|
||||
def first_node(state: State) -> dict[str, str]:
|
||||
control.request_drain()
|
||||
return {"first": "done"}
|
||||
|
||||
def second_node(state: State) -> dict[str, str]:
|
||||
return {"second": "should-not-run"}
|
||||
|
||||
graph = StateGraph(State)
|
||||
graph.add_node("first", first_node)
|
||||
graph.add_node("second", second_node)
|
||||
graph.add_edge(START, "first")
|
||||
graph.add_edge("first", "second")
|
||||
graph.add_edge("second", END)
|
||||
|
||||
with pytest.raises(GraphDrained, match="shutdown"):
|
||||
graph.compile().invoke({}, control=control)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_run_control_request_drain_stops_future_steps_async() -> None:
|
||||
class State(TypedDict, total=False):
|
||||
first: str
|
||||
second: str
|
||||
|
||||
control = RunControl()
|
||||
|
||||
async def first_node(state: State) -> dict[str, str]:
|
||||
control.request_drain()
|
||||
return {"first": "done"}
|
||||
|
||||
async def second_node(state: State) -> dict[str, str]:
|
||||
return {"second": "should-not-run"}
|
||||
|
||||
graph = StateGraph(State)
|
||||
graph.add_node("first", first_node)
|
||||
graph.add_node("second", second_node)
|
||||
graph.add_edge(START, "first")
|
||||
graph.add_edge("first", "second")
|
||||
graph.add_edge("second", END)
|
||||
|
||||
with pytest.raises(GraphDrained, match="shutdown"):
|
||||
await graph.compile().ainvoke({}, control=control)
|
||||
|
||||
|
||||
def test_drain_requested_in_terminal_step_finishes_normally() -> None:
|
||||
class State(TypedDict, total=False):
|
||||
value: str
|
||||
|
||||
control = RunControl()
|
||||
|
||||
def node(state: State) -> dict[str, str]:
|
||||
control.request_drain()
|
||||
return {"value": "done"}
|
||||
|
||||
graph = StateGraph(State)
|
||||
graph.add_node("node", node)
|
||||
graph.add_edge(START, "node")
|
||||
graph.add_edge("node", END)
|
||||
|
||||
assert graph.compile().invoke({}, control=control) == {"value": "done"}
|
||||
assert control.drain_requested
|
||||
|
||||
|
||||
def test_drain_with_exit_durability_persists_resume_checkpoint() -> None:
|
||||
class State(TypedDict, total=False):
|
||||
first: str
|
||||
second: str
|
||||
|
||||
control = RunControl()
|
||||
|
||||
def first_node(state: State) -> dict[str, str]:
|
||||
control.request_drain("sigterm")
|
||||
return {"first": "done"}
|
||||
|
||||
def second_node(state: State) -> dict[str, str]:
|
||||
return {"second": "done"}
|
||||
|
||||
graph = StateGraph(State)
|
||||
graph.add_node("first", first_node)
|
||||
graph.add_node("second", second_node)
|
||||
graph.add_edge(START, "first")
|
||||
graph.add_edge("first", "second")
|
||||
graph.add_edge("second", END)
|
||||
|
||||
compiled = graph.compile(checkpointer=MemorySaver())
|
||||
config = {"configurable": {"thread_id": "drain-exit"}}
|
||||
|
||||
with pytest.raises(GraphDrained, match="sigterm"):
|
||||
compiled.invoke({}, config, durability="exit", control=control)
|
||||
|
||||
assert compiled.invoke(None, config, durability="exit") == {
|
||||
"first": "done",
|
||||
"second": "done",
|
||||
}
|
||||
|
||||
|
||||
def test_drain_from_subgraph_can_resume_parent() -> None:
|
||||
class State(TypedDict, total=False):
|
||||
child_first: str
|
||||
child_second: str
|
||||
parent_second: str
|
||||
|
||||
control = RunControl()
|
||||
|
||||
def child_first(state: State) -> dict[str, str]:
|
||||
control.request_drain("sigterm")
|
||||
return {"child_first": "done"}
|
||||
|
||||
def child_second(state: State) -> dict[str, str]:
|
||||
return {"child_second": "done"}
|
||||
|
||||
child_builder = StateGraph(State)
|
||||
child_builder.add_node("child_first", child_first)
|
||||
child_builder.add_node("child_second", child_second)
|
||||
child_builder.add_edge(START, "child_first")
|
||||
child_builder.add_edge("child_first", "child_second")
|
||||
child_builder.add_edge("child_second", END)
|
||||
child_graph = child_builder.compile(checkpointer=True)
|
||||
|
||||
def parent_second(state: State) -> dict[str, str]:
|
||||
return {"parent_second": "done"}
|
||||
|
||||
parent_builder = StateGraph(State)
|
||||
parent_builder.add_node("child", child_graph)
|
||||
parent_builder.add_node("parent_second", parent_second)
|
||||
parent_builder.add_edge(START, "child")
|
||||
parent_builder.add_edge("child", "parent_second")
|
||||
parent_builder.add_edge("parent_second", END)
|
||||
|
||||
compiled = parent_builder.compile(checkpointer=MemorySaver())
|
||||
config = {"configurable": {"thread_id": "drain-subgraph"}}
|
||||
|
||||
with pytest.raises(GraphDrained, match="sigterm"):
|
||||
compiled.invoke({}, config, control=control)
|
||||
|
||||
assert compiled.invoke(None, config) == {
|
||||
"child_first": "done",
|
||||
"child_second": "done",
|
||||
"parent_second": "done",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_drain_requested_in_terminal_step_finishes_normally_async() -> None:
|
||||
class State(TypedDict, total=False):
|
||||
value: str
|
||||
|
||||
control = RunControl()
|
||||
|
||||
async def node(state: State) -> dict[str, str]:
|
||||
control.request_drain()
|
||||
return {"value": "done"}
|
||||
|
||||
graph = StateGraph(State)
|
||||
graph.add_node("node", node)
|
||||
graph.add_edge(START, "node")
|
||||
graph.add_edge("node", END)
|
||||
|
||||
assert await graph.compile().ainvoke({}, control=control) == {"value": "done"}
|
||||
assert control.drain_requested
|
||||
|
||||
|
||||
def test_runtime_propogated_to_subgraph() -> None:
|
||||
@dataclass
|
||||
class Context:
|
||||
@@ -392,6 +579,334 @@ def test_context_coercion_pydantic_validation_errors() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_external_drain_concurrent_sync() -> None:
|
||||
"""External thread calls request_drain() while graph is mid-execution."""
|
||||
|
||||
class State(TypedDict, total=False):
|
||||
first: str
|
||||
second: str
|
||||
|
||||
started = threading.Event()
|
||||
|
||||
def first_node(state: State) -> dict[str, str]:
|
||||
started.set()
|
||||
time.sleep(0.05)
|
||||
return {"first": "done"}
|
||||
|
||||
def second_node(state: State) -> dict[str, str]:
|
||||
return {"second": "should-not-run"}
|
||||
|
||||
graph = StateGraph(State)
|
||||
graph.add_node("first", first_node)
|
||||
graph.add_node("second", second_node)
|
||||
graph.add_edge(START, "first")
|
||||
graph.add_edge("first", "second")
|
||||
graph.add_edge("second", END)
|
||||
|
||||
control = RunControl()
|
||||
compiled = graph.compile()
|
||||
|
||||
exc_holder: list[BaseException | None] = [None]
|
||||
|
||||
def run_graph() -> None:
|
||||
try:
|
||||
compiled.invoke({}, control=control)
|
||||
except GraphDrained as e:
|
||||
exc_holder[0] = e
|
||||
|
||||
t = threading.Thread(target=run_graph)
|
||||
t.start()
|
||||
|
||||
started.wait(timeout=5)
|
||||
control.request_drain("sigterm")
|
||||
|
||||
t.join(timeout=10)
|
||||
|
||||
exc = exc_holder[0]
|
||||
assert isinstance(exc, GraphDrained)
|
||||
assert exc.reason == "sigterm"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_external_drain_concurrent_async() -> None:
|
||||
"""External task calls request_drain() while graph is mid-execution."""
|
||||
|
||||
class State(TypedDict, total=False):
|
||||
first: str
|
||||
second: str
|
||||
|
||||
started = asyncio.Event()
|
||||
|
||||
async def first_node(state: State) -> dict[str, str]:
|
||||
started.set()
|
||||
await asyncio.sleep(0.05)
|
||||
return {"first": "done"}
|
||||
|
||||
async def second_node(state: State) -> dict[str, str]:
|
||||
return {"second": "should-not-run"}
|
||||
|
||||
graph = StateGraph(State)
|
||||
graph.add_node("first", first_node)
|
||||
graph.add_node("second", second_node)
|
||||
graph.add_edge(START, "first")
|
||||
graph.add_edge("first", "second")
|
||||
graph.add_edge("second", END)
|
||||
|
||||
control = RunControl()
|
||||
compiled = graph.compile()
|
||||
|
||||
async def drain_after_start() -> None:
|
||||
await started.wait()
|
||||
control.request_drain("sigterm")
|
||||
|
||||
drain_task = asyncio.create_task(drain_after_start())
|
||||
|
||||
with pytest.raises(GraphDrained, match="sigterm"):
|
||||
await compiled.ainvoke({}, control=control)
|
||||
|
||||
await drain_task
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_drain_then_cancel_after_graceful_timeout() -> None:
|
||||
"""Simulate: drain requested -> node still running -> graceful timeout -> cancel.
|
||||
|
||||
This shows what happens when a long-running node doesn't finish within
|
||||
the graceful period after drain is requested.
|
||||
"""
|
||||
|
||||
class State(TypedDict, total=False):
|
||||
first: str
|
||||
second: str
|
||||
|
||||
node_started = asyncio.Event()
|
||||
node_cancelled = asyncio.Event()
|
||||
node_finished = asyncio.Event()
|
||||
|
||||
async def slow_node(state: State) -> dict[str, str]:
|
||||
node_started.set()
|
||||
try:
|
||||
await asyncio.sleep(30) # very long operation
|
||||
except asyncio.CancelledError:
|
||||
node_cancelled.set()
|
||||
raise
|
||||
node_finished.set()
|
||||
return {"first": "done"}
|
||||
|
||||
async def second_node(state: State) -> dict[str, str]:
|
||||
return {"second": "should-not-run"}
|
||||
|
||||
graph = StateGraph(State)
|
||||
graph.add_node("first", slow_node)
|
||||
graph.add_node("second", second_node)
|
||||
graph.add_edge(START, "first")
|
||||
graph.add_edge("first", "second")
|
||||
graph.add_edge("second", END)
|
||||
|
||||
control = RunControl()
|
||||
compiled = graph.compile()
|
||||
|
||||
# Phase 1: start graph
|
||||
graph_task = asyncio.create_task(compiled.ainvoke({}, control=control))
|
||||
|
||||
# Phase 2: wait for node to start, then request drain
|
||||
await node_started.wait()
|
||||
control.request_drain("sigterm")
|
||||
|
||||
# Phase 3: graceful timeout — node is still running, cancel after 1s
|
||||
graceful_timeout = 1.0
|
||||
await asyncio.sleep(graceful_timeout)
|
||||
|
||||
assert not node_finished.is_set(), "node should still be running"
|
||||
assert not node_cancelled.is_set(), "node should not be cancelled yet"
|
||||
|
||||
# Phase 4: force cancel
|
||||
graph_task.cancel()
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await graph_task
|
||||
|
||||
# The node received CancelledError at the await point
|
||||
assert node_cancelled.is_set(), "node should have received CancelledError"
|
||||
assert not node_finished.is_set(), "node should NOT have finished normally"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_cancel_ainvoke_with_async_node() -> None:
|
||||
"""Cancel ainvoke running an async node: CancelledError is delivered
|
||||
at the await point and the node stops immediately."""
|
||||
|
||||
class State(TypedDict, total=False):
|
||||
first: str
|
||||
second: str
|
||||
|
||||
timeline: list[str] = []
|
||||
node_started = asyncio.Event()
|
||||
|
||||
async def slow_async_node(state: State) -> dict[str, str]:
|
||||
timeline.append(f"async_node:start thread={threading.current_thread().name}")
|
||||
node_started.set()
|
||||
try:
|
||||
await asyncio.sleep(30)
|
||||
except asyncio.CancelledError:
|
||||
timeline.append("async_node:cancelled")
|
||||
raise
|
||||
timeline.append("async_node:finished")
|
||||
return {"first": "done"}
|
||||
|
||||
async def second_node(state: State) -> dict[str, str]:
|
||||
timeline.append("second_node:run")
|
||||
return {"second": "should-not-run"}
|
||||
|
||||
graph = StateGraph(State)
|
||||
graph.add_node("first", slow_async_node)
|
||||
graph.add_node("second", second_node)
|
||||
graph.add_edge(START, "first")
|
||||
graph.add_edge("first", "second")
|
||||
graph.add_edge("second", END)
|
||||
|
||||
compiled = graph.compile()
|
||||
graph_task = asyncio.create_task(compiled.ainvoke({}))
|
||||
|
||||
await node_started.wait()
|
||||
timeline.append("test:cancel")
|
||||
graph_task.cancel()
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await graph_task
|
||||
timeline.append("test:done")
|
||||
|
||||
# async node runs on the event loop thread (MainThread)
|
||||
assert any("MainThread" in e for e in timeline if "async_node:start" in e)
|
||||
# CancelledError was delivered at the await point — node stopped
|
||||
assert "async_node:cancelled" in timeline
|
||||
# Node did NOT run to completion
|
||||
assert "async_node:finished" not in timeline
|
||||
# Second node never ran
|
||||
assert "second_node:run" not in timeline
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_cancel_ainvoke_with_sync_node() -> None:
|
||||
"""Cancel ainvoke running a sync node.
|
||||
|
||||
Sync nodes in ainvoke run on a separate thread (via run_in_executor),
|
||||
NOT on the event loop thread. Cancelling the asyncio task disconnects
|
||||
from the thread future, but the thread keeps running as an orphan and
|
||||
completes on its own.
|
||||
|
||||
Key difference from async nodes:
|
||||
- async node: CancelledError stops the coroutine at an await point
|
||||
- sync node: cancel only disconnects asyncio; the thread runs to completion
|
||||
|
||||
In shutdown case, we will ignore this because the instance will be destroyed soon.
|
||||
"""
|
||||
|
||||
class State(TypedDict, total=False):
|
||||
first: str
|
||||
second: str
|
||||
|
||||
timeline: list[str] = []
|
||||
node_started = threading.Event()
|
||||
node_finished = threading.Event()
|
||||
|
||||
def slow_sync_node(state: State) -> dict[str, str]:
|
||||
timeline.append(f"sync_node:start thread={threading.current_thread().name}")
|
||||
node_started.set()
|
||||
time.sleep(1)
|
||||
timeline.append("sync_node:after_sleep")
|
||||
node_finished.set()
|
||||
return {"first": "done"}
|
||||
|
||||
def second_node(state: State) -> dict[str, str]:
|
||||
timeline.append("second_node:run")
|
||||
return {"second": "should-not-run"}
|
||||
|
||||
graph = StateGraph(State)
|
||||
graph.add_node("first", slow_sync_node)
|
||||
graph.add_node("second", second_node)
|
||||
graph.add_edge(START, "first")
|
||||
graph.add_edge("first", "second")
|
||||
graph.add_edge("second", END)
|
||||
|
||||
control = RunControl()
|
||||
compiled = graph.compile()
|
||||
|
||||
timeline.append(f"test:main thread={threading.current_thread().name}")
|
||||
graph_task = asyncio.create_task(compiled.ainvoke({}, control=control))
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(None, node_started.wait, 5)
|
||||
|
||||
timeline.append("test:cancel+drain")
|
||||
graph_task.cancel()
|
||||
control.request_drain("sigterm")
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await graph_task
|
||||
timeline.append("test:exc=CancelledError")
|
||||
|
||||
# Sync node runs on a background thread (asyncio_*), NOT MainThread
|
||||
sync_start = next(e for e in timeline if "sync_node:start" in e)
|
||||
assert "MainThread" not in sync_start, (
|
||||
"sync node should run on a background thread, not the event loop thread"
|
||||
)
|
||||
|
||||
# At this point, the asyncio task is done but the thread is orphaned.
|
||||
# The sync node has NOT finished yet — cancel only disconnected asyncio.
|
||||
assert not node_finished.is_set(), (
|
||||
"sync node should still be running in its background thread"
|
||||
)
|
||||
|
||||
# Wait for the orphaned thread to complete on its own.
|
||||
await loop.run_in_executor(None, node_finished.wait, 5)
|
||||
assert node_finished.is_set()
|
||||
|
||||
# After the orphaned thread finishes, the full timeline looks like:
|
||||
# test:main thread=MainThread
|
||||
# sync_node:start thread=asyncio_N <- background thread
|
||||
# test:cancel+drain <- cancel + drain fired
|
||||
# test:exc=CancelledError <- asyncio disconnected
|
||||
# sync_node:after_sleep <- thread ran to completion anyway
|
||||
assert "sync_node:after_sleep" in timeline
|
||||
# Second node never ran
|
||||
assert "second_node:run" not in timeline
|
||||
|
||||
# Verify timeline ordering: cancel happened before node finished
|
||||
cancel_idx = timeline.index("test:cancel+drain")
|
||||
sleep_idx = timeline.index("sync_node:after_sleep")
|
||||
assert cancel_idx < sleep_idx, (
|
||||
"cancel was issued while the sync node was still sleeping"
|
||||
)
|
||||
|
||||
|
||||
def test_drain_with_control_parameter_sync() -> None:
|
||||
"""Control parameter is wired through invoke -> stream."""
|
||||
|
||||
class State(TypedDict, total=False):
|
||||
value: str
|
||||
|
||||
ran = False
|
||||
|
||||
def node(state: State) -> dict[str, str]:
|
||||
nonlocal ran
|
||||
ran = True
|
||||
return {"value": "done"}
|
||||
|
||||
graph = StateGraph(State)
|
||||
graph.add_node("node", node)
|
||||
graph.add_edge(START, "node")
|
||||
graph.add_edge("node", END)
|
||||
|
||||
# Pre-drained control stops before executing the first pending task.
|
||||
control = RunControl()
|
||||
control.request_drain("pre-drained")
|
||||
|
||||
with pytest.raises(GraphDrained, match="pre-drained"):
|
||||
graph.compile().invoke({}, control=control)
|
||||
assert not ran
|
||||
|
||||
|
||||
# --- ExecutionInfo unit tests ---
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,707 @@
|
||||
"""Tests for CustomTransformer, UpdatesTransformer, CheckpointsTransformer, DebugTransformer, TasksTransformer.
|
||||
|
||||
These transformers capture raw protocol events for their respective stream
|
||||
modes and expose them as native projections on the run stream (run.custom,
|
||||
run.updates, run.checkpoints, run.debug, run.tasks). Tests dispatch synthetic
|
||||
protocol events through a StreamMux to isolate transformer logic; the final
|
||||
group exercises real graphs through stream_events(version="v3").
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import operator
|
||||
import time
|
||||
from typing import Annotated, Any
|
||||
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from langgraph.constants import END, START
|
||||
from langgraph.graph import StateGraph
|
||||
from langgraph.stream._mux import StreamMux
|
||||
from langgraph.stream.stream_channel import StreamChannel
|
||||
from langgraph.stream.transformers import (
|
||||
CheckpointsTransformer,
|
||||
CustomTransformer,
|
||||
DebugTransformer,
|
||||
LifecycleTransformer,
|
||||
TasksTransformer,
|
||||
UpdatesTransformer,
|
||||
)
|
||||
|
||||
TS = int(time.time() * 1000)
|
||||
|
||||
|
||||
def _custom_event(namespace: list[str], data: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "event",
|
||||
"method": "custom",
|
||||
"params": {"namespace": namespace, "timestamp": TS, "data": data},
|
||||
}
|
||||
|
||||
|
||||
def _checkpoints_event(namespace: list[str], data: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "event",
|
||||
"method": "checkpoints",
|
||||
"params": {"namespace": namespace, "timestamp": TS, "data": data},
|
||||
}
|
||||
|
||||
|
||||
def _debug_event(namespace: list[str], data: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "event",
|
||||
"method": "debug",
|
||||
"params": {"namespace": namespace, "timestamp": TS, "data": data},
|
||||
}
|
||||
|
||||
|
||||
def _tasks_event(namespace: list[str], data: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "event",
|
||||
"method": "tasks",
|
||||
"params": {"namespace": namespace, "timestamp": TS, "data": data},
|
||||
}
|
||||
|
||||
|
||||
def _updates_event(namespace: list[str], data: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "event",
|
||||
"method": "updates",
|
||||
"params": {"namespace": namespace, "timestamp": TS, "data": data},
|
||||
}
|
||||
|
||||
|
||||
def _arm(mux: StreamMux, transformer: Any) -> None:
|
||||
"""Force projection logs to accept pushes (skip lazy-subscribe gate)."""
|
||||
mux._events._subscribed = True
|
||||
transformer._log._subscribed = True
|
||||
|
||||
|
||||
def _unstamped(items):
|
||||
"""Strip push stamps from a StreamChannel's internal buffer."""
|
||||
return [item for _stamp, item in items]
|
||||
|
||||
|
||||
def _drain(transformer: Any) -> list[Any]:
|
||||
return _unstamped(transformer._log._items)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CustomTransformer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_custom_captures_root_scope_events() -> None:
|
||||
t = CustomTransformer()
|
||||
mux = StreamMux([t], is_async=False)
|
||||
_arm(mux, t)
|
||||
|
||||
mux.push(_custom_event([], {"status": "processing"}))
|
||||
mux.push(_custom_event([], {"status": "done"}))
|
||||
|
||||
items = _drain(t)
|
||||
assert items == [{"status": "processing"}, {"status": "done"}]
|
||||
|
||||
|
||||
def test_custom_ignores_subgraph_scope_events() -> None:
|
||||
t = CustomTransformer()
|
||||
mux = StreamMux([t], is_async=False)
|
||||
_arm(mux, t)
|
||||
|
||||
mux.push(_custom_event(["subgraph:abc"], {"from": "child"}))
|
||||
|
||||
assert _drain(t) == []
|
||||
|
||||
|
||||
def test_custom_scoped_transformer_captures_own_scope() -> None:
|
||||
t = CustomTransformer(scope=("agent:abc",))
|
||||
mux = StreamMux([t], is_async=False)
|
||||
_arm(mux, t)
|
||||
|
||||
mux.push(_custom_event([], {"from": "root"}))
|
||||
mux.push(_custom_event(["agent:abc"], {"from": "self"}))
|
||||
mux.push(_custom_event(["agent:abc", "deep:def"], {"from": "child"}))
|
||||
|
||||
items = _drain(t)
|
||||
assert items == [{"from": "self"}]
|
||||
|
||||
|
||||
def test_custom_preserves_any_payload_type() -> None:
|
||||
t = CustomTransformer()
|
||||
mux = StreamMux([t], is_async=False)
|
||||
_arm(mux, t)
|
||||
|
||||
mux.push(_custom_event([], "string_payload"))
|
||||
mux.push(_custom_event([], 42))
|
||||
mux.push(_custom_event([], [1, 2, 3]))
|
||||
|
||||
assert _drain(t) == ["string_payload", 42, [1, 2, 3]]
|
||||
|
||||
|
||||
def test_custom_does_not_suppress_from_main_log() -> None:
|
||||
t = CustomTransformer()
|
||||
mux = StreamMux([t], is_async=False)
|
||||
_arm(mux, t)
|
||||
|
||||
mux.push(_custom_event([], "data"))
|
||||
|
||||
methods = [evt["method"] for evt in _unstamped(mux._events._items)]
|
||||
assert "custom" in methods
|
||||
|
||||
|
||||
def test_custom_ignores_other_methods() -> None:
|
||||
t = CustomTransformer()
|
||||
mux = StreamMux([t], is_async=False)
|
||||
_arm(mux, t)
|
||||
|
||||
mux.push(
|
||||
{
|
||||
"type": "event",
|
||||
"method": "values",
|
||||
"params": {"namespace": [], "timestamp": TS, "data": {}},
|
||||
}
|
||||
)
|
||||
assert _drain(t) == []
|
||||
|
||||
|
||||
def test_custom_required_stream_modes() -> None:
|
||||
assert CustomTransformer.required_stream_modes == ("custom",)
|
||||
|
||||
|
||||
def test_custom_is_native() -> None:
|
||||
assert getattr(CustomTransformer, "_native", False) is True
|
||||
|
||||
|
||||
def test_custom_init_returns_correct_key() -> None:
|
||||
t = CustomTransformer()
|
||||
projection = t.init()
|
||||
assert "custom" in projection
|
||||
assert isinstance(projection["custom"], StreamChannel)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CheckpointsTransformer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_checkpoints_captures_root_scope_events() -> None:
|
||||
t = CheckpointsTransformer()
|
||||
mux = StreamMux([t], is_async=False)
|
||||
_arm(mux, t)
|
||||
|
||||
checkpoint_data = {"values": {"x": 1}, "next": ["node_b"]}
|
||||
mux.push(_checkpoints_event([], checkpoint_data))
|
||||
|
||||
items = _drain(t)
|
||||
assert items == [checkpoint_data]
|
||||
|
||||
|
||||
def test_checkpoints_ignores_subgraph_events() -> None:
|
||||
t = CheckpointsTransformer()
|
||||
mux = StreamMux([t], is_async=False)
|
||||
_arm(mux, t)
|
||||
|
||||
mux.push(_checkpoints_event(["child:abc"], {"values": {"x": 1}}))
|
||||
|
||||
assert _drain(t) == []
|
||||
|
||||
|
||||
def test_checkpoints_scoped_transformer() -> None:
|
||||
t = CheckpointsTransformer(scope=("sub:abc",))
|
||||
mux = StreamMux([t], is_async=False)
|
||||
_arm(mux, t)
|
||||
|
||||
mux.push(_checkpoints_event([], {"from": "root"}))
|
||||
mux.push(_checkpoints_event(["sub:abc"], {"from": "self"}))
|
||||
|
||||
assert _drain(t) == [{"from": "self"}]
|
||||
|
||||
|
||||
def test_checkpoints_does_not_suppress_from_main_log() -> None:
|
||||
t = CheckpointsTransformer()
|
||||
mux = StreamMux([t], is_async=False)
|
||||
_arm(mux, t)
|
||||
|
||||
mux.push(_checkpoints_event([], {"values": {}}))
|
||||
|
||||
methods = [evt["method"] for evt in _unstamped(mux._events._items)]
|
||||
assert "checkpoints" in methods
|
||||
|
||||
|
||||
def test_checkpoints_required_stream_modes() -> None:
|
||||
assert CheckpointsTransformer.required_stream_modes == ("checkpoints",)
|
||||
|
||||
|
||||
def test_checkpoints_is_native() -> None:
|
||||
assert getattr(CheckpointsTransformer, "_native", False) is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DebugTransformer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_debug_captures_root_scope_events() -> None:
|
||||
t = DebugTransformer()
|
||||
mux = StreamMux([t], is_async=False)
|
||||
_arm(mux, t)
|
||||
|
||||
debug_data = {
|
||||
"step": 0,
|
||||
"type": "checkpoint",
|
||||
"timestamp": "2026-01-01T00:00:00Z",
|
||||
"payload": {"values": {"x": 1}},
|
||||
}
|
||||
mux.push(_debug_event([], debug_data))
|
||||
|
||||
items = _drain(t)
|
||||
assert items == [debug_data]
|
||||
|
||||
|
||||
def test_debug_ignores_subgraph_events() -> None:
|
||||
t = DebugTransformer()
|
||||
mux = StreamMux([t], is_async=False)
|
||||
_arm(mux, t)
|
||||
|
||||
mux.push(_debug_event(["child:abc"], {"step": 0, "type": "task"}))
|
||||
|
||||
assert _drain(t) == []
|
||||
|
||||
|
||||
def test_debug_captures_multiple_event_types() -> None:
|
||||
t = DebugTransformer()
|
||||
mux = StreamMux([t], is_async=False)
|
||||
_arm(mux, t)
|
||||
|
||||
mux.push(_debug_event([], {"step": 0, "type": "checkpoint", "payload": {}}))
|
||||
mux.push(_debug_event([], {"step": 1, "type": "task", "payload": {}}))
|
||||
mux.push(_debug_event([], {"step": 1, "type": "task_result", "payload": {}}))
|
||||
|
||||
items = _drain(t)
|
||||
assert len(items) == 3
|
||||
assert [d["type"] for d in items] == ["checkpoint", "task", "task_result"]
|
||||
|
||||
|
||||
def test_debug_does_not_suppress_from_main_log() -> None:
|
||||
t = DebugTransformer()
|
||||
mux = StreamMux([t], is_async=False)
|
||||
_arm(mux, t)
|
||||
|
||||
mux.push(_debug_event([], {"step": 0}))
|
||||
|
||||
methods = [evt["method"] for evt in _unstamped(mux._events._items)]
|
||||
assert "debug" in methods
|
||||
|
||||
|
||||
def test_debug_required_stream_modes() -> None:
|
||||
assert DebugTransformer.required_stream_modes == ("debug",)
|
||||
|
||||
|
||||
def test_debug_is_native() -> None:
|
||||
assert getattr(DebugTransformer, "_native", False) is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TasksTransformer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_tasks_captures_root_scope_events() -> None:
|
||||
t = TasksTransformer()
|
||||
mux = StreamMux([t], is_async=False)
|
||||
_arm(mux, t)
|
||||
|
||||
task_start = {"id": "t1", "name": "my_node", "input": None, "triggers": []}
|
||||
mux.push(_tasks_event([], task_start))
|
||||
|
||||
items = _drain(t)
|
||||
assert items == [task_start]
|
||||
|
||||
|
||||
def test_tasks_captures_start_and_result() -> None:
|
||||
t = TasksTransformer()
|
||||
mux = StreamMux([t], is_async=False)
|
||||
_arm(mux, t)
|
||||
|
||||
start = {"id": "t1", "name": "a", "input": None, "triggers": []}
|
||||
result = {"id": "t1", "name": "a", "result": {"output": 42}, "error": None}
|
||||
mux.push(_tasks_event([], start))
|
||||
mux.push(_tasks_event([], result))
|
||||
|
||||
items = _drain(t)
|
||||
assert items == [start, result]
|
||||
|
||||
|
||||
def test_tasks_ignores_subgraph_events() -> None:
|
||||
t = TasksTransformer()
|
||||
mux = StreamMux([t], is_async=False)
|
||||
_arm(mux, t)
|
||||
|
||||
mux.push(_tasks_event(["child:abc"], {"id": "t1", "name": "x"}))
|
||||
|
||||
assert _drain(t) == []
|
||||
|
||||
|
||||
def test_tasks_scoped_transformer() -> None:
|
||||
t = TasksTransformer(scope=("agent:abc",))
|
||||
mux = StreamMux([t], is_async=False)
|
||||
_arm(mux, t)
|
||||
|
||||
mux.push(_tasks_event([], {"id": "t1"}))
|
||||
mux.push(_tasks_event(["agent:abc"], {"id": "t2"}))
|
||||
mux.push(_tasks_event(["agent:abc", "deep:def"], {"id": "t3"}))
|
||||
|
||||
assert _drain(t) == [{"id": "t2"}]
|
||||
|
||||
|
||||
def test_tasks_does_not_suppress_from_main_log() -> None:
|
||||
"""TasksTransformer returns True — it doesn't suppress tasks events.
|
||||
|
||||
(LifecycleTransformer suppresses them, but that's independent.)
|
||||
"""
|
||||
t = TasksTransformer()
|
||||
mux = StreamMux([t], is_async=False)
|
||||
_arm(mux, t)
|
||||
|
||||
mux.push(_tasks_event([], {"id": "t1"}))
|
||||
|
||||
methods = [evt["method"] for evt in _unstamped(mux._events._items)]
|
||||
assert "tasks" in methods
|
||||
|
||||
|
||||
def test_tasks_required_stream_modes() -> None:
|
||||
assert TasksTransformer.required_stream_modes == ("tasks",)
|
||||
|
||||
|
||||
def test_tasks_is_native() -> None:
|
||||
assert getattr(TasksTransformer, "_native", False) is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# UpdatesTransformer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_updates_captures_root_scope_events() -> None:
|
||||
t = UpdatesTransformer()
|
||||
mux = StreamMux([t], is_async=False)
|
||||
_arm(mux, t)
|
||||
|
||||
update = {"my_node": {"value": "hello!"}}
|
||||
mux.push(_updates_event([], update))
|
||||
|
||||
items = _drain(t)
|
||||
assert items == [update]
|
||||
|
||||
|
||||
def test_updates_captures_multiple_steps() -> None:
|
||||
t = UpdatesTransformer()
|
||||
mux = StreamMux([t], is_async=False)
|
||||
_arm(mux, t)
|
||||
|
||||
mux.push(_updates_event([], {"node_a": {"x": 1}}))
|
||||
mux.push(_updates_event([], {"node_b": {"x": 2}}))
|
||||
|
||||
items = _drain(t)
|
||||
assert items == [{"node_a": {"x": 1}}, {"node_b": {"x": 2}}]
|
||||
|
||||
|
||||
def test_updates_ignores_subgraph_events() -> None:
|
||||
t = UpdatesTransformer()
|
||||
mux = StreamMux([t], is_async=False)
|
||||
_arm(mux, t)
|
||||
|
||||
mux.push(_updates_event(["child:abc"], {"inner_node": {"v": 1}}))
|
||||
|
||||
assert _drain(t) == []
|
||||
|
||||
|
||||
def test_updates_scoped_transformer() -> None:
|
||||
t = UpdatesTransformer(scope=("agent:abc",))
|
||||
mux = StreamMux([t], is_async=False)
|
||||
_arm(mux, t)
|
||||
|
||||
mux.push(_updates_event([], {"from": "root"}))
|
||||
mux.push(_updates_event(["agent:abc"], {"from": "self"}))
|
||||
|
||||
assert _drain(t) == [{"from": "self"}]
|
||||
|
||||
|
||||
def test_updates_does_not_suppress_from_main_log() -> None:
|
||||
t = UpdatesTransformer()
|
||||
mux = StreamMux([t], is_async=False)
|
||||
_arm(mux, t)
|
||||
|
||||
mux.push(_updates_event([], {"n": {}}))
|
||||
|
||||
methods = [evt["method"] for evt in _unstamped(mux._events._items)]
|
||||
assert "updates" in methods
|
||||
|
||||
|
||||
def test_updates_required_stream_modes() -> None:
|
||||
assert UpdatesTransformer.required_stream_modes == ("updates",)
|
||||
|
||||
|
||||
def test_updates_is_native() -> None:
|
||||
assert getattr(UpdatesTransformer, "_native", False) is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cross-transformer: unrelated events pass through
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_unrelated_events_ignored_by_all() -> None:
|
||||
"""Non-matching method events don't land in any transformer's log."""
|
||||
transformers = [
|
||||
CustomTransformer(),
|
||||
UpdatesTransformer(),
|
||||
CheckpointsTransformer(),
|
||||
DebugTransformer(),
|
||||
TasksTransformer(),
|
||||
]
|
||||
mux = StreamMux(transformers, is_async=False)
|
||||
mux._events._subscribed = True
|
||||
for t in transformers:
|
||||
t._log._subscribed = True
|
||||
|
||||
mux.push(
|
||||
{
|
||||
"type": "event",
|
||||
"method": "values",
|
||||
"params": {"namespace": [], "timestamp": TS, "data": {"x": 1}},
|
||||
}
|
||||
)
|
||||
|
||||
for t in transformers:
|
||||
assert _unstamped(t._log._items) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# End-to-end: real graphs through stream_events(version="v3")
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _State(TypedDict):
|
||||
value: str
|
||||
items: Annotated[list[str], operator.add]
|
||||
|
||||
|
||||
def _my_node(state: _State) -> dict[str, Any]:
|
||||
from langgraph.config import get_stream_writer
|
||||
|
||||
writer = get_stream_writer()
|
||||
writer({"status": "working", "node": "my_node"})
|
||||
return {"value": state["value"] + "!", "items": ["done"]}
|
||||
|
||||
|
||||
def _make_simple_graph() -> Any:
|
||||
builder = StateGraph(_State, input_schema=_State)
|
||||
builder.add_node("my_node", _my_node)
|
||||
builder.add_edge(START, "my_node")
|
||||
builder.add_edge("my_node", END)
|
||||
return builder.compile()
|
||||
|
||||
|
||||
def test_stream_events_v3_custom_projection_opt_in() -> None:
|
||||
"""run.custom surfaces get_stream_writer() payloads when opted in."""
|
||||
graph = _make_simple_graph()
|
||||
run = graph.stream_events(
|
||||
{"value": "hello", "items": []}, version="v3", transformers=[CustomTransformer]
|
||||
)
|
||||
|
||||
custom_events = list(run.custom)
|
||||
assert len(custom_events) >= 1
|
||||
assert any(e.get("status") == "working" for e in custom_events)
|
||||
|
||||
|
||||
def test_stream_events_v3_custom_and_values_coexist() -> None:
|
||||
"""Both run.custom and run.values work in the same run."""
|
||||
graph = _make_simple_graph()
|
||||
run = graph.stream_events(
|
||||
{"value": "hello", "items": []}, version="v3", transformers=[CustomTransformer]
|
||||
)
|
||||
|
||||
custom_events = list(run.custom)
|
||||
assert run.output is not None
|
||||
assert run.output["value"] == "hello!"
|
||||
assert len(custom_events) >= 1
|
||||
|
||||
|
||||
def test_stream_events_v3_tasks_projection_opt_in() -> None:
|
||||
"""run.tasks surfaces raw task events when opted in via transformers=."""
|
||||
graph = _make_simple_graph()
|
||||
run = graph.stream_events(
|
||||
{"value": "x", "items": []}, transformers=[TasksTransformer], version="v3"
|
||||
)
|
||||
|
||||
tasks_events = list(run.tasks)
|
||||
assert len(tasks_events) >= 1
|
||||
names = [t.get("name") for t in tasks_events if "name" in t]
|
||||
assert "my_node" in names
|
||||
|
||||
|
||||
def test_stream_events_v3_debug_projection_opt_in() -> None:
|
||||
"""run.debug surfaces debug events when opted in via transformers=."""
|
||||
graph = _make_simple_graph()
|
||||
run = graph.stream_events(
|
||||
{"value": "x", "items": []}, transformers=[DebugTransformer], version="v3"
|
||||
)
|
||||
|
||||
debug_events = list(run.debug)
|
||||
assert len(debug_events) >= 1
|
||||
types = {d.get("type") for d in debug_events}
|
||||
assert types & {"checkpoint", "task", "task_result"}
|
||||
|
||||
|
||||
def test_stream_events_v3_updates_projection_opt_in() -> None:
|
||||
"""run.updates surfaces node output dicts when opted in via transformers=."""
|
||||
graph = _make_simple_graph()
|
||||
run = graph.stream_events(
|
||||
{"value": "x", "items": []}, version="v3", transformers=[UpdatesTransformer]
|
||||
)
|
||||
|
||||
updates = list(run.updates)
|
||||
assert len(updates) >= 1
|
||||
node_names = {k for u in updates for k in u if k != "__interrupt__"}
|
||||
assert "my_node" in node_names
|
||||
|
||||
|
||||
def test_stream_events_v3_all_transformers_interleaved() -> None:
|
||||
"""All five transformers registered together, consumed via interleave."""
|
||||
graph = _make_simple_graph()
|
||||
run = graph.stream_events(
|
||||
{"value": "x", "items": []},
|
||||
version="v3",
|
||||
transformers=[
|
||||
CustomTransformer,
|
||||
UpdatesTransformer,
|
||||
CheckpointsTransformer,
|
||||
DebugTransformer,
|
||||
TasksTransformer,
|
||||
],
|
||||
)
|
||||
|
||||
collected: dict[str, list[Any]] = {
|
||||
"custom": [],
|
||||
"updates": [],
|
||||
"debug": [],
|
||||
"tasks": [],
|
||||
}
|
||||
for name, item in run.interleave("custom", "updates", "debug", "tasks"):
|
||||
collected[name].append(item)
|
||||
|
||||
assert len(collected["custom"]) >= 1
|
||||
assert len(collected["updates"]) >= 1
|
||||
assert len(collected["tasks"]) >= 1
|
||||
assert len(collected["debug"]) >= 1
|
||||
types = {d.get("type") for d in collected["debug"]}
|
||||
assert types & {"checkpoint", "task", "task_result"}
|
||||
node_names = {k for u in collected["updates"] for k in u if k != "__interrupt__"}
|
||||
assert "my_node" in node_names
|
||||
|
||||
assert run.output is not None
|
||||
assert run.output["value"] == "x!"
|
||||
|
||||
|
||||
def test_stream_events_v3_all_transformers_with_checkpointer() -> None:
|
||||
"""All transformers with a checkpointer — run.checkpoints populated."""
|
||||
from langgraph.checkpoint.memory import InMemorySaver
|
||||
|
||||
builder = StateGraph(_State, input_schema=_State)
|
||||
builder.add_node("my_node", _my_node)
|
||||
builder.add_edge(START, "my_node")
|
||||
builder.add_edge("my_node", END)
|
||||
graph = builder.compile(checkpointer=InMemorySaver())
|
||||
|
||||
run = graph.stream_events(
|
||||
{"value": "x", "items": []},
|
||||
version="v3",
|
||||
config={"configurable": {"thread_id": "test-all"}},
|
||||
transformers=[
|
||||
CustomTransformer,
|
||||
UpdatesTransformer,
|
||||
CheckpointsTransformer,
|
||||
DebugTransformer,
|
||||
TasksTransformer,
|
||||
],
|
||||
)
|
||||
|
||||
collected: dict[str, list[Any]] = {
|
||||
"custom": [],
|
||||
"updates": [],
|
||||
"checkpoints": [],
|
||||
"debug": [],
|
||||
"tasks": [],
|
||||
}
|
||||
for name, item in run.interleave(
|
||||
"custom", "updates", "checkpoints", "debug", "tasks"
|
||||
):
|
||||
collected[name].append(item)
|
||||
|
||||
assert len(collected["checkpoints"]) >= 1
|
||||
assert len(collected["custom"]) >= 1
|
||||
|
||||
|
||||
def test_stream_events_v3_checkpoints_projection_opt_in() -> None:
|
||||
"""run.checkpoints surfaces checkpoint data when opted in with a checkpointer."""
|
||||
from langgraph.checkpoint.memory import InMemorySaver
|
||||
|
||||
builder = StateGraph(_State, input_schema=_State)
|
||||
builder.add_node("my_node", _my_node)
|
||||
builder.add_edge(START, "my_node")
|
||||
builder.add_edge("my_node", END)
|
||||
graph = builder.compile(checkpointer=InMemorySaver())
|
||||
|
||||
run = graph.stream_events(
|
||||
{"value": "x", "items": []},
|
||||
version="v3",
|
||||
config={"configurable": {"thread_id": "test-ckpt-standalone"}},
|
||||
transformers=[CheckpointsTransformer],
|
||||
)
|
||||
|
||||
checkpoints = list(run.checkpoints)
|
||||
assert len(checkpoints) >= 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TasksTransformer + LifecycleTransformer co-registration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_tasks_and_lifecycle_coregistration() -> None:
|
||||
"""When both are in the same StreamMux, LifecycleTransformer suppresses
|
||||
tasks events from the main log (returns False) while TasksTransformer
|
||||
still captures them into its own log.
|
||||
"""
|
||||
lifecycle = LifecycleTransformer()
|
||||
tasks = TasksTransformer()
|
||||
mux = StreamMux([lifecycle, tasks], is_async=False)
|
||||
mux._events._subscribed = True
|
||||
tasks._log._subscribed = True
|
||||
lifecycle._channel._subscribed = True
|
||||
|
||||
task_data = {"id": "t1", "name": "my_node", "input": None, "triggers": []}
|
||||
mux.push(_tasks_event([], task_data))
|
||||
|
||||
assert _drain(tasks) == [task_data]
|
||||
|
||||
methods = [evt["method"] for evt in _unstamped(mux._events._items)]
|
||||
assert "tasks" not in methods
|
||||
|
||||
|
||||
def test_tasks_and_lifecycle_coregistration_e2e() -> None:
|
||||
"""E2e: TasksTransformer captures task events even when LifecycleTransformer
|
||||
is present and suppressing them from the main log.
|
||||
"""
|
||||
graph = _make_simple_graph()
|
||||
run = graph.stream_events(
|
||||
{"value": "x", "items": []},
|
||||
version="v3",
|
||||
transformers=[TasksTransformer],
|
||||
)
|
||||
|
||||
tasks_events = list(run.tasks)
|
||||
assert len(tasks_events) >= 1
|
||||
names = [t.get("name") for t in tasks_events if "name" in t]
|
||||
assert "my_node" in names
|
||||
+29
-1
@@ -19,9 +19,11 @@ from typing_extensions import TypedDict, assert_type
|
||||
|
||||
from langgraph._internal._constants import INTERRUPT
|
||||
from langgraph.constants import END, START
|
||||
from langgraph.errors import GraphDrained
|
||||
from langgraph.func import entrypoint
|
||||
from langgraph.graph import StateGraph
|
||||
from langgraph.graph.message import MessagesState
|
||||
from langgraph.runtime import RunControl
|
||||
from langgraph.types import (
|
||||
CheckpointPayload,
|
||||
CheckpointStreamPart,
|
||||
@@ -229,6 +231,32 @@ class TestV2Stream:
|
||||
for c in chunks:
|
||||
_assert_stream_part_shape(c)
|
||||
|
||||
def test_stream_events_v3_accepts_control_for_drain(self) -> None:
|
||||
class DrainState(TypedDict, total=False):
|
||||
value: str
|
||||
skipped: str
|
||||
|
||||
control = RunControl()
|
||||
|
||||
def first_node(state: DrainState) -> dict[str, str]:
|
||||
control.request_drain("sigterm")
|
||||
return {"value": "done"}
|
||||
|
||||
def second_node(state: DrainState) -> dict[str, str]:
|
||||
return {"skipped": "nope"}
|
||||
|
||||
builder = StateGraph(DrainState)
|
||||
builder.add_node("first", first_node)
|
||||
builder.add_node("second", second_node)
|
||||
builder.add_edge(START, "first")
|
||||
builder.add_edge("first", "second")
|
||||
builder.add_edge("second", END)
|
||||
graph = builder.compile()
|
||||
|
||||
run = graph.stream_events({}, control=control, version="v3")
|
||||
with pytest.raises(GraphDrained, match="sigterm"):
|
||||
list(run.values)
|
||||
|
||||
def test_subgraphs_ns(self) -> None:
|
||||
outer = _make_subgraph()
|
||||
chunks = list(
|
||||
@@ -1096,7 +1124,7 @@ class TestV2ValidationErrors:
|
||||
|
||||
_INVALID_INPUT: dict[str, Any] = {"value": [1, 2, 3], "items": []}
|
||||
|
||||
def test_stream_v2_pydantic_validation_error(self) -> None:
|
||||
def test_stream_events_v3_pydantic_validation_error(self) -> None:
|
||||
"""Invalid input to stream with v2 + pydantic state raises ValidationError."""
|
||||
graph = _make_pydantic_graph()
|
||||
with pytest.raises(ValidationError):
|
||||
@@ -0,0 +1,808 @@
|
||||
"""End-to-end tests exercising all stream_events(version="v3") projections together.
|
||||
|
||||
Each test builds a realistic graph (subgraphs, LLM calls, custom writers,
|
||||
interrupts) and verifies that every projection — values, messages, lifecycle,
|
||||
subgraphs, raw events, output, interleave — produces correct, consistent
|
||||
results through a single stream_events(version="v3") / astream_events(version="v3") run.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import operator
|
||||
import sys
|
||||
from typing import Annotated, Any
|
||||
|
||||
import pytest
|
||||
from langchain_core.language_models import GenericFakeChatModel
|
||||
from langchain_core.language_models.chat_model_stream import (
|
||||
AsyncChatModelStream,
|
||||
ChatModelStream,
|
||||
)
|
||||
from langchain_core.messages import AIMessage
|
||||
from langgraph.checkpoint.memory import InMemorySaver
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from langgraph.constants import END, START
|
||||
from langgraph.graph import MessagesState, StateGraph
|
||||
from langgraph.stream import StreamChannel, StreamTransformer
|
||||
from langgraph.stream._types import ProtocolEvent
|
||||
from langgraph.types import StreamWriter, interrupt
|
||||
|
||||
NEEDS_CONTEXTVARS = pytest.mark.skipif(
|
||||
sys.version_info < (3, 11),
|
||||
reason="Python 3.11+ is required for async contextvars support",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# State and graph builders
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AgentState(TypedDict):
|
||||
value: str
|
||||
items: Annotated[list[str], operator.add]
|
||||
|
||||
|
||||
def _make_nested_graph():
|
||||
"""Build a two-level graph with pure state transforms.
|
||||
|
||||
Structure:
|
||||
outer:
|
||||
router_node (state transform)
|
||||
inner_graph (compiled subgraph)
|
||||
|
||||
inner_graph:
|
||||
process_node (state transform)
|
||||
"""
|
||||
|
||||
def process_node(state: AgentState) -> dict[str, Any]:
|
||||
return {"value": state["value"] + "_processed", "items": ["processed"]}
|
||||
|
||||
inner_builder: StateGraph = StateGraph(AgentState, input_schema=AgentState)
|
||||
inner_builder.add_node("process_node", process_node)
|
||||
inner_builder.add_edge(START, "process_node")
|
||||
inner_builder.add_edge("process_node", END)
|
||||
inner_graph = inner_builder.compile()
|
||||
|
||||
def router_node(state: AgentState) -> dict[str, Any]:
|
||||
return {"value": state["value"] + "_routed", "items": ["routed"]}
|
||||
|
||||
outer_builder: StateGraph = StateGraph(AgentState, input_schema=AgentState)
|
||||
outer_builder.add_node("router", router_node)
|
||||
outer_builder.add_node("inner", inner_graph)
|
||||
outer_builder.add_edge(START, "router")
|
||||
outer_builder.add_edge("router", "inner")
|
||||
outer_builder.add_edge("inner", END)
|
||||
return outer_builder.compile()
|
||||
|
||||
|
||||
def _make_messages_graph():
|
||||
"""Flat graph with an LLM call for messages projection testing."""
|
||||
model = GenericFakeChatModel(messages=iter(["hello world"]))
|
||||
|
||||
def call_model(state: MessagesState) -> dict[str, Any]:
|
||||
return {"messages": model.invoke(state["messages"])}
|
||||
|
||||
return (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("call_model", call_model)
|
||||
.add_edge(START, "call_model")
|
||||
.add_edge("call_model", END)
|
||||
.compile()
|
||||
)
|
||||
|
||||
|
||||
def _make_messages_subgraph():
|
||||
"""Outer graph with a MessagesState subgraph that returns an AIMessage.
|
||||
|
||||
Uses the whole-message fallback path (node returns AIMessage directly)
|
||||
to exercise messages through a subgraph boundary.
|
||||
"""
|
||||
|
||||
def return_message(state: MessagesState) -> dict[str, Any]:
|
||||
return {"messages": AIMessage(content="from subgraph", id="sub-msg-1")}
|
||||
|
||||
inner = (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("return_message", return_message)
|
||||
.add_edge(START, "return_message")
|
||||
.add_edge("return_message", END)
|
||||
.compile()
|
||||
)
|
||||
|
||||
class OuterState(TypedDict):
|
||||
messages: Annotated[list[Any], operator.add]
|
||||
done: bool
|
||||
|
||||
def pre_node(state: OuterState) -> dict[str, Any]:
|
||||
return {"done": False}
|
||||
|
||||
return (
|
||||
StateGraph(OuterState)
|
||||
.add_node("pre", pre_node)
|
||||
.add_node("inner", inner)
|
||||
.add_edge(START, "pre")
|
||||
.add_edge("pre", "inner")
|
||||
.add_edge("inner", END)
|
||||
.compile()
|
||||
)
|
||||
|
||||
|
||||
def _make_custom_writer_graph():
|
||||
"""Graph where a node emits custom stream events via StreamWriter."""
|
||||
|
||||
def writer_node(state: AgentState, *, writer: StreamWriter) -> dict[str, Any]:
|
||||
writer({"step": "start", "detail": "beginning work"})
|
||||
writer({"step": "middle", "detail": "processing"})
|
||||
writer({"step": "end", "detail": "done"})
|
||||
return {"value": state["value"] + "_custom", "items": ["custom"]}
|
||||
|
||||
builder = StateGraph(AgentState)
|
||||
builder.add_node("writer_node", writer_node)
|
||||
builder.add_edge(START, "writer_node")
|
||||
builder.add_edge("writer_node", END)
|
||||
return builder.compile()
|
||||
|
||||
|
||||
def _make_interrupt_graph():
|
||||
"""Graph that interrupts after the first node."""
|
||||
|
||||
def step_one(state: AgentState) -> dict[str, Any]:
|
||||
return {"value": state["value"] + "_step1", "items": ["step1"]}
|
||||
|
||||
def step_two(state: AgentState) -> dict[str, Any]:
|
||||
answer = interrupt("need approval")
|
||||
return {"value": state["value"] + f"_{answer}", "items": ["step2"]}
|
||||
|
||||
builder = StateGraph(AgentState)
|
||||
builder.add_node("step_one", step_one)
|
||||
builder.add_node("step_two", step_two)
|
||||
builder.add_edge(START, "step_one")
|
||||
builder.add_edge("step_one", "step_two")
|
||||
builder.add_edge("step_two", END)
|
||||
return builder.compile(checkpointer=InMemorySaver())
|
||||
|
||||
|
||||
def _make_error_subgraph():
|
||||
"""Graph with a subgraph that raises."""
|
||||
|
||||
def failing_node(state: AgentState) -> dict[str, Any]:
|
||||
raise ValueError("subgraph explosion")
|
||||
|
||||
inner_builder = StateGraph(AgentState)
|
||||
inner_builder.add_node("fail", failing_node)
|
||||
inner_builder.add_edge(START, "fail")
|
||||
inner_builder.add_edge("fail", END)
|
||||
inner = inner_builder.compile()
|
||||
|
||||
outer_builder = StateGraph(AgentState)
|
||||
outer_builder.add_node("inner", inner)
|
||||
outer_builder.add_edge(START, "inner")
|
||||
outer_builder.add_edge("inner", END)
|
||||
return outer_builder.compile()
|
||||
|
||||
|
||||
class _CustomPassthroughTransformer(StreamTransformer):
|
||||
required_stream_modes = ("custom",)
|
||||
|
||||
def init(self) -> dict[str, Any]:
|
||||
return {}
|
||||
|
||||
def process(self, event: ProtocolEvent) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
class _CounterTransformer(StreamTransformer):
|
||||
"""Custom transformer that counts values events via a StreamChannel."""
|
||||
|
||||
def __init__(self, scope: tuple[str, ...] = ()) -> None:
|
||||
super().__init__(scope)
|
||||
self._channel: StreamChannel[int] = StreamChannel("counter")
|
||||
self._count = 0
|
||||
|
||||
def init(self) -> dict[str, Any]:
|
||||
return {"counter": self._channel}
|
||||
|
||||
def process(self, event: ProtocolEvent) -> bool:
|
||||
if event["method"] == "values":
|
||||
self._count += 1
|
||||
self._channel.push(self._count)
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sync end-to-end: all projections on nested graph
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStreamV2E2ESync:
|
||||
def test_all_projections_nested_graph(self) -> None:
|
||||
"""Run a nested graph through stream_events(version="v3") and verify values + lifecycle."""
|
||||
graph = _make_nested_graph()
|
||||
run = graph.stream_events({"value": "x", "items": []}, version="v3")
|
||||
|
||||
values_snapshots: list[dict[str, Any]] = []
|
||||
lifecycle_events: list[dict[str, Any]] = []
|
||||
for name, item in run.interleave("values", "lifecycle"):
|
||||
if name == "values":
|
||||
values_snapshots.append(item)
|
||||
elif name == "lifecycle":
|
||||
lifecycle_events.append(item)
|
||||
|
||||
assert len(values_snapshots) >= 1
|
||||
final = values_snapshots[-1]
|
||||
assert "routed" in final["items"]
|
||||
assert "processed" in final["items"]
|
||||
assert "_routed" in final["value"]
|
||||
assert "_processed" in final["value"]
|
||||
|
||||
assert len(lifecycle_events) >= 2
|
||||
started = [e for e in lifecycle_events if e["event"] == "started"]
|
||||
completed = [e for e in lifecycle_events if e["event"] == "completed"]
|
||||
assert len(started) >= 1
|
||||
assert len(completed) >= 1
|
||||
|
||||
def test_subgraph_handles_with_drill_down(self) -> None:
|
||||
"""Subgraph handles yield and support values drill-down."""
|
||||
graph = _make_nested_graph()
|
||||
run = graph.stream_events({"value": "x", "items": []}, version="v3")
|
||||
|
||||
handles = []
|
||||
for handle in run.subgraphs:
|
||||
child_values = list(handle.values)
|
||||
handles.append(
|
||||
{
|
||||
"path": handle.path,
|
||||
"graph_name": handle.graph_name,
|
||||
"values_count": len(child_values),
|
||||
}
|
||||
)
|
||||
|
||||
assert len(handles) >= 1
|
||||
assert handles[0]["values_count"] >= 1
|
||||
|
||||
output = run.output
|
||||
assert output is not None
|
||||
assert "_routed" in output["value"]
|
||||
assert "_processed" in output["value"]
|
||||
|
||||
def test_raw_events_have_monotonic_seq(self) -> None:
|
||||
"""Raw protocol events have monotonically increasing seq numbers."""
|
||||
graph = _make_nested_graph()
|
||||
run = graph.stream_events({"value": "x", "items": []}, version="v3")
|
||||
events = list(run)
|
||||
assert len(events) > 0
|
||||
|
||||
seqs = [e["seq"] for e in events]
|
||||
for i in range(1, len(seqs)):
|
||||
assert seqs[i] > seqs[i - 1], f"seq not monotonic at {i}: {seqs}"
|
||||
|
||||
for event in events:
|
||||
assert event["type"] == "event"
|
||||
assert "method" in event
|
||||
assert isinstance(event["params"]["timestamp"], int)
|
||||
|
||||
def test_output_matches_final_values_snapshot(self) -> None:
|
||||
"""output property returns the same state as the last values snapshot."""
|
||||
run1 = _make_nested_graph().stream_events(
|
||||
{"value": "x", "items": []}, version="v3"
|
||||
)
|
||||
snapshots = list(run1.values)
|
||||
final_via_values = snapshots[-1]
|
||||
|
||||
run2 = _make_nested_graph().stream_events(
|
||||
{"value": "x", "items": []}, version="v3"
|
||||
)
|
||||
final_via_output = run2.output
|
||||
|
||||
assert final_via_values == final_via_output
|
||||
|
||||
def test_context_manager_and_abort(self) -> None:
|
||||
"""Context manager calls abort, marking the stream exhausted."""
|
||||
graph = _make_nested_graph()
|
||||
with graph.stream_events({"value": "x", "items": []}, version="v3") as run:
|
||||
first_val = next(iter(run.values))
|
||||
assert isinstance(first_val, dict)
|
||||
assert run._exhausted is True
|
||||
|
||||
def test_extensions_has_all_native_keys(self) -> None:
|
||||
"""Extensions dict exposes all native projection keys."""
|
||||
graph = _make_nested_graph()
|
||||
run = graph.stream_events({"value": "x", "items": []}, version="v3")
|
||||
_ = run.output
|
||||
|
||||
assert "values" in run.extensions
|
||||
assert "messages" in run.extensions
|
||||
assert "lifecycle" in run.extensions
|
||||
assert "subgraphs" in run.extensions
|
||||
assert run.values is run.extensions["values"]
|
||||
assert run.messages is run.extensions["messages"]
|
||||
assert run.lifecycle is run.extensions["lifecycle"]
|
||||
assert run.subgraphs is run.extensions["subgraphs"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sync: messages projection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStreamV2E2EMessages:
|
||||
def test_messages_projection_from_invoke(self) -> None:
|
||||
"""Messages projection captures LLM calls via model.invoke() auto-routing."""
|
||||
graph = _make_messages_graph()
|
||||
run = graph.stream_events({"messages": "hi"}, version="v3")
|
||||
streams = list(run.messages)
|
||||
|
||||
assert len(streams) >= 1
|
||||
for stream in streams:
|
||||
assert isinstance(stream, ChatModelStream)
|
||||
assert streams[0].output.text == "hello world"
|
||||
|
||||
def test_messages_text_deltas(self) -> None:
|
||||
"""Text deltas from the messages projection concatenate correctly."""
|
||||
model = GenericFakeChatModel(messages=iter(["streamed answer"]))
|
||||
|
||||
def call_model(state: MessagesState) -> dict[str, Any]:
|
||||
return {"messages": model.invoke(state["messages"])}
|
||||
|
||||
graph = (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("call_model", call_model)
|
||||
.add_edge(START, "call_model")
|
||||
.add_edge("call_model", END)
|
||||
.compile()
|
||||
)
|
||||
|
||||
run = graph.stream_events({"messages": "go"}, version="v3")
|
||||
(stream,) = list(run.messages)
|
||||
assert "".join(stream.text) == "streamed answer"
|
||||
|
||||
def test_messages_from_whole_ai_message(self) -> None:
|
||||
"""Node returning AIMessage directly produces a complete stream."""
|
||||
|
||||
def return_msg(state: MessagesState) -> dict[str, Any]:
|
||||
return {"messages": AIMessage(content="hardcoded", id="msg-1")}
|
||||
|
||||
graph = (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("return_msg", return_msg)
|
||||
.add_edge(START, "return_msg")
|
||||
.add_edge("return_msg", END)
|
||||
.compile()
|
||||
)
|
||||
|
||||
run = graph.stream_events({"messages": "hi"}, version="v3")
|
||||
(stream,) = list(run.messages)
|
||||
assert stream.output.text == "hardcoded"
|
||||
assert stream.message_id == "msg-1"
|
||||
|
||||
def test_root_messages_only_shows_root_scope(self) -> None:
|
||||
"""Root messages projection doesn't surface subgraph-scoped messages."""
|
||||
graph = _make_messages_subgraph()
|
||||
run = graph.stream_events({"messages": ["hi"], "done": False}, version="v3")
|
||||
root_streams = list(run.messages)
|
||||
# The message is emitted inside the subgraph, so the root
|
||||
# messages projection (scoped to root namespace) doesn't see it.
|
||||
assert root_streams == []
|
||||
|
||||
def test_subgraph_handle_messages_drill_down(self) -> None:
|
||||
"""Drilling into subgraph handle's messages surfaces subgraph messages."""
|
||||
graph = _make_messages_subgraph()
|
||||
run = graph.stream_events({"messages": ["hi"], "done": False}, version="v3")
|
||||
|
||||
found_messages = False
|
||||
for handle in run.subgraphs:
|
||||
child_messages = list(handle.messages)
|
||||
if child_messages:
|
||||
found_messages = True
|
||||
assert isinstance(child_messages[0], ChatModelStream)
|
||||
assert child_messages[0].output.text == "from subgraph"
|
||||
assert found_messages
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sync: custom stream writer + custom transformer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStreamV2E2ECustom:
|
||||
def test_custom_events_with_passthrough_transformer(self) -> None:
|
||||
"""Custom StreamWriter events appear on the main log when a
|
||||
transformer declares the custom mode."""
|
||||
graph = _make_custom_writer_graph()
|
||||
run = graph.stream_events(
|
||||
{"value": "x", "items": []},
|
||||
version="v3",
|
||||
transformers=[_CustomPassthroughTransformer],
|
||||
)
|
||||
events = list(run)
|
||||
custom = [e for e in events if e["method"] == "custom"]
|
||||
assert len(custom) == 3
|
||||
steps = [e["params"]["data"]["step"] for e in custom]
|
||||
assert steps == ["start", "middle", "end"]
|
||||
|
||||
def test_custom_events_suppressed_without_transformer(self) -> None:
|
||||
"""Without a custom-mode transformer, custom events don't flow."""
|
||||
graph = _make_custom_writer_graph()
|
||||
run = graph.stream_events({"value": "x", "items": []}, version="v3")
|
||||
events = list(run)
|
||||
custom = [e for e in events if e["method"] == "custom"]
|
||||
assert custom == []
|
||||
|
||||
def test_custom_transformer_with_stream_channel(self) -> None:
|
||||
"""A custom transformer with a StreamChannel produces extension data."""
|
||||
graph = _make_nested_graph()
|
||||
run = graph.stream_events(
|
||||
{"value": "x", "items": []},
|
||||
version="v3",
|
||||
transformers=[_CounterTransformer],
|
||||
)
|
||||
|
||||
assert "counter" in run.extensions
|
||||
counter_iter = iter(run.extensions["counter"])
|
||||
_ = run.output
|
||||
counts = list(counter_iter)
|
||||
assert len(counts) >= 1
|
||||
assert all(isinstance(c, int) for c in counts)
|
||||
assert counts == sorted(counts)
|
||||
|
||||
def test_custom_channel_events_on_main_log(self) -> None:
|
||||
"""StreamChannel auto-forward injects custom:<name> events into the main log."""
|
||||
graph = _make_nested_graph()
|
||||
run = graph.stream_events(
|
||||
{"value": "x", "items": []},
|
||||
version="v3",
|
||||
transformers=[_CounterTransformer],
|
||||
)
|
||||
events = list(run)
|
||||
counter_events = [e for e in events if e["method"] == "custom:counter"]
|
||||
assert len(counter_events) >= 1
|
||||
assert all(isinstance(e["params"]["data"], int) for e in counter_events)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sync: interrupt handling
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStreamV2E2EInterrupt:
|
||||
def test_interrupt_sets_flags_and_surfaces_interrupts(self) -> None:
|
||||
"""Interrupted run has correct flags and interrupt payloads."""
|
||||
graph = _make_interrupt_graph()
|
||||
config: dict[str, Any] = {"configurable": {"thread_id": "int-1"}}
|
||||
run = graph.stream_events({"value": "x", "items": []}, config, version="v3")
|
||||
|
||||
output = run.output
|
||||
assert output is not None
|
||||
assert run.interrupted is True
|
||||
assert len(run.interrupts) > 0
|
||||
assert output["items"] == ["step1"]
|
||||
assert "_step1" in output["value"]
|
||||
|
||||
def test_interrupt_values_snapshot_has_partial_state(self) -> None:
|
||||
"""Values snapshots captured before the interrupt reflect partial state."""
|
||||
graph = _make_interrupt_graph()
|
||||
config: dict[str, Any] = {"configurable": {"thread_id": "int-2"}}
|
||||
run = graph.stream_events({"value": "x", "items": []}, config, version="v3")
|
||||
|
||||
snapshots = list(run.values)
|
||||
assert len(snapshots) >= 1
|
||||
last = snapshots[-1]
|
||||
assert "step1" in last["items"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sync: error propagation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStreamV2E2EErrors:
|
||||
def test_subgraph_error_propagates_through_output(self) -> None:
|
||||
"""Error in a subgraph propagates through output."""
|
||||
graph = _make_error_subgraph()
|
||||
run = graph.stream_events({"value": "x", "items": []}, version="v3")
|
||||
|
||||
with pytest.raises(ValueError, match="subgraph explosion"):
|
||||
_ = run.output
|
||||
|
||||
def test_subgraph_error_propagates_through_raw_events(self) -> None:
|
||||
graph = _make_error_subgraph()
|
||||
run = graph.stream_events({"value": "x", "items": []}, version="v3")
|
||||
|
||||
with pytest.raises(ValueError, match="subgraph explosion"):
|
||||
list(run)
|
||||
|
||||
def test_error_subgraph_handle_status(self) -> None:
|
||||
"""Subgraph handle surfaces the error status."""
|
||||
graph = _make_error_subgraph()
|
||||
run = graph.stream_events({"value": "x", "items": []}, version="v3")
|
||||
|
||||
handle = next(iter(run.subgraphs))
|
||||
with pytest.raises(RuntimeError, match="subgraph explosion"):
|
||||
_ = handle.output
|
||||
assert handle.status == "failed"
|
||||
assert handle.error == "subgraph explosion"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Async end-to-end
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@NEEDS_CONTEXTVARS
|
||||
class TestStreamV2E2EAsync:
|
||||
async def test_all_projections_async(self) -> None:
|
||||
"""Async run exercises values projection."""
|
||||
graph = _make_nested_graph()
|
||||
run = await graph.astream_events({"value": "x", "items": []}, version="v3")
|
||||
|
||||
values_snapshots = [s async for s in run.values]
|
||||
assert len(values_snapshots) >= 1
|
||||
final = values_snapshots[-1]
|
||||
assert "_routed" in final["value"]
|
||||
assert "_processed" in final["value"]
|
||||
|
||||
async def test_async_output(self) -> None:
|
||||
"""Async output returns the final state."""
|
||||
graph = _make_nested_graph()
|
||||
run = await graph.astream_events({"value": "x", "items": []}, version="v3")
|
||||
output = await run.output()
|
||||
assert output is not None
|
||||
assert output["value"] == "x_routed_processed"
|
||||
assert "routed" in output["items"]
|
||||
assert "processed" in output["items"]
|
||||
|
||||
async def test_async_raw_events(self) -> None:
|
||||
"""Async raw event iteration yields well-formed ProtocolEvents."""
|
||||
graph = _make_nested_graph()
|
||||
run = await graph.astream_events({"value": "x", "items": []}, version="v3")
|
||||
events = [e async for e in run]
|
||||
assert len(events) > 0
|
||||
seqs = [e["seq"] for e in events]
|
||||
for i in range(1, len(seqs)):
|
||||
assert seqs[i] > seqs[i - 1]
|
||||
|
||||
async def test_async_messages_projection(self) -> None:
|
||||
"""Async messages projection captures LLM streams."""
|
||||
model = GenericFakeChatModel(messages=iter(["async answer"]))
|
||||
|
||||
async def call_model(state: MessagesState) -> dict[str, Any]:
|
||||
return {"messages": await model.ainvoke(state["messages"])}
|
||||
|
||||
graph = (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("call_model", call_model)
|
||||
.add_edge(START, "call_model")
|
||||
.add_edge("call_model", END)
|
||||
.compile()
|
||||
)
|
||||
|
||||
run = await graph.astream_events({"messages": "hi"}, version="v3")
|
||||
streams = [s async for s in run.messages]
|
||||
assert len(streams) >= 1
|
||||
for s in streams:
|
||||
assert isinstance(s, AsyncChatModelStream)
|
||||
assert (await streams[0].output).text == "async answer"
|
||||
|
||||
async def test_async_interrupt(self) -> None:
|
||||
"""Async interrupted run has correct flags."""
|
||||
graph = _make_interrupt_graph()
|
||||
config: dict[str, Any] = {"configurable": {"thread_id": "async-int-1"}}
|
||||
run = await graph.astream_events(
|
||||
{"value": "x", "items": []}, config, version="v3"
|
||||
)
|
||||
|
||||
output = await run.output()
|
||||
assert output is not None
|
||||
assert await run.interrupted() is True
|
||||
assert len(await run.interrupts()) > 0
|
||||
|
||||
async def test_async_error_propagation(self) -> None:
|
||||
"""Async error from subgraph propagates through output."""
|
||||
graph = _make_error_subgraph()
|
||||
run = await graph.astream_events({"value": "x", "items": []}, version="v3")
|
||||
with pytest.raises(ValueError, match="subgraph explosion"):
|
||||
await run.output()
|
||||
|
||||
async def test_async_context_manager(self) -> None:
|
||||
"""Async context manager calls abort on exit."""
|
||||
graph = _make_nested_graph()
|
||||
run = await graph.astream_events({"value": "x", "items": []}, version="v3")
|
||||
async with run:
|
||||
_ = await anext(aiter(run.values))
|
||||
assert run._exhausted is True
|
||||
|
||||
async def test_async_extensions_present(self) -> None:
|
||||
"""Async run has all native extensions."""
|
||||
graph = _make_nested_graph()
|
||||
run = await graph.astream_events({"value": "x", "items": []}, version="v3")
|
||||
_ = await run.output()
|
||||
assert "values" in run.extensions
|
||||
assert "messages" in run.extensions
|
||||
assert "lifecycle" in run.extensions
|
||||
assert "subgraphs" in run.extensions
|
||||
|
||||
async def test_async_custom_transformer(self) -> None:
|
||||
"""Async custom transformer with StreamChannel works."""
|
||||
graph = _make_nested_graph()
|
||||
run = await graph.astream_events(
|
||||
{"value": "x", "items": []},
|
||||
version="v3",
|
||||
transformers=[_CounterTransformer],
|
||||
)
|
||||
assert "counter" in run.extensions
|
||||
counter_cursor = aiter(run.extensions["counter"])
|
||||
_ = await run.output()
|
||||
counts = [c async for c in counter_cursor]
|
||||
assert len(counts) >= 1
|
||||
assert counts == sorted(counts)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sync: combined projections stress test
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStreamV2E2ECombined:
|
||||
def test_interleave_all_native_projections(self) -> None:
|
||||
"""Interleave values + messages + lifecycle without deadlock."""
|
||||
graph = _make_nested_graph()
|
||||
run = graph.stream_events({"value": "x", "items": []}, version="v3")
|
||||
|
||||
seen_names: set[str] = set()
|
||||
for name, _item in run.interleave("values", "messages", "lifecycle"):
|
||||
seen_names.add(name)
|
||||
|
||||
assert "values" in seen_names
|
||||
assert "lifecycle" in seen_names
|
||||
|
||||
def test_multiple_custom_transformers(self) -> None:
|
||||
"""Multiple custom transformers can coexist."""
|
||||
|
||||
class TagTransformer(StreamTransformer):
|
||||
def __init__(self, scope: tuple[str, ...] = ()) -> None:
|
||||
super().__init__(scope)
|
||||
self._channel: StreamChannel[str] = StreamChannel("tags")
|
||||
|
||||
def init(self) -> dict[str, Any]:
|
||||
return {"tags": self._channel}
|
||||
|
||||
def process(self, event: ProtocolEvent) -> bool:
|
||||
if event["method"] == "values":
|
||||
self._channel.push(
|
||||
f"tag:{event['params']['data'].get('value', '')}"
|
||||
)
|
||||
return True
|
||||
|
||||
graph = _make_nested_graph()
|
||||
run = graph.stream_events(
|
||||
{"value": "x", "items": []},
|
||||
version="v3",
|
||||
transformers=[_CounterTransformer, TagTransformer],
|
||||
)
|
||||
|
||||
assert "counter" in run.extensions
|
||||
assert "tags" in run.extensions
|
||||
|
||||
counter_iter = iter(run.extensions["counter"])
|
||||
tags_iter = iter(run.extensions["tags"])
|
||||
_ = run.output
|
||||
counts = list(counter_iter)
|
||||
tags = list(tags_iter)
|
||||
|
||||
assert len(counts) >= 1
|
||||
assert len(tags) >= 1
|
||||
assert all(t.startswith("tag:") for t in tags)
|
||||
|
||||
def test_two_sibling_subgraphs_both_discoverable(self) -> None:
|
||||
"""Two sequential subgraph invocations produce two handles."""
|
||||
|
||||
class _S(TypedDict):
|
||||
items: Annotated[list[str], operator.add]
|
||||
|
||||
def _item(name: str):
|
||||
def node(state: _S) -> dict[str, Any]:
|
||||
return {"items": [name]}
|
||||
|
||||
return node
|
||||
|
||||
inner_a = (
|
||||
StateGraph(_S)
|
||||
.add_node("add_a", _item("a"))
|
||||
.add_edge(START, "add_a")
|
||||
.add_edge("add_a", END)
|
||||
.compile()
|
||||
)
|
||||
inner_b = (
|
||||
StateGraph(_S)
|
||||
.add_node("add_b", _item("b"))
|
||||
.add_edge(START, "add_b")
|
||||
.add_edge("add_b", END)
|
||||
.compile()
|
||||
)
|
||||
|
||||
outer = (
|
||||
StateGraph(_S)
|
||||
.add_node("sub_a", inner_a)
|
||||
.add_node("sub_b", inner_b)
|
||||
.add_edge(START, "sub_a")
|
||||
.add_edge("sub_a", "sub_b")
|
||||
.add_edge("sub_b", END)
|
||||
.compile()
|
||||
)
|
||||
|
||||
run = outer.stream_events({"items": []}, version="v3")
|
||||
handles = []
|
||||
for handle in run.subgraphs:
|
||||
list(handle.values)
|
||||
handles.append(handle)
|
||||
|
||||
assert len(handles) == 2
|
||||
names = [h.graph_name for h in handles]
|
||||
assert "sub_a" in names
|
||||
assert "sub_b" in names
|
||||
assert all(h.status == "completed" for h in handles)
|
||||
|
||||
output = run.output
|
||||
assert output is not None
|
||||
assert set(output["items"]) == {"a", "b"}
|
||||
|
||||
def test_lifecycle_matches_subgraph_handles(self) -> None:
|
||||
"""Lifecycle events and subgraph handles agree on discovered subgraphs."""
|
||||
run1 = _make_nested_graph().stream_events(
|
||||
{"value": "x", "items": []}, version="v3"
|
||||
)
|
||||
handle_paths: list[tuple[str, ...]] = []
|
||||
for handle in run1.subgraphs:
|
||||
list(handle.values)
|
||||
handle_paths.append(handle.path)
|
||||
|
||||
run2 = _make_nested_graph().stream_events(
|
||||
{"value": "x", "items": []}, version="v3"
|
||||
)
|
||||
lifecycle = list(run2.lifecycle)
|
||||
|
||||
started_ns = [
|
||||
tuple(e["namespace"]) for e in lifecycle if e["event"] == "started"
|
||||
]
|
||||
# Handle paths use format "graph_name:call_id", lifecycle namespaces
|
||||
# use the same format. Both should have the same graph_name prefix.
|
||||
handle_prefixes = {p[0].split(":")[0] for p in handle_paths}
|
||||
lifecycle_prefixes = {ns[0].split(":")[0] for ns in started_ns}
|
||||
assert handle_prefixes == lifecycle_prefixes
|
||||
|
||||
def test_values_plus_messages_plus_custom(self) -> None:
|
||||
"""Values, messages, and a custom transformer all produce data in one run."""
|
||||
model = GenericFakeChatModel(messages=iter(["combined test"]))
|
||||
|
||||
def call_model(state: MessagesState) -> dict[str, Any]:
|
||||
return {"messages": model.invoke(state["messages"])}
|
||||
|
||||
graph = (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("call_model", call_model)
|
||||
.add_edge(START, "call_model")
|
||||
.add_edge("call_model", END)
|
||||
.compile()
|
||||
)
|
||||
|
||||
run = graph.stream_events(
|
||||
{"messages": "hi"},
|
||||
version="v3",
|
||||
transformers=[_CounterTransformer],
|
||||
)
|
||||
|
||||
counter_iter = iter(run.extensions["counter"])
|
||||
values_iter = iter(run.values)
|
||||
messages_iter = iter(run.messages)
|
||||
|
||||
values = list(values_iter)
|
||||
messages = list(messages_iter)
|
||||
counts = list(counter_iter)
|
||||
|
||||
assert len(values) >= 1
|
||||
assert len(messages) >= 1
|
||||
assert len(counts) >= 1
|
||||
assert messages[0].output.text == "combined test"
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Tests that ``(a)stream_events(version="v3")`` forwards extra kwargs to the
|
||||
underlying ``(a)stream`` call, and rejects the kwargs v3 owns internally.
|
||||
|
||||
Background: prior to this change the v3 dispatcher silently dropped ``**kwargs``
|
||||
on the v3 branch while forwarding them on v1/v2, so callers passing e.g.
|
||||
``context=...`` saw their value disappear with no error. v3 now forwards
|
||||
caller kwargs to the inner ``(a)stream`` call but rejects ``stream_mode`` and
|
||||
``subgraphs`` since v3 owns them (``stream_mode`` is built from the
|
||||
transformer mux; ``subgraphs`` is forced True so nested namespaces flow
|
||||
through scoped muxes).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from langgraph.constants import END, START
|
||||
from langgraph.graph import StateGraph
|
||||
from langgraph.runtime import Runtime
|
||||
|
||||
NEEDS_CONTEXTVARS = pytest.mark.skipif(
|
||||
sys.version_info < (3, 11),
|
||||
reason="Python 3.11+ is required for async contextvars support",
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Ctx:
|
||||
api_key: str
|
||||
|
||||
|
||||
class _State(TypedDict):
|
||||
message: str
|
||||
|
||||
|
||||
def _build_context_reading_graph():
|
||||
def read_context(state: _State, runtime: Runtime[_Ctx]) -> dict[str, Any]:
|
||||
return {"message": f"api key: {runtime.context.api_key}"}
|
||||
|
||||
builder = StateGraph(state_schema=_State, context_schema=_Ctx)
|
||||
builder.add_node("read_context", read_context)
|
||||
builder.add_edge(START, "read_context")
|
||||
builder.add_edge("read_context", END)
|
||||
return builder.compile()
|
||||
|
||||
|
||||
class TestKwargForwardingSync:
|
||||
def test_context_reaches_node(self) -> None:
|
||||
run = _build_context_reading_graph().stream_events(
|
||||
{"message": "hello"},
|
||||
version="v3",
|
||||
context=_Ctx(api_key="sk_sync"),
|
||||
)
|
||||
assert run.output == {"message": "api key: sk_sync"}
|
||||
|
||||
def test_rejects_stream_mode(self) -> None:
|
||||
graph = _build_context_reading_graph()
|
||||
with pytest.raises(TypeError, match="stream_mode"):
|
||||
graph.stream_events(
|
||||
{"message": "hello"},
|
||||
version="v3",
|
||||
stream_mode=["values"],
|
||||
)
|
||||
|
||||
def test_rejects_subgraphs(self) -> None:
|
||||
graph = _build_context_reading_graph()
|
||||
with pytest.raises(TypeError, match="subgraphs"):
|
||||
graph.stream_events(
|
||||
{"message": "hello"},
|
||||
version="v3",
|
||||
subgraphs=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@NEEDS_CONTEXTVARS
|
||||
class TestKwargForwardingAsync:
|
||||
async def test_context_reaches_node(self) -> None:
|
||||
run = await _build_context_reading_graph().astream_events(
|
||||
{"message": "hello"},
|
||||
version="v3",
|
||||
context=_Ctx(api_key="sk_async"),
|
||||
)
|
||||
output = await run.output()
|
||||
assert output == {"message": "api key: sk_async"}
|
||||
|
||||
async def test_rejects_stream_mode(self) -> None:
|
||||
graph = _build_context_reading_graph()
|
||||
with pytest.raises(TypeError, match="stream_mode"):
|
||||
await graph.astream_events(
|
||||
{"message": "hello"},
|
||||
version="v3",
|
||||
stream_mode=["values"],
|
||||
)
|
||||
|
||||
async def test_rejects_subgraphs(self) -> None:
|
||||
graph = _build_context_reading_graph()
|
||||
with pytest.raises(TypeError, match="subgraphs"):
|
||||
await graph.astream_events(
|
||||
{"message": "hello"},
|
||||
version="v3",
|
||||
subgraphs=False,
|
||||
)
|
||||
@@ -0,0 +1,406 @@
|
||||
"""Tests for LifecycleTransformer.
|
||||
|
||||
Consumes the `tasks` stream mode and emits subgraph lifecycle payloads
|
||||
on the `lifecycle` channel for both in-process iteration via
|
||||
`run.lifecycle` and wire delivery via `custom:lifecycle` protocol
|
||||
events. Most tests dispatch synthetic protocol events through a
|
||||
`StreamMux` to keep the inference logic isolated; the end-of-file
|
||||
group exercises the path through real graphs (multi-depth
|
||||
discovery, nested `stream_events(version="v3")` calls with non-empty `parent_ns`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import operator
|
||||
import time
|
||||
from typing import Annotated, Any
|
||||
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from langgraph._internal._constants import CONF, CONFIG_KEY_CHECKPOINT_NS
|
||||
from langgraph.constants import END, START
|
||||
from langgraph.errors import GraphInterrupt
|
||||
from langgraph.graph import StateGraph
|
||||
from langgraph.stream._mux import StreamMux
|
||||
from langgraph.stream.transformers import (
|
||||
LifecyclePayload,
|
||||
LifecycleTransformer,
|
||||
)
|
||||
|
||||
TS = int(time.time() * 1000)
|
||||
|
||||
|
||||
def _tasks_start(
|
||||
namespace: list[str],
|
||||
*,
|
||||
task_id: str,
|
||||
name: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Build a `tasks` ProtocolEvent carrying a TaskPayload (start)."""
|
||||
return {
|
||||
"type": "event",
|
||||
"method": "tasks",
|
||||
"params": {
|
||||
"namespace": namespace,
|
||||
"timestamp": TS,
|
||||
"data": {
|
||||
"id": task_id,
|
||||
"name": name,
|
||||
"input": None,
|
||||
"triggers": [],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _tasks_result(
|
||||
namespace: list[str],
|
||||
*,
|
||||
task_id: str,
|
||||
name: str,
|
||||
error: str | None = None,
|
||||
interrupts: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build a `tasks` ProtocolEvent carrying a TaskResultPayload (finish)."""
|
||||
return {
|
||||
"type": "event",
|
||||
"method": "tasks",
|
||||
"params": {
|
||||
"namespace": namespace,
|
||||
"timestamp": TS,
|
||||
"data": {
|
||||
"id": task_id,
|
||||
"name": name,
|
||||
"error": error,
|
||||
"interrupts": interrupts or [],
|
||||
"result": {},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _arm(mux: StreamMux) -> None:
|
||||
"""Force projection channels to accept pushes (skip lazy-subscribe gate).
|
||||
|
||||
`StreamChannel.push` only appends to the local buffer when a
|
||||
subscriber is attached. Tests that inspect `_items` directly need
|
||||
the gate flipped before any event is dispatched.
|
||||
"""
|
||||
mux._events._subscribed = True
|
||||
for transformer in mux._transformers:
|
||||
if isinstance(transformer, LifecycleTransformer):
|
||||
transformer._channel._subscribed = True
|
||||
|
||||
|
||||
def _unstamped(items):
|
||||
"""Strip push stamps from a StreamChannel's internal buffer."""
|
||||
return [item for _stamp, item in items]
|
||||
|
||||
|
||||
def _drain_lifecycle(mux: StreamMux) -> list[LifecyclePayload]:
|
||||
"""Snapshot the lifecycle channel's buffer."""
|
||||
transformer = mux.transformer_by_key("lifecycle")
|
||||
assert isinstance(transformer, LifecycleTransformer)
|
||||
return _unstamped(transformer._channel._items)
|
||||
|
||||
|
||||
def _build_lifecycle_mux(*, scope: tuple[str, ...] = ()) -> StreamMux:
|
||||
mux = StreamMux([LifecycleTransformer(scope=scope)], is_async=False)
|
||||
_arm(mux)
|
||||
return mux
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LifecycleTransformer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_started_emitted_on_first_direct_child_task() -> None:
|
||||
mux = _build_lifecycle_mux()
|
||||
mux.push(_tasks_start(["agent:abc123"], task_id="t1", name="tool"))
|
||||
|
||||
[payload] = _drain_lifecycle(mux)
|
||||
assert payload["event"] == "started"
|
||||
assert payload["namespace"] == ["agent:abc123"]
|
||||
assert payload["graph_name"] == "agent"
|
||||
assert payload["trigger_call_id"] == "abc123"
|
||||
|
||||
|
||||
def test_started_dedup_on_repeat_namespace() -> None:
|
||||
mux = _build_lifecycle_mux()
|
||||
mux.push(_tasks_start(["agent:abc"], task_id="t1", name="a"))
|
||||
mux.push(_tasks_start(["agent:abc"], task_id="t2", name="b"))
|
||||
|
||||
payloads = _drain_lifecycle(mux)
|
||||
assert [p["event"] for p in payloads] == ["started"]
|
||||
|
||||
|
||||
def test_grandchild_namespace_discovered() -> None:
|
||||
"""Subgraphs at any depth below scope are tracked, not just direct children."""
|
||||
mux = _build_lifecycle_mux()
|
||||
# First-seen task at length-2 ns means a 2nd-level subgraph started.
|
||||
mux.push(_tasks_start(["agent:abc", "tool:def"], task_id="t1", name="x"))
|
||||
|
||||
[payload] = _drain_lifecycle(mux)
|
||||
assert payload["event"] == "started"
|
||||
assert payload["namespace"] == ["agent:abc", "tool:def"]
|
||||
|
||||
|
||||
def test_nested_chain_emits_started_at_each_depth() -> None:
|
||||
"""A graph → subgraph → subgraph chain produces a started event per level."""
|
||||
mux = _build_lifecycle_mux()
|
||||
# Subgraph1 starts emitting tasks (events tagged with its own ns).
|
||||
mux.push(_tasks_start(["agent:abc"], task_id="t1", name="tool"))
|
||||
# Subgraph1 invokes subgraph2; subgraph2's first task event arrives.
|
||||
mux.push(_tasks_start(["agent:abc", "tool:def"], task_id="t2", name="deep"))
|
||||
|
||||
payloads = _drain_lifecycle(mux)
|
||||
assert [p["namespace"] for p in payloads] == [
|
||||
["agent:abc"],
|
||||
["agent:abc", "tool:def"],
|
||||
]
|
||||
assert all(p["event"] == "started" for p in payloads)
|
||||
|
||||
|
||||
def test_nested_chain_emits_completed_at_each_depth() -> None:
|
||||
"""Each subgraph in a nested chain closes when its parent task result arrives."""
|
||||
mux = _build_lifecycle_mux()
|
||||
mux.push(_tasks_start(["agent:abc"], task_id="t1", name="tool"))
|
||||
mux.push(_tasks_start(["agent:abc", "tool:def"], task_id="t2", name="deep"))
|
||||
|
||||
# Subgraph2's owning task (id=def, inside subgraph1) finishes.
|
||||
mux.push(_tasks_result(["agent:abc"], task_id="def", name="tool"))
|
||||
# Subgraph1's owning task (id=abc, at root) finishes.
|
||||
mux.push(_tasks_result([], task_id="abc", name="agent"))
|
||||
|
||||
payloads = _drain_lifecycle(mux)
|
||||
events = [(p["event"], p["namespace"]) for p in payloads]
|
||||
assert events == [
|
||||
("started", ["agent:abc"]),
|
||||
("started", ["agent:abc", "tool:def"]),
|
||||
("completed", ["agent:abc", "tool:def"]),
|
||||
("completed", ["agent:abc"]),
|
||||
]
|
||||
|
||||
|
||||
def test_completed_on_parent_task_result() -> None:
|
||||
mux = _build_lifecycle_mux()
|
||||
mux.push(_tasks_start(["agent:abc"], task_id="t1", name="tool"))
|
||||
mux.push(_tasks_result([], task_id="abc", name="agent"))
|
||||
|
||||
events = [p["event"] for p in _drain_lifecycle(mux)]
|
||||
assert events == ["started", "completed"]
|
||||
|
||||
|
||||
def test_failed_on_parent_task_result_with_error() -> None:
|
||||
mux = _build_lifecycle_mux()
|
||||
mux.push(_tasks_start(["agent:abc"], task_id="t1", name="tool"))
|
||||
mux.push(_tasks_result([], task_id="abc", name="agent", error="boom"))
|
||||
|
||||
payloads = _drain_lifecycle(mux)
|
||||
assert [p["event"] for p in payloads] == ["started", "failed"]
|
||||
assert payloads[1]["error"] == "boom"
|
||||
|
||||
|
||||
def test_interrupted_on_parent_task_result_with_interrupts() -> None:
|
||||
mux = _build_lifecycle_mux()
|
||||
mux.push(_tasks_start(["agent:abc"], task_id="t1", name="tool"))
|
||||
mux.push(
|
||||
_tasks_result(
|
||||
[],
|
||||
task_id="abc",
|
||||
name="agent",
|
||||
interrupts=[{"value": "pause"}],
|
||||
)
|
||||
)
|
||||
|
||||
payloads = _drain_lifecycle(mux)
|
||||
assert [p["event"] for p in payloads] == ["started", "interrupted"]
|
||||
|
||||
|
||||
def test_interrupt_takes_precedence_over_error() -> None:
|
||||
mux = _build_lifecycle_mux()
|
||||
mux.push(_tasks_start(["agent:abc"], task_id="t1", name="tool"))
|
||||
mux.push(
|
||||
_tasks_result(
|
||||
[],
|
||||
task_id="abc",
|
||||
name="agent",
|
||||
error="should-be-suppressed",
|
||||
interrupts=[{"value": "pause"}],
|
||||
)
|
||||
)
|
||||
|
||||
last = _drain_lifecycle(mux)[-1]
|
||||
assert last["event"] == "interrupted"
|
||||
assert "error" not in last
|
||||
|
||||
|
||||
def test_finalize_completes_open_subgraphs() -> None:
|
||||
mux = _build_lifecycle_mux()
|
||||
mux.push(_tasks_start(["agent:abc"], task_id="t1", name="tool"))
|
||||
|
||||
mux.close()
|
||||
payloads = _drain_lifecycle(mux)
|
||||
assert [p["event"] for p in payloads] == ["started", "completed"]
|
||||
|
||||
|
||||
def test_fail_emits_interrupted_for_graph_interrupt() -> None:
|
||||
mux = _build_lifecycle_mux()
|
||||
mux.push(_tasks_start(["agent:abc"], task_id="t1", name="tool"))
|
||||
|
||||
mux.fail(GraphInterrupt())
|
||||
payloads = _drain_lifecycle(mux)
|
||||
assert [p["event"] for p in payloads] == ["started", "interrupted"]
|
||||
assert "error" not in payloads[1]
|
||||
|
||||
|
||||
def test_fail_emits_failed_for_other_exceptions() -> None:
|
||||
mux = _build_lifecycle_mux()
|
||||
mux.push(_tasks_start(["agent:abc"], task_id="t1", name="tool"))
|
||||
|
||||
mux.fail(RuntimeError("boom"))
|
||||
payloads = _drain_lifecycle(mux)
|
||||
assert [p["event"] for p in payloads] == ["started", "failed"]
|
||||
assert payloads[1]["error"] == "boom"
|
||||
|
||||
|
||||
def test_unrelated_methods_pass_through() -> None:
|
||||
"""Non-`tasks` events are not consumed and don't emit lifecycle."""
|
||||
mux = _build_lifecycle_mux()
|
||||
mux.push(
|
||||
{
|
||||
"type": "event",
|
||||
"method": "values",
|
||||
"params": {"namespace": ["agent:abc"], "timestamp": TS, "data": {}},
|
||||
}
|
||||
)
|
||||
assert _drain_lifecycle(mux) == []
|
||||
|
||||
|
||||
def test_scoped_transformer_filters_outside_scope_but_tracks_all_depths() -> None:
|
||||
"""Scope filters the prefix; subgraphs at any depth below scope are tracked."""
|
||||
mux = _build_lifecycle_mux(scope=("agent:abc",))
|
||||
# Root-level task — out of scope (no shared prefix).
|
||||
mux.push(_tasks_start(["other:1"], task_id="t1", name="other"))
|
||||
# Direct child of agent:abc — in scope.
|
||||
mux.push(_tasks_start(["agent:abc", "tool:def"], task_id="t2", name="tool"))
|
||||
# Grandchild of agent:abc — also in scope, tracked at its own depth.
|
||||
mux.push(
|
||||
_tasks_start(["agent:abc", "tool:def", "deep:ghi"], task_id="t3", name="deep")
|
||||
)
|
||||
|
||||
payloads = _drain_lifecycle(mux)
|
||||
assert [p["namespace"] for p in payloads] == [
|
||||
["agent:abc", "tool:def"],
|
||||
["agent:abc", "tool:def", "deep:ghi"],
|
||||
]
|
||||
|
||||
|
||||
def test_required_stream_modes_declared() -> None:
|
||||
assert LifecycleTransformer.required_stream_modes == ("tasks",)
|
||||
|
||||
|
||||
def test_protocol_event_method_is_native() -> None:
|
||||
"""Native transformer — auto-forwarded events use `lifecycle`, not `custom:lifecycle`."""
|
||||
mux = _build_lifecycle_mux()
|
||||
mux.push(_tasks_start(["agent:abc"], task_id="t1", name="tool"))
|
||||
|
||||
methods = {evt["method"] for evt in _unstamped(mux._events._items)}
|
||||
assert "lifecycle" in methods
|
||||
assert "custom:lifecycle" not in methods
|
||||
|
||||
|
||||
def test_tasks_events_suppressed_from_main_log() -> None:
|
||||
"""Tasks events are folded into lifecycle and don't appear on the main log."""
|
||||
mux = _build_lifecycle_mux()
|
||||
mux.push(_tasks_start(["agent:abc"], task_id="t1", name="tool"))
|
||||
mux.push(_tasks_result([], task_id="abc", name="agent"))
|
||||
|
||||
methods = [evt["method"] for evt in _unstamped(mux._events._items)]
|
||||
assert "tasks" not in methods
|
||||
# Lifecycle events did make it through, though.
|
||||
assert "lifecycle" in methods
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# End-to-end: real graphs through stream_events(version="v3")
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _State(TypedDict):
|
||||
value: str
|
||||
items: Annotated[list[str], operator.add]
|
||||
|
||||
|
||||
def _passthrough(state: _State) -> dict[str, Any]:
|
||||
return {"value": state["value"] + "!", "items": ["x"]}
|
||||
|
||||
|
||||
def _make_two_level_nested() -> Any:
|
||||
"""Build outer → middle → inner. Three Pregel instances, two nesting levels."""
|
||||
inner_b: StateGraph = StateGraph(_State, input_schema=_State)
|
||||
inner_b.add_node("inner_node", _passthrough)
|
||||
inner_b.add_edge(START, "inner_node")
|
||||
inner_b.add_edge("inner_node", END)
|
||||
inner = inner_b.compile()
|
||||
|
||||
middle_b: StateGraph = StateGraph(_State, input_schema=_State)
|
||||
middle_b.add_node("inner", inner)
|
||||
middle_b.add_edge(START, "inner")
|
||||
middle_b.add_edge("inner", END)
|
||||
middle = middle_b.compile()
|
||||
|
||||
outer_b: StateGraph = StateGraph(_State, input_schema=_State)
|
||||
outer_b.add_node("middle", middle)
|
||||
outer_b.add_edge(START, "middle")
|
||||
outer_b.add_edge("middle", END)
|
||||
return outer_b.compile()
|
||||
|
||||
|
||||
def test_stream_events_v3_real_graph_emits_lifecycle_at_each_depth() -> None:
|
||||
"""Outer graph with two nested subgraphs surfaces lifecycle for both."""
|
||||
graph = _make_two_level_nested()
|
||||
run = graph.stream_events({"value": "x", "items": []}, version="v3")
|
||||
|
||||
# Iterating the projection drives the pump and drains synthesized
|
||||
# lifecycle events at the same time.
|
||||
payloads = list(run.lifecycle)
|
||||
# Each subgraph instance produces a started + a terminal event. Two
|
||||
# nested instances, so four payloads total in some interleaving.
|
||||
by_event = {p["event"] for p in payloads}
|
||||
assert "started" in by_event
|
||||
assert "completed" in by_event
|
||||
# Two distinct namespaces — direct child of root, and grandchild.
|
||||
namespaces = {tuple(p["namespace"]) for p in payloads}
|
||||
direct_children = {ns for ns in namespaces if len(ns) == 1}
|
||||
grandchildren = {ns for ns in namespaces if len(ns) == 2}
|
||||
assert direct_children, f"expected a level-1 lifecycle namespace, got {namespaces}"
|
||||
assert grandchildren, f"expected a level-2 lifecycle namespace, got {namespaces}"
|
||||
# Every direct-child namespace has a matching grandchild whose path extends it.
|
||||
for parent in direct_children:
|
||||
assert any(gc[: len(parent)] == parent for gc in grandchildren), (
|
||||
f"grandchild does not extend parent {parent}: {grandchildren}"
|
||||
)
|
||||
|
||||
|
||||
def test_stream_events_v3_with_nested_parent_ns_scopes_lifecycle() -> None:
|
||||
"""When `stream_events(version="v3")` is called with a non-empty checkpoint_ns in config,
|
||||
`_resolve_parent_ns` returns that namespace and the registered
|
||||
`LifecycleTransformer` is constructed with `scope=parent_ns`. This
|
||||
exercises the path that exists today purely for nested-stream_events(version="v3")
|
||||
callers; the test simulates such a caller by injecting a
|
||||
checkpoint_ns into the config.
|
||||
"""
|
||||
graph = _make_two_level_nested()
|
||||
config = {CONF: {CONFIG_KEY_CHECKPOINT_NS: "outer:abc"}}
|
||||
run = graph.stream_events({"value": "x", "items": []}, config=config, version="v3")
|
||||
|
||||
payloads = list(run.lifecycle)
|
||||
# Every emitted lifecycle namespace must extend the caller's scope —
|
||||
# nothing at root-level, nothing under a sibling prefix.
|
||||
for p in payloads:
|
||||
ns = tuple(p["namespace"])
|
||||
assert ns[:1] == ("outer:abc",), (
|
||||
f"namespace {ns} not within scoped prefix ('outer:abc',)"
|
||||
)
|
||||
@@ -0,0 +1,883 @@
|
||||
"""Tests for MessagesTransformer: protocol event routing, whole-message fallback,
|
||||
legacy v1 chunk filtering, and end-to-end via stream_events(version="v3") / astream_events(version="v3")."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from langchain_core.language_models import GenericFakeChatModel
|
||||
from langchain_core.language_models.chat_model_stream import (
|
||||
AsyncChatModelStream,
|
||||
ChatModelStream,
|
||||
)
|
||||
from langchain_core.messages import AIMessage, AIMessageChunk
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from langgraph.constants import END, START
|
||||
from langgraph.graph import MessagesState, StateGraph
|
||||
from langgraph.stream._mux import StreamMux
|
||||
from langgraph.stream.run_stream import GraphRunStream
|
||||
from langgraph.stream.stream_channel import StreamChannel
|
||||
from langgraph.stream.transformers import MessagesTransformer, ValuesTransformer
|
||||
|
||||
TS = int(time.time() * 1000)
|
||||
|
||||
|
||||
def _unstamped(items):
|
||||
"""Strip push stamps from a StreamChannel's internal buffer."""
|
||||
return [item for _stamp, item in items]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _proto_event(
|
||||
event: dict[str, Any],
|
||||
*,
|
||||
run_id: str = "run-1",
|
||||
node: str = "llm",
|
||||
) -> dict[str, Any]:
|
||||
"""Build a messages ProtocolEvent carrying a protocol event dict (v2 path)."""
|
||||
return {
|
||||
"type": "event",
|
||||
"method": "messages",
|
||||
"params": {
|
||||
"namespace": [],
|
||||
"timestamp": TS,
|
||||
"data": (event, {"langgraph_node": node, "run_id": run_id}),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _v1_chunk(
|
||||
text: str,
|
||||
msg_id: str = "msg-1",
|
||||
*,
|
||||
finish: bool = False,
|
||||
node: str = "llm",
|
||||
) -> dict[str, Any]:
|
||||
"""Build a messages ProtocolEvent carrying a v1 AIMessageChunk tuple."""
|
||||
rm: dict[str, Any] = {"finish_reason": "stop"} if finish else {}
|
||||
return {
|
||||
"type": "event",
|
||||
"method": "messages",
|
||||
"params": {
|
||||
"namespace": [],
|
||||
"timestamp": TS,
|
||||
"data": (
|
||||
AIMessageChunk(content=text, id=msg_id, response_metadata=rm),
|
||||
{"langgraph_node": node},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _whole_msg(
|
||||
text: str,
|
||||
msg_id: str = "msg-10",
|
||||
*,
|
||||
node: str = "node",
|
||||
) -> dict[str, Any]:
|
||||
"""Build a messages ProtocolEvent carrying a completed AIMessage."""
|
||||
return {
|
||||
"type": "event",
|
||||
"method": "messages",
|
||||
"params": {
|
||||
"namespace": [],
|
||||
"timestamp": TS,
|
||||
"data": (AIMessage(content=text, id=msg_id), {"langgraph_node": node}),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _make_sync_transformer() -> tuple[
|
||||
MessagesTransformer, StreamChannel[ChatModelStream]
|
||||
]:
|
||||
t = MessagesTransformer()
|
||||
log: StreamChannel[ChatModelStream] = t.init()["messages"]
|
||||
log._bind(is_async=False)
|
||||
# Subscribe up front so pushes during process() are retained.
|
||||
log._subscribed = True
|
||||
t._bind_pump(lambda: False)
|
||||
return t, log
|
||||
|
||||
|
||||
def _make_async_transformer() -> tuple[
|
||||
MessagesTransformer, StreamChannel[ChatModelStream]
|
||||
]:
|
||||
t = MessagesTransformer()
|
||||
log: StreamChannel[ChatModelStream] = t.init()["messages"]
|
||||
log._bind(is_async=True)
|
||||
log._subscribed = True
|
||||
return t, log
|
||||
|
||||
|
||||
def _lifecycle(
|
||||
*, text: str = "hello world", message_id: str = "run-1"
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Produce a valid protocol event lifecycle: start, delta, finish."""
|
||||
half = len(text) // 2
|
||||
first, second = text[:half], text[half:]
|
||||
return [
|
||||
{"event": "message-start", "role": "ai", "message_id": message_id},
|
||||
{
|
||||
"event": "content-block-start",
|
||||
"index": 0,
|
||||
"content_block": {"type": "text", "text": ""},
|
||||
},
|
||||
{
|
||||
"event": "content-block-delta",
|
||||
"index": 0,
|
||||
"content_block": {"type": "text", "text": first},
|
||||
},
|
||||
{
|
||||
"event": "content-block-delta",
|
||||
"index": 0,
|
||||
"content_block": {"type": "text", "text": second},
|
||||
},
|
||||
{
|
||||
"event": "content-block-finish",
|
||||
"index": 0,
|
||||
"content_block": {"type": "text", "text": text},
|
||||
},
|
||||
{"event": "message-finish", "reason": "stop"},
|
||||
]
|
||||
|
||||
|
||||
def _simple_graph():
|
||||
def call_model(state: MessagesState) -> dict[str, Any]:
|
||||
model = GenericFakeChatModel(messages=iter(["hello world"]))
|
||||
stream = model.stream_events(state["messages"], version="v3")
|
||||
return {"messages": stream.output}
|
||||
|
||||
return (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("call_model", call_model)
|
||||
.add_edge(START, "call_model")
|
||||
.add_edge("call_model", END)
|
||||
.compile()
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Protocol event routing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestProtocolEventRouting:
|
||||
def test_message_start_creates_stream(self) -> None:
|
||||
t, log = _make_sync_transformer()
|
||||
t.process(
|
||||
_proto_event(
|
||||
{"event": "message-start", "role": "ai", "message_id": "run-1"},
|
||||
run_id="run-1",
|
||||
)
|
||||
)
|
||||
log.close()
|
||||
(stream,) = _unstamped(log._items)
|
||||
assert isinstance(stream, ChatModelStream)
|
||||
assert stream.message_id == "run-1"
|
||||
|
||||
def test_full_lifecycle_yields_done_stream(self) -> None:
|
||||
t, log = _make_sync_transformer()
|
||||
for evt in _lifecycle(text="hello world"):
|
||||
t.process(_proto_event(evt, run_id="run-1"))
|
||||
log.close()
|
||||
(stream,) = _unstamped(log._items)
|
||||
assert stream.done
|
||||
assert stream.output.text == "hello world"
|
||||
|
||||
def test_message_finish_cleans_up_routing(self) -> None:
|
||||
t, log = _make_sync_transformer()
|
||||
for evt in _lifecycle():
|
||||
t.process(_proto_event(evt, run_id="run-1"))
|
||||
assert t._by_run == {}
|
||||
|
||||
def test_events_without_prior_start_are_ignored(self) -> None:
|
||||
t, log = _make_sync_transformer()
|
||||
t.process(
|
||||
_proto_event(
|
||||
{
|
||||
"event": "content-block-delta",
|
||||
"index": 0,
|
||||
"content_block": {"type": "text", "text": "orphan"},
|
||||
},
|
||||
run_id="unknown",
|
||||
)
|
||||
)
|
||||
log.close()
|
||||
assert _unstamped(log._items) == []
|
||||
|
||||
def test_concurrent_streams_routed_by_run_id(self) -> None:
|
||||
t, log = _make_sync_transformer()
|
||||
life_a = _lifecycle(text="aaaa", message_id="run-a")
|
||||
life_b = _lifecycle(text="bbbb", message_id="run-b")
|
||||
for a, b in zip(life_a, life_b):
|
||||
t.process(_proto_event(a, run_id="run-a"))
|
||||
t.process(_proto_event(b, run_id="run-b"))
|
||||
log.close()
|
||||
streams = _unstamped(log._items)
|
||||
assert len(streams) == 2
|
||||
by_id = {s.message_id: s for s in streams}
|
||||
assert by_id["run-a"].output.text == "aaaa"
|
||||
assert by_id["run-b"].output.text == "bbbb"
|
||||
|
||||
def test_text_deltas_accumulated_on_stream(self) -> None:
|
||||
t, log = _make_sync_transformer()
|
||||
for evt in _lifecycle(text="abcdef"):
|
||||
t.process(_proto_event(evt))
|
||||
log.close()
|
||||
(stream,) = _unstamped(log._items)
|
||||
assert "".join(stream._text_proj._deltas) == "abcdef"
|
||||
|
||||
def test_stream_pushed_on_message_start_not_finish(self) -> None:
|
||||
# Consumer can see the stream before message-finish arrives.
|
||||
t, log = _make_sync_transformer()
|
||||
t.process(
|
||||
_proto_event(
|
||||
{"event": "message-start", "role": "ai", "message_id": "run-1"},
|
||||
run_id="run-1",
|
||||
)
|
||||
)
|
||||
assert len(log._items) == 1
|
||||
|
||||
def test_node_metadata_set_on_stream(self) -> None:
|
||||
t, log = _make_sync_transformer()
|
||||
t.process(
|
||||
_proto_event(
|
||||
{"event": "message-start", "role": "ai", "message_id": "run-1"},
|
||||
run_id="run-1",
|
||||
node="my_llm",
|
||||
)
|
||||
)
|
||||
(stream,) = _unstamped(log._items)
|
||||
assert stream.node == "my_llm"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Whole-message fallback
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestWholeMessageFallback:
|
||||
def test_whole_ai_message_produces_complete_stream(self) -> None:
|
||||
t, log = _make_sync_transformer()
|
||||
t.process(_whole_msg("the full answer"))
|
||||
log.close()
|
||||
(stream,) = _unstamped(log._items)
|
||||
assert stream.done
|
||||
assert stream.output.text == "the full answer"
|
||||
|
||||
def test_whole_message_has_full_lifecycle(self) -> None:
|
||||
t, log = _make_sync_transformer()
|
||||
t.process(_whole_msg("full"))
|
||||
log.close()
|
||||
(stream,) = _unstamped(log._items)
|
||||
assert [e["event"] for e in stream._events] == [
|
||||
"message-start",
|
||||
"content-block-start",
|
||||
"content-block-delta",
|
||||
"content-block-finish",
|
||||
"message-finish",
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Filtering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFiltering:
|
||||
def test_non_messages_events_pass_through(self) -> None:
|
||||
t, _ = _make_sync_transformer()
|
||||
assert (
|
||||
t.process(
|
||||
{
|
||||
"type": "event",
|
||||
"method": "values",
|
||||
"params": {"namespace": [], "timestamp": TS, "data": {"x": 1}},
|
||||
}
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
def test_subgraph_namespace_dropped(self) -> None:
|
||||
t, log = _make_sync_transformer()
|
||||
t.process(
|
||||
{
|
||||
"type": "event",
|
||||
"method": "messages",
|
||||
"params": {
|
||||
"namespace": ["subgraph"],
|
||||
"timestamp": TS,
|
||||
"data": (
|
||||
{"event": "message-start", "message_id": "run-x"},
|
||||
{"run_id": "run-x"},
|
||||
),
|
||||
},
|
||||
}
|
||||
)
|
||||
log.close()
|
||||
assert _unstamped(log._items) == []
|
||||
|
||||
def test_legacy_v1_chunks_ignored(self) -> None:
|
||||
# v1 AIMessageChunk tuples (from on_llm_new_token) are not streamed
|
||||
# into this projection; callers must migrate to stream_events(version="v3").
|
||||
t, log = _make_sync_transformer()
|
||||
t.process(_v1_chunk("hello"))
|
||||
t.process(_v1_chunk(" world", finish=True))
|
||||
log.close()
|
||||
assert _unstamped(log._items) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle: fail / finalize
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLifecycle:
|
||||
def test_fail_propagates_to_open_streams(self) -> None:
|
||||
t, log = _make_sync_transformer()
|
||||
t.process(
|
||||
_proto_event(
|
||||
{"event": "message-start", "message_id": "run-1"}, run_id="run-1"
|
||||
)
|
||||
)
|
||||
streams = _unstamped(log._items)
|
||||
err = RuntimeError("graph died")
|
||||
t.fail(err)
|
||||
assert t._by_run == {}
|
||||
assert streams[0]._error is err
|
||||
|
||||
def test_finalize_clears_routing_state(self) -> None:
|
||||
t, _ = _make_sync_transformer()
|
||||
t.process(
|
||||
_proto_event(
|
||||
{"event": "message-start", "message_id": "run-1"}, run_id="run-1"
|
||||
)
|
||||
)
|
||||
assert "run-1" in t._by_run
|
||||
t.finalize()
|
||||
assert t._by_run == {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Async mode
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAsyncMode:
|
||||
def test_async_mode_creates_async_stream(self) -> None:
|
||||
t, log = _make_async_transformer()
|
||||
for evt in _lifecycle(text="async stream"):
|
||||
t.process(_proto_event(evt))
|
||||
assert isinstance(_unstamped(log._items)[0], AsyncChatModelStream)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_text_projection_yields_deltas(self) -> None:
|
||||
t, log = _make_async_transformer()
|
||||
for evt in _lifecycle(text="hello world"):
|
||||
t.process(_proto_event(evt))
|
||||
(stream,) = _unstamped(log._items)
|
||||
assert isinstance(stream, AsyncChatModelStream)
|
||||
assert "".join([d async for d in stream.text]) == "hello world"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_output_awaitable(self) -> None:
|
||||
t, log = _make_async_transformer()
|
||||
for evt in _lifecycle(text="async"):
|
||||
t.process(_proto_event(evt))
|
||||
(stream,) = _unstamped(log._items)
|
||||
assert (await stream.output).text == "async"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GraphRunStream integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestWireRequestMore:
|
||||
def test_bind_pump_called_on_wire(self) -> None:
|
||||
values_t = ValuesTransformer()
|
||||
messages_t = MessagesTransformer()
|
||||
mux = StreamMux([values_t, messages_t], is_async=False)
|
||||
|
||||
assert messages_t._pump_fn is None
|
||||
run = GraphRunStream(iter([]), mux)
|
||||
assert messages_t._pump_fn is not None
|
||||
assert messages_t._pump_fn() is False
|
||||
assert run._exhausted
|
||||
|
||||
def test_created_streams_have_request_more(self) -> None:
|
||||
values_t = ValuesTransformer()
|
||||
messages_t = MessagesTransformer()
|
||||
mux = StreamMux([values_t, messages_t], is_async=False)
|
||||
GraphRunStream(iter([]), mux)
|
||||
|
||||
log: StreamChannel[ChatModelStream] = mux.extensions["messages"]
|
||||
log._subscribed = True
|
||||
for evt in _lifecycle():
|
||||
messages_t.process(_proto_event(evt))
|
||||
|
||||
(stream,) = _unstamped(log._items)
|
||||
assert stream._request_more is messages_t._pump_fn
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# End-to-end via StreamMux
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestViaMux:
|
||||
def _make_mux(
|
||||
self,
|
||||
) -> tuple[MessagesTransformer, StreamMux, StreamChannel[ChatModelStream]]:
|
||||
t = MessagesTransformer()
|
||||
v = ValuesTransformer()
|
||||
mux = StreamMux([v, t], is_async=False)
|
||||
t._bind_pump(lambda: False)
|
||||
log: StreamChannel[ChatModelStream] = mux.extensions["messages"]
|
||||
log._subscribed = True
|
||||
return t, mux, log
|
||||
|
||||
def test_streaming_via_mux(self) -> None:
|
||||
t, mux, log = self._make_mux()
|
||||
for evt in _lifecycle(text="mux stream"):
|
||||
mux.push(_proto_event(evt))
|
||||
mux.close()
|
||||
(stream,) = _unstamped(log._items)
|
||||
assert stream.output.text == "mux stream"
|
||||
|
||||
def test_whole_message_via_mux(self) -> None:
|
||||
t, mux, log = self._make_mux()
|
||||
mux.push(_whole_msg("result"))
|
||||
mux.close()
|
||||
(stream,) = _unstamped(log._items)
|
||||
assert stream.output.text == "result"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_async_streaming_via_mux(self) -> None:
|
||||
t = MessagesTransformer()
|
||||
v = ValuesTransformer()
|
||||
mux = StreamMux([v, t], is_async=True)
|
||||
log: StreamChannel[ChatModelStream] = mux.extensions["messages"]
|
||||
log._subscribed = True
|
||||
|
||||
for evt in _lifecycle(text="async mux"):
|
||||
await mux.apush(_proto_event(evt))
|
||||
|
||||
(stream,) = _unstamped(log._items)
|
||||
assert (await stream.output).text == "async mux"
|
||||
await mux.aclose()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# End-to-end: graph → stream_events(version="v3") → run.messages (node calls stream_events)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEndToEnd:
|
||||
"""stream_events(version="v3") path: node calls model.stream_events() explicitly."""
|
||||
|
||||
def test_node_calling_stream_v2_populates_messages(self) -> None:
|
||||
model = GenericFakeChatModel(messages=iter(["hello world"]))
|
||||
|
||||
def call_model(state: MessagesState) -> dict[str, Any]:
|
||||
stream = model.stream_events(state["messages"], version="v3")
|
||||
return {"messages": stream.output}
|
||||
|
||||
graph = (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("call_model", call_model)
|
||||
.add_edge(START, "call_model")
|
||||
.add_edge("call_model", END)
|
||||
.compile()
|
||||
)
|
||||
|
||||
run = graph.stream_events({"messages": "hi"}, version="v3")
|
||||
(stream,) = list(run.messages)
|
||||
assert isinstance(stream, ChatModelStream)
|
||||
assert stream.output.text == "hello world"
|
||||
|
||||
def test_node_stream_v2_text_deltas_iterate(self) -> None:
|
||||
"""Consumer can iterate `.text` on the streamed message in real time."""
|
||||
model = GenericFakeChatModel(messages=iter(["streamed answer"]))
|
||||
|
||||
def call_model(state: MessagesState) -> dict[str, Any]:
|
||||
stream = model.stream_events(state["messages"], version="v3")
|
||||
return {"messages": stream.output}
|
||||
|
||||
graph = (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("call_model", call_model)
|
||||
.add_edge(START, "call_model")
|
||||
.add_edge("call_model", END)
|
||||
.compile()
|
||||
)
|
||||
|
||||
run = graph.stream_events({"messages": "go"}, version="v3")
|
||||
(stream,) = list(run.messages)
|
||||
assert "".join(stream.text) == "streamed answer"
|
||||
|
||||
def test_non_llm_message_returned_from_node(self) -> None:
|
||||
"""Whole-message fallback: node returns a finalized AIMessage directly."""
|
||||
|
||||
def return_message(state: MessagesState) -> dict[str, Any]:
|
||||
return {"messages": AIMessage(content="hardcoded", id="msg-abc")}
|
||||
|
||||
graph = (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("return_message", return_message)
|
||||
.add_edge(START, "return_message")
|
||||
.add_edge("return_message", END)
|
||||
.compile()
|
||||
)
|
||||
|
||||
run = graph.stream_events({"messages": "hi"}, version="v3")
|
||||
(stream,) = list(run.messages)
|
||||
assert stream.output.text == "hardcoded"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_async_node_calling_astream_v2(self) -> None:
|
||||
model = GenericFakeChatModel(messages=iter(["async answer"]))
|
||||
|
||||
async def call_model(state: MessagesState) -> dict[str, Any]:
|
||||
stream = await model.astream_events(state["messages"], version="v3")
|
||||
return {"messages": await stream}
|
||||
|
||||
graph = (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("call_model", call_model)
|
||||
.add_edge(START, "call_model")
|
||||
.add_edge("call_model", END)
|
||||
.compile()
|
||||
)
|
||||
|
||||
run = await graph.astream_events({"messages": "hi"}, version="v3")
|
||||
streams = [s async for s in run.messages]
|
||||
assert len(streams) == 1
|
||||
assert isinstance(streams[0], AsyncChatModelStream)
|
||||
assert (await streams[0].output).text == "async answer"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_nested_async_iteration_yields_text_deltas(self) -> None:
|
||||
"""Inner stream.text drives the shared graph pump via the async pump binding."""
|
||||
import asyncio
|
||||
|
||||
model = GenericFakeChatModel(messages=iter(["hello world"]))
|
||||
|
||||
async def call_model(state: MessagesState) -> dict[str, Any]:
|
||||
stream = await model.astream_events(state["messages"], version="v3")
|
||||
return {"messages": await stream}
|
||||
|
||||
graph = (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("call_model", call_model)
|
||||
.add_edge(START, "call_model")
|
||||
.add_edge("call_model", END)
|
||||
.compile()
|
||||
)
|
||||
|
||||
run = await graph.astream_events({"messages": "hi"}, version="v3")
|
||||
|
||||
async def consume() -> list[str]:
|
||||
collected: list[str] = []
|
||||
async for stream in run.messages:
|
||||
async for delta in stream.text:
|
||||
collected.append(delta)
|
||||
return collected
|
||||
|
||||
assert "".join(await asyncio.wait_for(consume(), timeout=2.0)) == "hello world"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# End-to-end: graph → stream_events(version="v3") → run.messages (node calls invoke)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEndToEndV2Invoke:
|
||||
"""Auto-routing path: stream_events(version="v3") injects CONFIG_KEY_STREAM_MESSAGES_V2,
|
||||
causing BaseChatModel to drive the v2 protocol event generator even for
|
||||
model.invoke()."""
|
||||
|
||||
def _graph(self, model):
|
||||
def call_model(state: MessagesState) -> dict[str, Any]:
|
||||
return {"messages": model.invoke(state["messages"])}
|
||||
|
||||
return (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("call_model", call_model)
|
||||
.add_edge(START, "call_model")
|
||||
.add_edge("call_model", END)
|
||||
.compile()
|
||||
)
|
||||
|
||||
def test_invoke_populates_messages(self) -> None:
|
||||
run = self._graph(
|
||||
GenericFakeChatModel(messages=iter(["hello world"]))
|
||||
).stream_events({"messages": "hi"}, version="v3")
|
||||
(stream,) = list(run.messages)
|
||||
assert isinstance(stream, ChatModelStream)
|
||||
assert stream.output.text == "hello world"
|
||||
|
||||
def test_invoke_emits_protocol_events(self) -> None:
|
||||
"""Iterating the stream yields the full v2 lifecycle, not v1 chunks."""
|
||||
run = self._graph(
|
||||
GenericFakeChatModel(messages=iter(["streamed answer"]))
|
||||
).stream_events({"messages": "go"}, version="v3")
|
||||
(stream,) = list(run.messages)
|
||||
|
||||
events = list(stream)
|
||||
event_types = [e.get("event") for e in events]
|
||||
assert "message-start" in event_types
|
||||
assert "content-block-start" in event_types
|
||||
assert "content-block-delta" in event_types
|
||||
assert "content-block-finish" in event_types
|
||||
assert "message-finish" in event_types
|
||||
# Sanity: every event is a dict carrying an "event" key — not an
|
||||
# AIMessageChunk tuple from the v1 path.
|
||||
for event in events:
|
||||
assert isinstance(event, dict)
|
||||
assert "event" in event
|
||||
# Typed projection still assembles the final text.
|
||||
assert stream.output.text == "streamed answer"
|
||||
|
||||
def test_invoke_text_deltas_iterate(self) -> None:
|
||||
run = self._graph(
|
||||
GenericFakeChatModel(messages=iter(["delta streaming works"]))
|
||||
).stream_events({"messages": "hi"}, version="v3")
|
||||
(stream,) = list(run.messages)
|
||||
assert "".join(stream.text) == "delta streaming works"
|
||||
|
||||
def test_invoke_two_nodes_two_streams(self) -> None:
|
||||
model_a = GenericFakeChatModel(messages=iter(["alpha"]))
|
||||
model_b = GenericFakeChatModel(messages=iter(["beta"]))
|
||||
|
||||
def node_a(state: MessagesState) -> dict[str, Any]:
|
||||
return {"messages": model_a.invoke(state["messages"])}
|
||||
|
||||
def node_b(state: MessagesState) -> dict[str, Any]:
|
||||
return {"messages": model_b.invoke(state["messages"])}
|
||||
|
||||
graph = (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("node_a", node_a)
|
||||
.add_node("node_b", node_b)
|
||||
.add_edge(START, "node_a")
|
||||
.add_edge("node_a", "node_b")
|
||||
.add_edge("node_b", END)
|
||||
.compile()
|
||||
)
|
||||
|
||||
streams = list(graph.stream_events({"messages": "hi"}, version="v3").messages)
|
||||
assert len(streams) == 2
|
||||
assert {s.output.text for s in streams} == {"alpha", "beta"}
|
||||
|
||||
def test_invoke_plus_constructed_message_two_streams(self) -> None:
|
||||
"""Live-streamed node + constructed-message node → two ChatModelStreams."""
|
||||
model = GenericFakeChatModel(messages=iter(["live stream"]))
|
||||
|
||||
def streaming_node(state: MessagesState) -> dict[str, Any]:
|
||||
return {"messages": model.invoke(state["messages"])}
|
||||
|
||||
def constructed_node(state: MessagesState) -> dict[str, Any]:
|
||||
return {"messages": [AIMessage(content="hardcoded", id="constructed-1")]}
|
||||
|
||||
graph = (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("streaming_node", streaming_node)
|
||||
.add_node("constructed_node", constructed_node)
|
||||
.add_edge(START, "streaming_node")
|
||||
.add_edge("streaming_node", "constructed_node")
|
||||
.add_edge("constructed_node", END)
|
||||
.compile()
|
||||
)
|
||||
|
||||
run = graph.stream_events({"messages": "hi"}, version="v3")
|
||||
streams = list(run.messages)
|
||||
assert len(streams) == 2
|
||||
assert streams[0].node == "streaming_node"
|
||||
assert streams[0].output.text == "live stream"
|
||||
assert streams[1].node == "constructed_node"
|
||||
assert streams[1].output.text == "hardcoded"
|
||||
assert streams[1].message_id == "constructed-1"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_ainvoke_populates_messages(self) -> None:
|
||||
model = GenericFakeChatModel(messages=iter(["async invoke"]))
|
||||
|
||||
async def call_model(state: MessagesState) -> dict[str, Any]:
|
||||
return {"messages": await model.ainvoke(state["messages"])}
|
||||
|
||||
graph = (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("call_model", call_model)
|
||||
.add_edge(START, "call_model")
|
||||
.add_edge("call_model", END)
|
||||
.compile()
|
||||
)
|
||||
|
||||
run = await graph.astream_events({"messages": "hi"}, version="v3")
|
||||
streams = [s async for s in run.messages]
|
||||
assert len(streams) == 1
|
||||
assert isinstance(streams[0], AsyncChatModelStream)
|
||||
assert (await streams[0].output).text == "async invoke"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Regression: direct stream_mode="messages" must stay v1
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDirectMessagesModeStaysV1:
|
||||
def test_direct_graph_stream_messages_yields_ai_message_chunks(self) -> None:
|
||||
"""graph.stream(stream_mode="messages") must not leak v2 event dicts —
|
||||
the v2 flag is only injected by stream_events(version="v3") / astream_events(version="v3")."""
|
||||
model = GenericFakeChatModel(messages=iter(["legacy path"]))
|
||||
|
||||
def call_model(state: MessagesState) -> dict[str, Any]:
|
||||
return {"messages": model.invoke(state["messages"])}
|
||||
|
||||
graph = (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("call_model", call_model)
|
||||
.add_edge(START, "call_model")
|
||||
.add_edge("call_model", END)
|
||||
.compile()
|
||||
)
|
||||
|
||||
parts = list(graph.stream({"messages": "hi"}, stream_mode="messages"))
|
||||
assert parts, "expected stream_mode='messages' to emit tuples"
|
||||
for payload, _metadata in parts:
|
||||
assert isinstance(payload, AIMessageChunk)
|
||||
assert (
|
||||
"".join(p[0].content for p in parts if isinstance(p[0].content, str))
|
||||
== "legacy path"
|
||||
)
|
||||
|
||||
def test_nested_graph_stream_messages_stays_v1_under_outer_stream_events_v3(
|
||||
self,
|
||||
) -> None:
|
||||
"""An outer `stream_events(version="v3")` run must not flip an inner direct
|
||||
`stream_mode="messages"` call onto the v2 event protocol."""
|
||||
model = GenericFakeChatModel(messages=iter(["nested legacy path"]))
|
||||
|
||||
def call_model(state: MessagesState) -> dict[str, Any]:
|
||||
return {"messages": model.invoke(state["messages"])}
|
||||
|
||||
inner = (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("call_model", call_model)
|
||||
.add_edge(START, "call_model")
|
||||
.add_edge("call_model", END)
|
||||
.compile()
|
||||
)
|
||||
|
||||
class OuterState(TypedDict, total=False):
|
||||
saw_only_chunks: bool
|
||||
first_payload_type: str
|
||||
text: str
|
||||
|
||||
def call_subgraph(state: OuterState, config: RunnableConfig) -> dict[str, Any]:
|
||||
parts = list(
|
||||
inner.stream(
|
||||
{"messages": "hi"},
|
||||
config,
|
||||
stream_mode="messages",
|
||||
)
|
||||
)
|
||||
assert parts
|
||||
payloads = [payload for payload, _metadata in parts]
|
||||
return {
|
||||
"saw_only_chunks": all(
|
||||
isinstance(payload, AIMessageChunk) for payload in payloads
|
||||
),
|
||||
"first_payload_type": type(payloads[0]).__name__,
|
||||
"text": "".join(
|
||||
payload.content
|
||||
for payload in payloads
|
||||
if isinstance(payload, AIMessageChunk)
|
||||
and isinstance(payload.content, str)
|
||||
),
|
||||
}
|
||||
|
||||
outer = (
|
||||
StateGraph(OuterState)
|
||||
.add_node("call_subgraph", call_subgraph)
|
||||
.add_edge(START, "call_subgraph")
|
||||
.add_edge("call_subgraph", END)
|
||||
.compile()
|
||||
)
|
||||
|
||||
result = outer.stream_events({}, version="v3").output
|
||||
|
||||
assert result is not None
|
||||
assert result["saw_only_chunks"] is True
|
||||
assert result["first_payload_type"] == "AIMessageChunk"
|
||||
assert result["text"] == "nested legacy path"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# StreamMessagesHandlerV2 unit
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStreamMessagesHandlerV2Unit:
|
||||
def test_on_llm_new_token_is_noop(self) -> None:
|
||||
"""v2 handler must not emit v1 chunks even when on_llm_new_token fires."""
|
||||
from uuid import uuid4
|
||||
|
||||
from langchain_core.outputs import ChatGenerationChunk
|
||||
|
||||
from langgraph.pregel._messages import StreamMessagesHandlerV2
|
||||
|
||||
emitted: list[Any] = []
|
||||
handler = StreamMessagesHandlerV2(emitted.append, subgraphs=False)
|
||||
run_id = uuid4()
|
||||
handler.metadata[run_id] = ((), {"langgraph_node": "x"})
|
||||
|
||||
handler.on_llm_new_token(
|
||||
"hello",
|
||||
chunk=ChatGenerationChunk(message=AIMessageChunk(content="hello")),
|
||||
run_id=run_id,
|
||||
)
|
||||
|
||||
assert emitted == []
|
||||
|
||||
def test_on_llm_end_dedupes_when_final_message_id_differs(self) -> None:
|
||||
"""A streamed v2 message should not be emitted again from the final
|
||||
AIMessage fallback when its final id does not match `message-start`."""
|
||||
from uuid import uuid4
|
||||
|
||||
from langchain_core.outputs import ChatGeneration, LLMResult
|
||||
|
||||
from langgraph.pregel._messages import StreamMessagesHandlerV2
|
||||
|
||||
emitted: list[Any] = []
|
||||
handler = StreamMessagesHandlerV2(emitted.append, subgraphs=False)
|
||||
run_id = uuid4()
|
||||
handler.metadata[run_id] = ((), {"langgraph_node": "x"})
|
||||
|
||||
handler.on_stream_event(
|
||||
{"event": "message-start", "message_id": "stream-msg-1"},
|
||||
run_id=run_id,
|
||||
)
|
||||
handler.on_llm_end(
|
||||
LLMResult(
|
||||
generations=[
|
||||
[
|
||||
ChatGeneration(
|
||||
message=AIMessage(content="hello", id="final-msg-1")
|
||||
)
|
||||
]
|
||||
]
|
||||
),
|
||||
run_id=run_id,
|
||||
)
|
||||
|
||||
assert len(emitted) == 1
|
||||
@@ -0,0 +1,856 @@
|
||||
"""Tests for SubgraphTransformer.
|
||||
|
||||
Subscribes to `tasks` events and produces in-process `SubgraphRunStream`
|
||||
handles backed by mini-muxes (built via `StreamMux._make_child`). The
|
||||
synthetic-event tests isolate the inference / mini-mux wiring; the
|
||||
real-graph tests exercise the end-to-end navigation path through
|
||||
`stream_events(version="v3")`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import operator
|
||||
import time
|
||||
from collections.abc import AsyncIterator
|
||||
from functools import partial
|
||||
from typing import Annotated, Any
|
||||
|
||||
import pytest
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from langgraph.constants import END, START
|
||||
from langgraph.errors import GraphInterrupt
|
||||
from langgraph.graph import StateGraph
|
||||
from langgraph.pregel.main import _normalize_stream_transformer_factories
|
||||
from langgraph.stream._mux import StreamMux
|
||||
from langgraph.stream._types import ProtocolEvent, StreamTransformer
|
||||
from langgraph.stream.run_stream import (
|
||||
AsyncGraphRunStream,
|
||||
AsyncSubgraphRunStream,
|
||||
GraphRunStream,
|
||||
SubgraphRunStream,
|
||||
)
|
||||
from langgraph.stream.transformers import (
|
||||
LifecycleTransformer,
|
||||
MessagesTransformer,
|
||||
SubgraphTransformer,
|
||||
ValuesTransformer,
|
||||
)
|
||||
|
||||
TS = int(time.time() * 1000)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _tasks_start(
|
||||
namespace: list[str],
|
||||
*,
|
||||
task_id: str,
|
||||
name: str,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "event",
|
||||
"method": "tasks",
|
||||
"params": {
|
||||
"namespace": namespace,
|
||||
"timestamp": TS,
|
||||
"data": {
|
||||
"id": task_id,
|
||||
"name": name,
|
||||
"input": None,
|
||||
"triggers": [],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _tasks_result(
|
||||
namespace: list[str],
|
||||
*,
|
||||
task_id: str,
|
||||
name: str,
|
||||
error: str | None = None,
|
||||
interrupts: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "event",
|
||||
"method": "tasks",
|
||||
"params": {
|
||||
"namespace": namespace,
|
||||
"timestamp": TS,
|
||||
"data": {
|
||||
"id": task_id,
|
||||
"name": name,
|
||||
"error": error,
|
||||
"interrupts": interrupts or [],
|
||||
"result": {},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _native_factories() -> list[Any]:
|
||||
"""Mirror the factory list `Pregel.stream_events(version="v3")` registers."""
|
||||
return [
|
||||
ValuesTransformer,
|
||||
MessagesTransformer,
|
||||
LifecycleTransformer,
|
||||
SubgraphTransformer,
|
||||
]
|
||||
|
||||
|
||||
def _stream_part(
|
||||
method: str,
|
||||
namespace: tuple[str, ...],
|
||||
data: Any,
|
||||
) -> dict[str, Any]:
|
||||
return {"type": method, "ns": namespace, "data": data}
|
||||
|
||||
|
||||
async def _astream_parts(*parts: dict[str, Any]) -> AsyncIterator[dict[str, Any]]:
|
||||
for part in parts:
|
||||
yield part
|
||||
|
||||
|
||||
def _arm(mux: StreamMux) -> None:
|
||||
"""Pre-subscribe every projection in the mux so synthetic pushes accumulate.
|
||||
|
||||
Real consumer code subscribes by iterating the projection; tests
|
||||
inspect `_items` directly, so the lazy-subscribe gate has to be
|
||||
flipped manually before any synthetic events are pushed.
|
||||
"""
|
||||
mux._events._subscribed = True
|
||||
for value in mux.extensions.values():
|
||||
if hasattr(value, "_subscribed"):
|
||||
value._subscribed = True
|
||||
|
||||
|
||||
def _arm_recursive(mux: StreamMux) -> None:
|
||||
"""Arm `mux` and every mini-mux currently held by SubgraphTransformer handles.
|
||||
|
||||
Mini-muxes are created during `mux.push(...)` when a new direct
|
||||
child is discovered. Tests must call this after each push that
|
||||
might have created a new mini-mux so subsequent pushes' projection
|
||||
side effects accumulate (rather than dropping silently against an
|
||||
unsubscribed log).
|
||||
"""
|
||||
_arm(mux)
|
||||
for handle in _subgraph_transformer(mux)._handles.values():
|
||||
if handle._mux is not None:
|
||||
_arm_recursive(handle._mux)
|
||||
|
||||
|
||||
def _build_root_mux(*, scope: tuple[str, ...] = ()) -> StreamMux:
|
||||
mux = StreamMux(
|
||||
factories=_native_factories(),
|
||||
scope=scope,
|
||||
is_async=False,
|
||||
)
|
||||
_arm(mux)
|
||||
return mux
|
||||
|
||||
|
||||
def _subgraph_transformer(mux: StreamMux) -> SubgraphTransformer:
|
||||
transformer = mux.transformer_by_key("subgraphs")
|
||||
assert isinstance(transformer, SubgraphTransformer)
|
||||
return transformer
|
||||
|
||||
|
||||
def _unstamped(items):
|
||||
"""Strip push stamps from a StreamChannel's internal buffer."""
|
||||
return [item for _stamp, item in items]
|
||||
|
||||
|
||||
def _drain_subgraphs(mux: StreamMux) -> list[SubgraphRunStream]:
|
||||
return _unstamped(_subgraph_transformer(mux)._log._items)
|
||||
|
||||
|
||||
def _child_mux(handle: SubgraphRunStream | AsyncSubgraphRunStream) -> StreamMux:
|
||||
assert handle._mux is not None
|
||||
return handle._mux
|
||||
|
||||
|
||||
def _event_items(mux: StreamMux) -> list[ProtocolEvent]:
|
||||
return _unstamped(mux._events._items)
|
||||
|
||||
|
||||
def _lifecycle_payloads(mux: StreamMux) -> list[dict[str, Any]]:
|
||||
lifecycle_t = mux.transformer_by_key("lifecycle")
|
||||
assert isinstance(lifecycle_t, LifecycleTransformer)
|
||||
return _unstamped(lifecycle_t._channel._items)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Synthetic-event tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_handle_created_on_first_direct_child_task() -> None:
|
||||
mux = _build_root_mux()
|
||||
mux.push(_tasks_start(["agent:abc"], task_id="t1", name="tool"))
|
||||
|
||||
[handle] = _drain_subgraphs(mux)
|
||||
assert handle.path == ("agent:abc",)
|
||||
assert handle.graph_name == "agent"
|
||||
assert handle.trigger_call_id == "abc"
|
||||
assert handle.status == "started"
|
||||
_child_mux(handle) # mini-mux backed
|
||||
|
||||
|
||||
def test_handle_status_completes_on_parent_result() -> None:
|
||||
mux = _build_root_mux()
|
||||
mux.push(_tasks_start(["agent:abc"], task_id="t1", name="tool"))
|
||||
mux.push(_tasks_result([], task_id="abc", name="agent"))
|
||||
|
||||
[handle] = _drain_subgraphs(mux)
|
||||
assert handle.status == "completed"
|
||||
assert handle.error is None
|
||||
|
||||
|
||||
def test_handle_status_failed_with_error() -> None:
|
||||
mux = _build_root_mux()
|
||||
mux.push(_tasks_start(["agent:abc"], task_id="t1", name="tool"))
|
||||
mux.push(_tasks_result([], task_id="abc", name="agent", error="boom"))
|
||||
|
||||
[handle] = _drain_subgraphs(mux)
|
||||
assert handle.status == "failed"
|
||||
assert handle.error == "boom"
|
||||
|
||||
|
||||
def test_handle_status_interrupted() -> None:
|
||||
mux = _build_root_mux()
|
||||
mux.push(_tasks_start(["agent:abc"], task_id="t1", name="tool"))
|
||||
mux.push(
|
||||
_tasks_result(
|
||||
[],
|
||||
task_id="abc",
|
||||
name="agent",
|
||||
interrupts=[{"value": "pause"}],
|
||||
)
|
||||
)
|
||||
|
||||
[handle] = _drain_subgraphs(mux)
|
||||
assert handle.status == "interrupted"
|
||||
|
||||
|
||||
def test_grandchild_discovered_via_child_mini_mux() -> None:
|
||||
"""Each mini-mux owns its own scope; grandchildren live on the child handle."""
|
||||
mux = _build_root_mux()
|
||||
# Direct child started — creates the mini-mux.
|
||||
mux.push(_tasks_start(["agent:abc"], task_id="t1", name="tool"))
|
||||
# Pre-subscribe the freshly-created mini-mux so subsequent
|
||||
# forwarded events land on its projections (consumer would
|
||||
# subscribe naturally by iterating handle.subgraphs, but the
|
||||
# test inspects `_items` directly).
|
||||
_arm_recursive(mux)
|
||||
# Grandchild's first task event flows down into the child mini-mux.
|
||||
mux.push(_tasks_start(["agent:abc", "tool:def"], task_id="t2", name="deep"))
|
||||
|
||||
[child_handle] = _drain_subgraphs(mux)
|
||||
assert child_handle.path == ("agent:abc",)
|
||||
# The grandchild appears on the CHILD'S subgraphs projection.
|
||||
grandchildren = _unstamped(child_handle.subgraphs._items)
|
||||
assert len(grandchildren) == 1
|
||||
assert grandchildren[0].path == ("agent:abc", "tool:def")
|
||||
|
||||
|
||||
def test_finalize_completes_open_handles() -> None:
|
||||
mux = _build_root_mux()
|
||||
mux.push(_tasks_start(["agent:abc"], task_id="t1", name="tool"))
|
||||
|
||||
mux.close()
|
||||
[handle] = _drain_subgraphs(mux)
|
||||
assert handle.status == "completed"
|
||||
|
||||
|
||||
def test_fail_marks_open_handles_interrupted_for_graph_interrupt() -> None:
|
||||
mux = _build_root_mux()
|
||||
mux.push(_tasks_start(["agent:abc"], task_id="t1", name="tool"))
|
||||
|
||||
mux.fail(GraphInterrupt())
|
||||
[handle] = _drain_subgraphs(mux)
|
||||
assert handle.status == "interrupted"
|
||||
|
||||
|
||||
def test_fail_marks_open_handles_failed_for_other_errors() -> None:
|
||||
mux = _build_root_mux()
|
||||
mux.push(_tasks_start(["agent:abc"], task_id="t1", name="tool"))
|
||||
|
||||
mux.fail(RuntimeError("boom"))
|
||||
[handle] = _drain_subgraphs(mux)
|
||||
assert handle.status == "failed"
|
||||
assert handle.error == "boom"
|
||||
|
||||
|
||||
def test_child_mux_requires_factories() -> None:
|
||||
"""A mux constructed only from `transformers=` can't clone factories."""
|
||||
transformer = SubgraphTransformer()
|
||||
mux = StreamMux(transformers=[transformer], is_async=False)
|
||||
with pytest.raises(RuntimeError, match="factories"):
|
||||
mux._make_child(("anything",))
|
||||
|
||||
|
||||
def test_subgraph_and_lifecycle_agree_on_terminal_status() -> None:
|
||||
"""Both transformers consume the same tasks signal — no drift."""
|
||||
mux = _build_root_mux()
|
||||
mux.push(_tasks_start(["agent:abc"], task_id="t1", name="tool"))
|
||||
mux.push(_tasks_result([], task_id="abc", name="agent", error="boom"))
|
||||
|
||||
[handle] = _drain_subgraphs(mux)
|
||||
payloads = _lifecycle_payloads(mux)
|
||||
assert handle.status == "failed"
|
||||
assert payloads[-1]["event"] == "failed"
|
||||
assert handle.error == payloads[-1]["error"]
|
||||
|
||||
|
||||
def test_required_stream_modes_declared() -> None:
|
||||
assert SubgraphTransformer.required_stream_modes == ("tasks",)
|
||||
|
||||
|
||||
def test_tasks_events_suppressed_from_main_log() -> None:
|
||||
"""Tasks events are folded into discovery and don't appear on the main log."""
|
||||
mux = _build_root_mux()
|
||||
mux.push(_tasks_start(["agent:abc"], task_id="t1", name="tool"))
|
||||
mux.push(_tasks_result([], task_id="abc", name="agent"))
|
||||
|
||||
methods = [evt["method"] for evt in _event_items(mux)]
|
||||
assert "tasks" not in methods
|
||||
|
||||
|
||||
class _ChildEventObserver(StreamTransformer):
|
||||
"""Records child-scope event identity without mutating it."""
|
||||
|
||||
records: list[tuple[tuple[str, ...], int, int, bool]] = []
|
||||
|
||||
def init(self) -> dict[str, Any]:
|
||||
return {}
|
||||
|
||||
def process(self, event: ProtocolEvent) -> bool:
|
||||
if self.scope and event["method"] == "values":
|
||||
self.records.append(
|
||||
(
|
||||
self.scope,
|
||||
id(event),
|
||||
id(event["params"]["data"]),
|
||||
"seq" in event,
|
||||
)
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def test_child_forwarding_reuses_event_without_assigning_seq() -> None:
|
||||
_ChildEventObserver.records = []
|
||||
mux = StreamMux(
|
||||
factories=[
|
||||
ValuesTransformer,
|
||||
MessagesTransformer,
|
||||
LifecycleTransformer,
|
||||
SubgraphTransformer,
|
||||
_ChildEventObserver,
|
||||
],
|
||||
is_async=False,
|
||||
)
|
||||
_arm(mux)
|
||||
mux.push(_tasks_start(["agent:abc"], task_id="t1", name="tool"))
|
||||
data = {"x": 1}
|
||||
event: ProtocolEvent = {
|
||||
"type": "event",
|
||||
"method": "values",
|
||||
"params": {
|
||||
"namespace": ["agent:abc"],
|
||||
"timestamp": TS,
|
||||
"data": data,
|
||||
},
|
||||
}
|
||||
mux.push(event)
|
||||
|
||||
assert _ChildEventObserver.records == [(("agent:abc",), id(event), id(data), False)]
|
||||
[root_event] = [evt for evt in _event_items(mux) if evt["method"] == "values"]
|
||||
assert root_event is event
|
||||
assert "seq" in root_event
|
||||
|
||||
|
||||
class _AsyncProbeTransformer(StreamTransformer):
|
||||
"""Async-only transformer used to verify mini-mux async dispatch."""
|
||||
|
||||
required_stream_modes = ("tasks",)
|
||||
|
||||
def __init__(self, scope: tuple[str, ...] = ()) -> None:
|
||||
super().__init__(scope)
|
||||
self.seen: list[tuple[str, ...]] = []
|
||||
self.finalized = False
|
||||
self.failed: BaseException | None = None
|
||||
|
||||
def init(self) -> dict[str, Any]:
|
||||
return {"async_probe": self}
|
||||
|
||||
async def aprocess(self, event: ProtocolEvent) -> bool:
|
||||
self.seen.append(tuple(event["params"]["namespace"]))
|
||||
return True
|
||||
|
||||
async def afinalize(self) -> None:
|
||||
self.finalized = True
|
||||
|
||||
async def afail(self, err: BaseException) -> None:
|
||||
self.failed = err
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_async_child_mini_mux_uses_async_lane() -> None:
|
||||
mux = StreamMux(
|
||||
factories=[
|
||||
ValuesTransformer,
|
||||
MessagesTransformer,
|
||||
LifecycleTransformer,
|
||||
SubgraphTransformer,
|
||||
_AsyncProbeTransformer,
|
||||
],
|
||||
is_async=True,
|
||||
)
|
||||
await mux.apush(_tasks_start(["agent:abc"], task_id="t1", name="tool"))
|
||||
|
||||
handle = _subgraph_transformer(mux)._handles[("agent:abc",)]
|
||||
assert isinstance(handle, AsyncSubgraphRunStream)
|
||||
probe = _child_mux(handle).transformer_by_key("async_probe")
|
||||
assert isinstance(probe, _AsyncProbeTransformer)
|
||||
assert probe.seen == [("agent:abc",)]
|
||||
|
||||
await mux.apush(_tasks_result([], task_id="abc", name="agent"))
|
||||
assert probe.finalized is True
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_async_child_mini_mux_fail_uses_async_lane() -> None:
|
||||
mux = StreamMux(
|
||||
factories=[
|
||||
ValuesTransformer,
|
||||
MessagesTransformer,
|
||||
LifecycleTransformer,
|
||||
SubgraphTransformer,
|
||||
_AsyncProbeTransformer,
|
||||
],
|
||||
is_async=True,
|
||||
)
|
||||
await mux.apush(_tasks_start(["agent:abc"], task_id="t1", name="tool"))
|
||||
|
||||
handle = _subgraph_transformer(mux)._handles[("agent:abc",)]
|
||||
probe = _child_mux(handle).transformer_by_key("async_probe")
|
||||
assert isinstance(probe, _AsyncProbeTransformer)
|
||||
|
||||
err = RuntimeError("boom")
|
||||
await mux.afail(err)
|
||||
assert probe.failed is err
|
||||
|
||||
|
||||
class _StandardCtorTransformer(StreamTransformer):
|
||||
"""Transformer class that inherits the standard scoped constructor."""
|
||||
|
||||
def init(self) -> dict[str, Any]:
|
||||
return {"standard_ctor": self}
|
||||
|
||||
def process(self, event: ProtocolEvent) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
class _ScopedTransformer(StreamTransformer):
|
||||
"""Transformer class that uses the inherited scoped construction."""
|
||||
|
||||
def init(self) -> dict[str, Any]:
|
||||
return {"scoped": self}
|
||||
|
||||
def process(self, event: ProtocolEvent) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
class _ConfigurableFactoryTransformer(StreamTransformer):
|
||||
"""Transformer built by a configured per-scope factory."""
|
||||
|
||||
def __init__(self, scope: tuple[str, ...] = (), *, label: str) -> None:
|
||||
super().__init__(scope)
|
||||
self.label = label
|
||||
|
||||
def init(self) -> dict[str, Any]:
|
||||
return {"configurable": self}
|
||||
|
||||
def process(self, event: ProtocolEvent) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
class _ChildExploder(StreamTransformer):
|
||||
"""Raise from child mini-muxes to verify errors propagate upstream."""
|
||||
|
||||
def init(self) -> dict[str, Any]:
|
||||
return {}
|
||||
|
||||
def process(self, event: ProtocolEvent) -> bool:
|
||||
if self.scope and event["method"] == "values":
|
||||
raise RuntimeError("child boom")
|
||||
return True
|
||||
|
||||
|
||||
class _ChildFinalizeExploder(StreamTransformer):
|
||||
"""Raise from child mini-mux finalization."""
|
||||
|
||||
supports_sync = True
|
||||
|
||||
def init(self) -> dict[str, Any]:
|
||||
return {}
|
||||
|
||||
def process(self, event: ProtocolEvent) -> bool:
|
||||
return True
|
||||
|
||||
def finalize(self) -> None:
|
||||
if self.scope:
|
||||
raise RuntimeError("child finalize boom")
|
||||
|
||||
async def afinalize(self) -> None:
|
||||
if self.scope:
|
||||
raise RuntimeError("child afinalize boom")
|
||||
|
||||
|
||||
def test_normalize_transformer_factories_supports_scoped_classes() -> None:
|
||||
factories = _normalize_stream_transformer_factories(
|
||||
[_StandardCtorTransformer, _ScopedTransformer]
|
||||
)
|
||||
|
||||
standard_ctor = factories[0](("child",))
|
||||
scoped = factories[1](("child",))
|
||||
assert isinstance(standard_ctor, _StandardCtorTransformer)
|
||||
assert standard_ctor.scope == ("child",)
|
||||
assert isinstance(scoped, _ScopedTransformer)
|
||||
assert scoped.scope == ("child",)
|
||||
|
||||
|
||||
def test_normalize_transformer_factories_supports_configured_factories() -> None:
|
||||
factories = _normalize_stream_transformer_factories(
|
||||
[partial(_ConfigurableFactoryTransformer, label="configured")]
|
||||
)
|
||||
|
||||
built = factories[0](("child",))
|
||||
assert isinstance(built, _ConfigurableFactoryTransformer)
|
||||
assert built.label == "configured"
|
||||
assert built.scope == ("child",)
|
||||
|
||||
|
||||
def test_normalize_transformer_factories_rejects_instances() -> None:
|
||||
with pytest.raises(TypeError, match="pre-built instance"):
|
||||
_normalize_stream_transformer_factories([_StandardCtorTransformer()])
|
||||
|
||||
|
||||
def test_child_forwarding_errors_fail_sync_run() -> None:
|
||||
mux = StreamMux(
|
||||
factories=[
|
||||
ValuesTransformer,
|
||||
MessagesTransformer,
|
||||
LifecycleTransformer,
|
||||
SubgraphTransformer,
|
||||
_ChildExploder,
|
||||
],
|
||||
is_async=False,
|
||||
)
|
||||
run = GraphRunStream(
|
||||
iter(
|
||||
[
|
||||
_stream_part(
|
||||
"tasks",
|
||||
("agent:abc",),
|
||||
{
|
||||
"id": "t1",
|
||||
"name": "tool",
|
||||
"input": None,
|
||||
"triggers": [],
|
||||
},
|
||||
),
|
||||
_stream_part("values", ("agent:abc",), {"x": 1}),
|
||||
]
|
||||
),
|
||||
mux,
|
||||
)
|
||||
|
||||
handle = next(iter(run.subgraphs))
|
||||
assert handle.path == ("agent:abc",)
|
||||
with pytest.raises(RuntimeError, match="child boom"):
|
||||
_ = run.output
|
||||
assert run._mux._events._error is not None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_child_forwarding_errors_fail_async_run() -> None:
|
||||
mux = StreamMux(
|
||||
factories=[
|
||||
ValuesTransformer,
|
||||
MessagesTransformer,
|
||||
LifecycleTransformer,
|
||||
SubgraphTransformer,
|
||||
_ChildExploder,
|
||||
],
|
||||
is_async=True,
|
||||
)
|
||||
run = AsyncGraphRunStream(
|
||||
_astream_parts(
|
||||
_stream_part(
|
||||
"tasks",
|
||||
("agent:abc",),
|
||||
{
|
||||
"id": "t1",
|
||||
"name": "tool",
|
||||
"input": None,
|
||||
"triggers": [],
|
||||
},
|
||||
),
|
||||
_stream_part("values", ("agent:abc",), {"x": 1}),
|
||||
),
|
||||
mux,
|
||||
)
|
||||
|
||||
handle = await run.subgraphs.__aiter__().__anext__()
|
||||
assert handle.path == ("agent:abc",)
|
||||
with pytest.raises(RuntimeError, match="child boom"):
|
||||
await run.output()
|
||||
assert run._mux._events._error is not None
|
||||
|
||||
|
||||
def test_child_finalize_errors_propagate_to_sync_run() -> None:
|
||||
mux = StreamMux(
|
||||
factories=[
|
||||
ValuesTransformer,
|
||||
MessagesTransformer,
|
||||
LifecycleTransformer,
|
||||
SubgraphTransformer,
|
||||
_ChildFinalizeExploder,
|
||||
],
|
||||
is_async=False,
|
||||
)
|
||||
run = GraphRunStream(
|
||||
iter(
|
||||
[
|
||||
_stream_part(
|
||||
"tasks",
|
||||
("agent:abc",),
|
||||
{
|
||||
"id": "t1",
|
||||
"name": "tool",
|
||||
"input": None,
|
||||
"triggers": [],
|
||||
},
|
||||
)
|
||||
]
|
||||
),
|
||||
mux,
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="child finalize boom"):
|
||||
_ = run.output
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_child_finalize_errors_propagate_to_async_run() -> None:
|
||||
mux = StreamMux(
|
||||
factories=[
|
||||
ValuesTransformer,
|
||||
MessagesTransformer,
|
||||
LifecycleTransformer,
|
||||
SubgraphTransformer,
|
||||
_ChildFinalizeExploder,
|
||||
],
|
||||
is_async=True,
|
||||
)
|
||||
run = AsyncGraphRunStream(
|
||||
_astream_parts(
|
||||
_stream_part(
|
||||
"tasks",
|
||||
("agent:abc",),
|
||||
{
|
||||
"id": "t1",
|
||||
"name": "tool",
|
||||
"input": None,
|
||||
"triggers": [],
|
||||
},
|
||||
)
|
||||
),
|
||||
mux,
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="child afinalize boom"):
|
||||
await run.output()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# End-to-end real-graph tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _State(TypedDict):
|
||||
value: str
|
||||
items: Annotated[list[str], operator.add]
|
||||
|
||||
|
||||
def _passthrough(state: _State) -> dict[str, Any]:
|
||||
return {"value": state["value"] + "!", "items": ["x"]}
|
||||
|
||||
|
||||
def _make_two_level_nested() -> Any:
|
||||
"""outer → middle → inner. Three Pregel instances, two nesting levels."""
|
||||
inner_b: StateGraph = StateGraph(_State, input_schema=_State)
|
||||
inner_b.add_node("inner_node", _passthrough)
|
||||
inner_b.add_edge(START, "inner_node")
|
||||
inner_b.add_edge("inner_node", END)
|
||||
inner = inner_b.compile()
|
||||
|
||||
middle_b: StateGraph = StateGraph(_State, input_schema=_State)
|
||||
middle_b.add_node("inner", inner)
|
||||
middle_b.add_edge(START, "inner")
|
||||
middle_b.add_edge("inner", END)
|
||||
middle = middle_b.compile()
|
||||
|
||||
outer_b: StateGraph = StateGraph(_State, input_schema=_State)
|
||||
outer_b.add_node("middle", middle)
|
||||
outer_b.add_edge(START, "middle")
|
||||
outer_b.add_edge("middle", END)
|
||||
return outer_b.compile()
|
||||
|
||||
|
||||
def _item_node(item: str):
|
||||
def node(state: _State) -> dict[str, Any]:
|
||||
return {"items": [item]}
|
||||
|
||||
return node
|
||||
|
||||
|
||||
def _make_two_sibling_subgraphs() -> Any:
|
||||
"""outer → one → two, where both nodes are compiled subgraphs."""
|
||||
one_b: StateGraph = StateGraph(_State, input_schema=_State)
|
||||
one_b.add_node("add_one", _item_node("one"))
|
||||
one_b.add_edge(START, "add_one")
|
||||
one_b.add_edge("add_one", END)
|
||||
one = one_b.compile()
|
||||
|
||||
two_b: StateGraph = StateGraph(_State, input_schema=_State)
|
||||
two_b.add_node("add_two", _item_node("two"))
|
||||
two_b.add_edge(START, "add_two")
|
||||
two_b.add_edge("add_two", END)
|
||||
two = two_b.compile()
|
||||
|
||||
outer_b: StateGraph = StateGraph(_State, input_schema=_State)
|
||||
outer_b.add_node("one", one)
|
||||
outer_b.add_node("two", two)
|
||||
outer_b.add_edge(START, "one")
|
||||
outer_b.add_edge("one", "two")
|
||||
outer_b.add_edge("two", END)
|
||||
return outer_b.compile()
|
||||
|
||||
|
||||
def _failing_node(state: _State) -> dict[str, Any]:
|
||||
raise ValueError("child boom")
|
||||
|
||||
|
||||
def _make_failing_nested() -> Any:
|
||||
inner_b: StateGraph = StateGraph(_State, input_schema=_State)
|
||||
inner_b.add_node("fail", _failing_node)
|
||||
inner_b.add_edge(START, "fail")
|
||||
inner_b.add_edge("fail", END)
|
||||
inner = inner_b.compile()
|
||||
|
||||
outer_b: StateGraph = StateGraph(_State, input_schema=_State)
|
||||
outer_b.add_node("inner", inner)
|
||||
outer_b.add_edge(START, "inner")
|
||||
outer_b.add_edge("inner", END)
|
||||
return outer_b.compile()
|
||||
|
||||
|
||||
def test_stream_events_v3_real_graph_yields_subgraph_handles() -> None:
|
||||
"""Iterating `run.subgraphs` yields handles for direct-child subgraphs."""
|
||||
graph = _make_two_level_nested()
|
||||
run = graph.stream_events({"value": "x", "items": []}, version="v3")
|
||||
|
||||
handle_paths: list[tuple[str, ...]] = []
|
||||
final_status: dict[tuple[str, ...], str] = {}
|
||||
for handle in run.subgraphs:
|
||||
# Drill into the handle's projections inside the loop body so
|
||||
# the mini-mux is subscribed before the next pump cycle.
|
||||
list(handle.values)
|
||||
handle_paths.append(handle.path)
|
||||
final_status[handle.path] = handle.status
|
||||
|
||||
assert len(handle_paths) == 1
|
||||
assert handle_paths[0][0].startswith("middle:")
|
||||
assert final_status[handle_paths[0]] == "completed"
|
||||
|
||||
|
||||
def test_stream_events_v3_grandchild_visible_on_child_handle() -> None:
|
||||
"""Drilling into `handle.subgraphs` surfaces nested grandchildren."""
|
||||
graph = _make_two_level_nested()
|
||||
run = graph.stream_events({"value": "x", "items": []}, version="v3")
|
||||
|
||||
grandchild_paths: list[tuple[str, ...]] = []
|
||||
middle_path: tuple[str, ...] | None = None
|
||||
for middle_handle in run.subgraphs:
|
||||
# Subscribe to grandchildren before the next pump cycle.
|
||||
for inner_handle in middle_handle.subgraphs:
|
||||
# Subscribe to inner.values so its mini-mux drains.
|
||||
list(inner_handle.values)
|
||||
grandchild_paths.append(inner_handle.path)
|
||||
middle_path = middle_handle.path
|
||||
|
||||
assert middle_path is not None
|
||||
assert len(grandchild_paths) == 1
|
||||
inner_path = grandchild_paths[0]
|
||||
assert inner_path[1].startswith("inner:")
|
||||
assert inner_path[: len(middle_path)] == middle_path
|
||||
|
||||
|
||||
def test_subgraph_output_stops_at_own_terminal_without_draining_siblings() -> None:
|
||||
"""A handle's `output` must not pump past its terminal event.
|
||||
|
||||
If it over-pumps the root run, the second sibling handle is yielded
|
||||
only after it has already completed, so subscribing to `values`
|
||||
inside the loop body misses its events.
|
||||
"""
|
||||
graph = _make_two_sibling_subgraphs()
|
||||
run = graph.stream_events({"value": "x", "items": []}, version="v3")
|
||||
|
||||
paths: list[tuple[str, ...]] = []
|
||||
second_values: list[dict[str, Any]] = []
|
||||
for handle in run.subgraphs:
|
||||
paths.append(handle.path)
|
||||
if handle.graph_name == "one":
|
||||
assert handle.output is not None
|
||||
assert handle.status == "completed"
|
||||
elif handle.graph_name == "two":
|
||||
second_values = list(handle.values)
|
||||
|
||||
assert [path[0].split(":", 1)[0] for path in paths] == ["one", "two"]
|
||||
assert second_values
|
||||
assert second_values[-1]["items"] == ["one", "two"]
|
||||
|
||||
|
||||
def test_aborted_subgraph_handle_does_not_fail_parent_forwarding() -> None:
|
||||
graph = _make_two_sibling_subgraphs()
|
||||
run = graph.stream_events({"value": "x", "items": []}, version="v3")
|
||||
|
||||
seen: list[str | None] = []
|
||||
for handle in run.subgraphs:
|
||||
seen.append(handle.graph_name)
|
||||
if handle.graph_name == "one":
|
||||
# Subscribe before aborting to ensure forwarding into the
|
||||
# closed mini-mux would have raised without the closed check.
|
||||
iter(handle.values)
|
||||
handle.abort()
|
||||
elif handle.graph_name == "two":
|
||||
assert list(handle.values)
|
||||
|
||||
assert seen == ["one", "two"]
|
||||
|
||||
|
||||
def test_failed_subgraph_output_raises_terminal_error() -> None:
|
||||
graph = _make_failing_nested()
|
||||
run = graph.stream_events({"value": "x", "items": []}, version="v3")
|
||||
|
||||
handle = next(iter(run.subgraphs))
|
||||
with pytest.raises(RuntimeError, match="child boom"):
|
||||
_ = handle.output
|
||||
assert handle.status == "failed"
|
||||
assert handle.error == "child boom"
|
||||
@@ -0,0 +1,290 @@
|
||||
"""Tests for StreamToolCallHandler and ToolRuntime.emit_output_delta.
|
||||
|
||||
These tests exercise the langgraph-core piece in isolation — the prebuilt
|
||||
`ToolCallTransformer` has its own test file. Here we feed real graphs
|
||||
through `Pregel.stream(stream_mode=["tools", ...])` and inspect the raw
|
||||
`(ns, mode, payload)` tuples on the `tools` channel.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated, Any
|
||||
|
||||
import pytest
|
||||
from langchain_core.messages import AIMessage
|
||||
from langchain_core.tools import tool
|
||||
from langgraph.prebuilt import ToolNode, ToolRuntime
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from langgraph.constants import END, START
|
||||
from langgraph.graph import StateGraph
|
||||
from langgraph.graph.message import add_messages
|
||||
from langgraph.pregel._tools import _tool_call_writer
|
||||
|
||||
|
||||
class _State(TypedDict):
|
||||
messages: Annotated[list, add_messages]
|
||||
|
||||
|
||||
def _caller_sync(tool_name: str, tool_args: dict[str, Any], tc_id: str = "tc1"):
|
||||
def caller(state: _State) -> dict:
|
||||
return {
|
||||
"messages": [
|
||||
AIMessage(
|
||||
content="",
|
||||
tool_calls=[{"name": tool_name, "args": tool_args, "id": tc_id}],
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
return caller
|
||||
|
||||
|
||||
def _caller_async(tool_name: str, tool_args: dict[str, Any], tc_id: str = "tc1"):
|
||||
async def caller(state: _State) -> dict:
|
||||
return {
|
||||
"messages": [
|
||||
AIMessage(
|
||||
content="",
|
||||
tool_calls=[{"name": tool_name, "args": tool_args, "id": tc_id}],
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
return caller
|
||||
|
||||
|
||||
def _build_graph(caller, tools) -> Any:
|
||||
sg = StateGraph(_State)
|
||||
sg.add_node("caller", caller)
|
||||
sg.add_node("tools", ToolNode(tools))
|
||||
sg.add_edge(START, "caller")
|
||||
sg.add_edge("caller", "tools")
|
||||
sg.add_edge("tools", END)
|
||||
return sg.compile()
|
||||
|
||||
|
||||
def _tool_events(stream) -> list[tuple[tuple[str, ...], dict]]:
|
||||
"""Collect `(ns, payload)` for every `tools`-mode chunk."""
|
||||
out: list[tuple[tuple[str, ...], dict]] = []
|
||||
for ns, mode, payload in stream:
|
||||
if mode == "tools":
|
||||
out.append((tuple(ns), payload))
|
||||
return out
|
||||
|
||||
|
||||
class TestSyncGraphSyncTool:
|
||||
def test_started_finished_cycle(self) -> None:
|
||||
@tool
|
||||
def echo(text: str) -> str:
|
||||
"""echo."""
|
||||
return f"echoed:{text}"
|
||||
|
||||
graph = _build_graph(_caller_sync("echo", {"text": "hi"}), [echo])
|
||||
events = _tool_events(
|
||||
graph.stream(
|
||||
{"messages": []},
|
||||
stream_mode=["tools"],
|
||||
subgraphs=True,
|
||||
)
|
||||
)
|
||||
|
||||
assert [p["event"] for _, p in events] == [
|
||||
"tool-started",
|
||||
"tool-finished",
|
||||
]
|
||||
assert events[0][1]["tool_call_id"] == "tc1"
|
||||
assert events[0][1]["tool_name"] == "echo"
|
||||
assert events[0][1]["input"] == {"text": "hi"}
|
||||
# ToolNode wraps the return in a ToolMessage.
|
||||
assert events[1][1]["tool_call_id"] == "tc1"
|
||||
|
||||
def test_emit_output_delta_produces_delta_events(self) -> None:
|
||||
@tool
|
||||
def streaming_echo(text: str, runtime: ToolRuntime) -> str:
|
||||
"""stream chunks."""
|
||||
for chunk in ("a", "b", "c"):
|
||||
runtime.emit_output_delta(chunk)
|
||||
return text
|
||||
|
||||
graph = _build_graph(
|
||||
_caller_sync("streaming_echo", {"text": "x"}), [streaming_echo]
|
||||
)
|
||||
events = _tool_events(
|
||||
graph.stream(
|
||||
{"messages": []},
|
||||
stream_mode=["tools"],
|
||||
subgraphs=True,
|
||||
)
|
||||
)
|
||||
|
||||
deltas = [p["delta"] for _, p in events if p["event"] == "tool-output-delta"]
|
||||
assert deltas == ["a", "b", "c"]
|
||||
# The deltas must be bracketed by started and finished.
|
||||
ordered = [p["event"] for _, p in events]
|
||||
assert ordered[0] == "tool-started"
|
||||
assert ordered[-1] == "tool-finished"
|
||||
|
||||
def test_tool_error_event(self) -> None:
|
||||
@tool
|
||||
def boom() -> str:
|
||||
"""raises."""
|
||||
raise ValueError("nope")
|
||||
|
||||
graph = _build_graph(_caller_sync("boom", {}), [boom])
|
||||
events: list[tuple[tuple[str, ...], dict]] = []
|
||||
with pytest.raises(ValueError, match="nope"):
|
||||
for ns, mode, payload in graph.stream(
|
||||
{"messages": []},
|
||||
stream_mode=["tools"],
|
||||
subgraphs=True,
|
||||
):
|
||||
if mode == "tools":
|
||||
events.append((tuple(ns), payload))
|
||||
|
||||
kinds = [p["event"] for _, p in events]
|
||||
assert kinds == ["tool-started", "tool-error"]
|
||||
assert events[1][1]["message"] == "nope"
|
||||
|
||||
def test_writer_unset_outside_tool(self) -> None:
|
||||
# Outside any tool body the ContextVar that ToolRuntime reads
|
||||
# is unset — emitting from there would be a no-op.
|
||||
assert _tool_call_writer.get() is None
|
||||
|
||||
def test_no_events_without_tools_mode(self) -> None:
|
||||
@tool
|
||||
def echo(text: str) -> str:
|
||||
"""echo."""
|
||||
return text
|
||||
|
||||
graph = _build_graph(_caller_sync("echo", {"text": "hi"}), [echo])
|
||||
# No "tools" in stream_mode — handler is not attached and zero
|
||||
# `tools`-method events fire.
|
||||
chunks = list(
|
||||
graph.stream(
|
||||
{"messages": []},
|
||||
stream_mode=["values"],
|
||||
subgraphs=True,
|
||||
)
|
||||
)
|
||||
assert all(
|
||||
not (isinstance(c, tuple) and len(c) == 3 and c[1] == "tools")
|
||||
for c in chunks
|
||||
)
|
||||
|
||||
|
||||
class TestAsyncGraphAsyncTool:
|
||||
@pytest.mark.anyio
|
||||
async def test_async_tool_produces_events(self) -> None:
|
||||
@tool
|
||||
async def aecho(text: str, runtime: ToolRuntime) -> str:
|
||||
"""async echo."""
|
||||
runtime.emit_output_delta(text)
|
||||
return f"got:{text}"
|
||||
|
||||
graph = _build_graph(_caller_async("aecho", {"text": "hi"}), [aecho])
|
||||
events: list[tuple[tuple[str, ...], dict]] = []
|
||||
async for ns, mode, payload in graph.astream(
|
||||
{"messages": []},
|
||||
stream_mode=["tools"],
|
||||
subgraphs=True,
|
||||
):
|
||||
if mode == "tools":
|
||||
events.append((tuple(ns), payload))
|
||||
|
||||
kinds = [p["event"] for _, p in events]
|
||||
assert kinds == ["tool-started", "tool-output-delta", "tool-finished"]
|
||||
assert events[1][1]["delta"] == "hi"
|
||||
|
||||
|
||||
class TestConcurrentToolCalls:
|
||||
def test_parallel_tool_calls_do_not_bleed(self) -> None:
|
||||
@tool
|
||||
def streamer(marker: str, runtime: ToolRuntime) -> str:
|
||||
"""emits marker twice."""
|
||||
runtime.emit_output_delta(f"{marker}-1")
|
||||
runtime.emit_output_delta(f"{marker}-2")
|
||||
return marker
|
||||
|
||||
def caller(state: _State) -> dict:
|
||||
return {
|
||||
"messages": [
|
||||
AIMessage(
|
||||
content="",
|
||||
tool_calls=[
|
||||
{"name": "streamer", "args": {"marker": "A"}, "id": "a"},
|
||||
{"name": "streamer", "args": {"marker": "B"}, "id": "b"},
|
||||
],
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
graph = _build_graph(caller, [streamer])
|
||||
events = _tool_events(
|
||||
graph.stream(
|
||||
{"messages": []},
|
||||
stream_mode=["tools"],
|
||||
subgraphs=True,
|
||||
)
|
||||
)
|
||||
|
||||
# Group deltas by tool_call_id.
|
||||
by_id: dict[str, list[str]] = {}
|
||||
for _, p in events:
|
||||
if p["event"] == "tool-output-delta":
|
||||
by_id.setdefault(p["tool_call_id"], []).append(p["delta"])
|
||||
assert by_id["a"] == ["A-1", "A-2"]
|
||||
assert by_id["b"] == ["B-1", "B-2"]
|
||||
|
||||
|
||||
class TestSubgraphNamespacePropagation:
|
||||
def test_tool_inside_subgraph_emits_with_subgraph_ns(self) -> None:
|
||||
@tool
|
||||
def inner_tool(text: str) -> str:
|
||||
"""inner tool."""
|
||||
return text
|
||||
|
||||
def sub_caller(state: _State) -> dict:
|
||||
return {
|
||||
"messages": [
|
||||
AIMessage(
|
||||
content="",
|
||||
tool_calls=[
|
||||
{
|
||||
"name": "inner_tool",
|
||||
"args": {"text": "x"},
|
||||
"id": "tc1",
|
||||
}
|
||||
],
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
inner = StateGraph(_State)
|
||||
inner.add_node("sub_caller", sub_caller)
|
||||
inner.add_node("sub_tools", ToolNode([inner_tool]))
|
||||
inner.add_edge(START, "sub_caller")
|
||||
inner.add_edge("sub_caller", "sub_tools")
|
||||
inner.add_edge("sub_tools", END)
|
||||
inner_graph = inner.compile()
|
||||
|
||||
outer = StateGraph(_State)
|
||||
outer.add_node("sub", inner_graph)
|
||||
outer.add_edge(START, "sub")
|
||||
outer.add_edge("sub", END)
|
||||
graph = outer.compile()
|
||||
|
||||
events = _tool_events(
|
||||
graph.stream(
|
||||
{"messages": []},
|
||||
stream_mode=["tools"],
|
||||
subgraphs=True,
|
||||
)
|
||||
)
|
||||
|
||||
# All `tools` events should carry a non-empty namespace rooted
|
||||
# at the `sub` node.
|
||||
assert events, "expected at least one tools event"
|
||||
for ns, _ in events:
|
||||
assert ns # non-empty
|
||||
assert ns[0].startswith("sub:")
|
||||
Generated
+458
-382
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
"""langgraph.prebuilt exposes a higher-level API for creating and executing agents and tools."""
|
||||
|
||||
from langgraph.prebuilt._tool_call_transformer import ToolCallTransformer
|
||||
from langgraph.prebuilt.chat_agent_executor import create_react_agent
|
||||
from langgraph.prebuilt.tool_node import (
|
||||
InjectedState,
|
||||
@@ -13,6 +14,7 @@ from langgraph.prebuilt.tool_validator import ValidationNode
|
||||
__all__ = [
|
||||
"create_react_agent",
|
||||
"ToolNode",
|
||||
"ToolCallTransformer",
|
||||
"tools_condition",
|
||||
"ValidationNode",
|
||||
"InjectedState",
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"""In-process handle for a single tool call's streaming execution.
|
||||
|
||||
Mirrors the shape of `ChatModelStream` from langchain-core but simpler —
|
||||
a tool has one output channel, no content-block multiplexing. Populated
|
||||
by `ToolCallTransformer` as `tool-started` / `tool-output-delta` /
|
||||
`tool-finished` / `tool-error` events flow in on the `tools` channel.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
from typing import Any
|
||||
|
||||
from langgraph.stream.stream_channel import StreamChannel
|
||||
|
||||
|
||||
class ToolCallStream:
|
||||
"""Scoped view of a single tool call's lifecycle.
|
||||
|
||||
Yielded on `run.tool_calls` once per `tool-started` event. Fields
|
||||
are populated as events arrive:
|
||||
|
||||
- `tool_call_id`, `tool_name`, `input`: stable from the start event.
|
||||
- `output_deltas`: a `StreamChannel` of delta chunks. Iterate (sync or
|
||||
async) to consume partial output in arrival order.
|
||||
- `output`: terminal payload from `tool-finished`, or `None` if the
|
||||
call failed or is still in flight.
|
||||
- `error`: terminal error string from `tool-error`, or `None` if the
|
||||
call succeeded or is still in flight.
|
||||
- `completed`: True once a terminal event (`tool-finished` or
|
||||
`tool-error`) has been observed.
|
||||
|
||||
`ToolCallStream` is not meant to be constructed by end users — it's
|
||||
produced by `ToolCallTransformer` as events flow through the mux.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tool_call_id: str,
|
||||
tool_name: str,
|
||||
input: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Initialize a fresh handle for a tool call.
|
||||
|
||||
Args:
|
||||
tool_call_id: The `tool_call_id` from the AIMessage.
|
||||
tool_name: The tool's name.
|
||||
input: The tool's input arguments (as reported by
|
||||
`on_tool_start`), or `None` if none were captured.
|
||||
"""
|
||||
self.tool_call_id = tool_call_id
|
||||
self.tool_name = tool_name
|
||||
self.input = input
|
||||
self._output_deltas: StreamChannel[Any] = StreamChannel()
|
||||
self.output: Any = None
|
||||
self.error: str | None = None
|
||||
self.completed = False
|
||||
|
||||
@property
|
||||
def output_deltas(self) -> StreamChannel[Any]:
|
||||
"""The channel of streamed `tool-output-delta` payloads.
|
||||
|
||||
Iterate (sync or async depending on how the run was started)
|
||||
to consume partial output in arrival order. The log closes when
|
||||
the tool finishes or errors.
|
||||
"""
|
||||
return self._output_deltas
|
||||
|
||||
def _bind(self, *, is_async: bool) -> None:
|
||||
"""Bind the deltas log to sync or async iteration.
|
||||
|
||||
Called by `ToolCallTransformer` when constructing this handle so
|
||||
the log matches the enclosing mux's mode.
|
||||
"""
|
||||
self._output_deltas._bind(is_async=is_async)
|
||||
|
||||
def _push_delta(self, delta: Any) -> None:
|
||||
self._output_deltas.push(delta)
|
||||
|
||||
def _finish(self, output: Any) -> None:
|
||||
self.output = output
|
||||
self.completed = True
|
||||
self._output_deltas.close()
|
||||
|
||||
def _fail(self, message: str) -> None:
|
||||
self.error = message
|
||||
self.completed = True
|
||||
self._output_deltas.close()
|
||||
|
||||
def __iter__(self) -> Iterator[Any]:
|
||||
"""Iterate delta chunks synchronously.
|
||||
|
||||
Equivalent to `iter(self.output_deltas)`. Raises `TypeError` if
|
||||
the underlying log is bound to async mode.
|
||||
"""
|
||||
return iter(self._output_deltas)
|
||||
|
||||
def __aiter__(self) -> AsyncIterator[Any]:
|
||||
"""Iterate delta chunks asynchronously.
|
||||
|
||||
Equivalent to `aiter(self.output_deltas)`. Raises `TypeError`
|
||||
if the underlying log is bound to sync mode.
|
||||
"""
|
||||
return self._output_deltas.__aiter__()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
status = (
|
||||
"completed"
|
||||
if self.completed and self.error is None
|
||||
else "failed"
|
||||
if self.completed
|
||||
else "running"
|
||||
)
|
||||
return (
|
||||
f"ToolCallStream(tool_call_id={self.tool_call_id!r}, "
|
||||
f"tool_name={self.tool_name!r}, status={status})"
|
||||
)
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Transformer that projects `tools` channel events into `ToolCallStream`s."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from langgraph.stream._types import ProtocolEvent, StreamTransformer
|
||||
from langgraph.stream.stream_channel import StreamChannel
|
||||
|
||||
from langgraph.prebuilt._tool_call_stream import ToolCallStream
|
||||
|
||||
|
||||
class ToolCallTransformer(StreamTransformer):
|
||||
"""Project `tools` channel events into `ToolCallStream` handles.
|
||||
|
||||
Each `tool-started` event spawns a `ToolCallStream`, pushed onto
|
||||
`run.tool_calls`. Subsequent `tool-output-delta` events append to
|
||||
that stream's deltas log; `tool-finished` and `tool-error` close it.
|
||||
|
||||
Native transformer — the `tool_calls` projection is exposed as a
|
||||
direct attribute on the run stream.
|
||||
|
||||
A nameless `StreamChannel[ToolCallStream]` is used (no protocol
|
||||
auto-forwarding) because the live handles are not serializable and
|
||||
should not be injected into the main event log. Wire consumers
|
||||
subscribe to the `tools` channel instead, where the raw protocol
|
||||
events flow through untouched by this transformer (`process`
|
||||
returns `True`).
|
||||
|
||||
Registered explicitly by users at compile time via
|
||||
`builder.compile(transformers=[ToolCallTransformer])` — not a
|
||||
default built-in, so the `tools` channel is user-opt-in.
|
||||
"""
|
||||
|
||||
_native = True
|
||||
required_stream_modes = ("tools",)
|
||||
|
||||
def __init__(self, scope: tuple[str, ...] = ()) -> None:
|
||||
super().__init__(scope)
|
||||
self._log: StreamChannel[ToolCallStream] = StreamChannel()
|
||||
self._active: dict[str, ToolCallStream] = {}
|
||||
self._is_async = False
|
||||
self._pump_fn: Callable[[], bool] | None = None
|
||||
self._apump_fn: Callable[[], Awaitable[bool]] | None = None
|
||||
|
||||
def init(self) -> dict[str, Any]:
|
||||
return {"tool_calls": self._log}
|
||||
|
||||
def _bind_pump(self, fn: Callable[[], bool]) -> None:
|
||||
"""Wire the sync pull callback onto this transformer.
|
||||
|
||||
Called by `StreamMux.bind_pump`. Stored so each new
|
||||
`ToolCallStream` created by `process` can wire its deltas log
|
||||
for pump-driven iteration.
|
||||
"""
|
||||
self._pump_fn = fn
|
||||
self._is_async = False
|
||||
|
||||
def _bind_apump(self, fn: Callable[[], Awaitable[bool]]) -> None:
|
||||
"""Async counterpart to `_bind_pump`."""
|
||||
self._apump_fn = fn
|
||||
self._is_async = True
|
||||
|
||||
def _new_stream(
|
||||
self,
|
||||
tool_call_id: str,
|
||||
tool_name: str,
|
||||
tool_input: dict[str, Any] | None,
|
||||
) -> ToolCallStream:
|
||||
stream = ToolCallStream(tool_call_id, tool_name, tool_input)
|
||||
stream._bind(is_async=self._is_async)
|
||||
if self._apump_fn is not None:
|
||||
stream._output_deltas._arequest_more = self._apump_fn
|
||||
if self._pump_fn is not None:
|
||||
stream._output_deltas._request_more = self._pump_fn
|
||||
return stream
|
||||
|
||||
def process(self, event: ProtocolEvent) -> bool:
|
||||
if event["method"] != "tools":
|
||||
return True
|
||||
|
||||
# Only project events emitted at this transformer's scope. Subgraph
|
||||
# events still flow through the parent's mux (the parent's main
|
||||
# event log keeps them) but they belong to the child mini-mux's
|
||||
# `tool_calls` projection, not the parent's.
|
||||
if tuple(event["params"]["namespace"]) != self.scope:
|
||||
return True
|
||||
|
||||
data = event["params"]["data"]
|
||||
tool_call_id = data.get("tool_call_id")
|
||||
if tool_call_id is None:
|
||||
return True
|
||||
event_type = data.get("event")
|
||||
|
||||
stream: ToolCallStream | None
|
||||
if event_type == "tool-started":
|
||||
stream = self._new_stream(
|
||||
tool_call_id,
|
||||
data.get("tool_name", ""),
|
||||
data.get("input"),
|
||||
)
|
||||
self._active[tool_call_id] = stream
|
||||
self._log.push(stream)
|
||||
elif event_type == "tool-output-delta":
|
||||
stream = self._active.get(tool_call_id)
|
||||
if stream is not None:
|
||||
stream._push_delta(data.get("delta"))
|
||||
elif event_type == "tool-finished":
|
||||
stream = self._active.pop(tool_call_id, None)
|
||||
if stream is not None:
|
||||
stream._finish(data.get("output"))
|
||||
elif event_type == "tool-error":
|
||||
stream = self._active.pop(tool_call_id, None)
|
||||
if stream is not None:
|
||||
stream._fail(data.get("message", ""))
|
||||
|
||||
# Pass-through — wire consumers subscribe to the `tools` channel
|
||||
# directly and reconstruct handles client-side.
|
||||
return True
|
||||
|
||||
def finalize(self) -> None:
|
||||
"""Close any still-active tool streams left open at run end."""
|
||||
for stream in self._active.values():
|
||||
if not stream.completed:
|
||||
stream._finish(None)
|
||||
self._active.clear()
|
||||
|
||||
def fail(self, err: BaseException) -> None:
|
||||
"""Fail any still-active tool streams when the run errors."""
|
||||
message = str(err)
|
||||
for stream in self._active.values():
|
||||
if not stream.completed:
|
||||
stream._fail(message)
|
||||
self._active.clear()
|
||||
@@ -44,7 +44,7 @@ import inspect
|
||||
import json
|
||||
from collections.abc import Awaitable, Callable
|
||||
from copy import copy, deepcopy
|
||||
from dataclasses import dataclass, replace
|
||||
from dataclasses import dataclass, field, replace
|
||||
from types import UnionType
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
@@ -82,9 +82,11 @@ from langchain_core.tools.base import (
|
||||
_is_injected_arg_type,
|
||||
get_all_basemodel_annotations,
|
||||
)
|
||||
from langgraph._internal._constants import CONF, CONFIG_KEY_READ
|
||||
from langgraph._internal._runnable import RunnableCallable
|
||||
from langgraph.errors import GraphBubbleUp
|
||||
from langgraph.graph.message import REMOVE_ALL_MESSAGES
|
||||
from langgraph.pregel._tools import _tool_call_writer
|
||||
from langgraph.runtime import ExecutionInfo, ServerInfo # noqa: TC002
|
||||
from langgraph.store.base import BaseStore # noqa: TC002
|
||||
from langgraph.types import Command, Send, StreamWriter
|
||||
@@ -800,7 +802,7 @@ class ToolNode(RunnableCallable):
|
||||
# Construct ToolRuntime instances at the top level for each tool call
|
||||
tool_runtimes = []
|
||||
for call, cfg in zip(tool_calls, config_list, strict=False):
|
||||
state = self._extract_state(input)
|
||||
state = self._extract_state(input, cfg)
|
||||
tool_runtime = ToolRuntime(
|
||||
state=state,
|
||||
tool_call_id=call["id"],
|
||||
@@ -835,7 +837,7 @@ class ToolNode(RunnableCallable):
|
||||
# Construct ToolRuntime instances at the top level for each tool call
|
||||
tool_runtimes = []
|
||||
for call, cfg in zip(tool_calls, config_list, strict=False):
|
||||
state = self._extract_state(input)
|
||||
state = self._extract_state(input, cfg)
|
||||
tool_runtime = ToolRuntime(
|
||||
state=state,
|
||||
tool_call_id=call["id"],
|
||||
@@ -1277,18 +1279,37 @@ class ToolNode(RunnableCallable):
|
||||
return None
|
||||
|
||||
def _extract_state(
|
||||
self, input: list[AnyMessage] | dict[str, Any] | BaseModel
|
||||
self,
|
||||
input: list[AnyMessage] | dict[str, Any] | BaseModel,
|
||||
config: RunnableConfig,
|
||||
) -> list[AnyMessage] | dict[str, Any] | BaseModel:
|
||||
"""Extract state from input, handling ToolCallWithContext if present.
|
||||
"""Extract state from input.
|
||||
|
||||
Args:
|
||||
input: The input which may be raw state or ToolCallWithContext.
|
||||
Three input shapes:
|
||||
|
||||
Returns:
|
||||
The actual state to pass to wrap_tool_call wrappers.
|
||||
- `ToolCallWithContext` dict — legacy Send payload carrying an inlined
|
||||
state snapshot; return `input["state"]`.
|
||||
- list of `ToolCall` dicts — new Send payload with no inlined state;
|
||||
hydrate state from channels via `CONFIG_KEY_READ`.
|
||||
- regular graph state (dict/list/BaseModel) — return `input` as-is.
|
||||
"""
|
||||
if isinstance(input, dict) and input.get("__type") == "tool_call_with_context":
|
||||
return input["state"]
|
||||
if (
|
||||
isinstance(input, list)
|
||||
and input
|
||||
and isinstance(input[-1], dict)
|
||||
and input[-1].get("type") == "tool_call"
|
||||
):
|
||||
read = config.get(CONF, {}).get(CONFIG_KEY_READ)
|
||||
if read is None:
|
||||
return {}
|
||||
# Pregel installs CONFIG_KEY_READ as
|
||||
# `functools.partial(local_read, scratchpad, channels, managed, task)`.
|
||||
# Match the previous inlined-state contract by reading channels only;
|
||||
# managed values have their own injection path (`ToolRuntime.context`).
|
||||
channels = read.args[1]
|
||||
return cast("dict[str, Any]", read(list(channels), True))
|
||||
return input
|
||||
|
||||
def _inject_tool_args(
|
||||
@@ -1702,12 +1723,32 @@ class ToolRuntime(_DirectlyInjectedToolArg, Generic[ContextT, StateT]):
|
||||
context: ContextT
|
||||
config: RunnableConfig
|
||||
stream_writer: StreamWriter
|
||||
tools: list[BaseTool]
|
||||
tool_call_id: str | None
|
||||
store: BaseStore | None
|
||||
tools: list[BaseTool] = field(default_factory=list)
|
||||
execution_info: ExecutionInfo | None = None
|
||||
server_info: ServerInfo | None = None
|
||||
|
||||
def emit_output_delta(self, delta: Any) -> None:
|
||||
"""Stream a partial output chunk on the `tools` stream channel.
|
||||
|
||||
Reads the per-tool-call writer that `StreamToolCallHandler`
|
||||
installs on a ContextVar at `on_tool_start` and forwards `delta`
|
||||
through it. Silent no-op when the graph was not run with
|
||||
`"tools"` in `stream_mode` (no writer is set), so tool authors
|
||||
can leave `emit_output_delta` calls in place without gating
|
||||
them on stream mode.
|
||||
|
||||
Args:
|
||||
delta: Partial output chunk. Any JSON-serializable value;
|
||||
surfaced as-is on the `tools` channel's
|
||||
`tool-output-delta` payload under `"delta"`.
|
||||
"""
|
||||
writer = _tool_call_writer.get()
|
||||
if writer is None:
|
||||
return
|
||||
writer(delta)
|
||||
|
||||
|
||||
class InjectedState(InjectedToolArg):
|
||||
"""Annotation for injecting graph state into tool arguments.
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "langgraph-prebuilt"
|
||||
version = "1.0.11"
|
||||
version = "1.1.0a2"
|
||||
description = "Library with high-level APIs for creating and executing LangGraph agents and tools."
|
||||
authors = []
|
||||
requires-python = ">=3.10"
|
||||
@@ -30,7 +30,7 @@ dependencies = [
|
||||
|
||||
[project.urls]
|
||||
Source = "https://github.com/langchain-ai/langgraph/tree/main/libs/prebuilt"
|
||||
Twitter = "https://x.com/LangChain"
|
||||
Twitter = "https://x.com/langchain_oss"
|
||||
Slack = "https://www.langchain.com/join-community"
|
||||
Reddit = "https://www.reddit.com/r/LangChain/"
|
||||
|
||||
|
||||
@@ -1320,6 +1320,98 @@ async def test_state_extraction_with_tool_call_with_context_async() -> None:
|
||||
assert "tool_call" not in state_seen[0]
|
||||
|
||||
|
||||
def _config_with_channel_read(
|
||||
channel_values: dict[str, object],
|
||||
store: BaseStore | None = None,
|
||||
) -> RunnableConfig:
|
||||
"""Build a config that mimics `CONFIG_KEY_READ` as Pregel installs it.
|
||||
|
||||
Pregel always installs a `functools.partial(local_read, scratchpad,
|
||||
channels, managed, task)`, and `ToolNode` introspects that partial to
|
||||
learn channel names. The stub matches the shape: partial whose second and
|
||||
third positional args are `channels` and `managed` mappings.
|
||||
"""
|
||||
import functools
|
||||
|
||||
channels_stub = {k: None for k in channel_values}
|
||||
managed_stub: dict[str, object] = {}
|
||||
|
||||
# Shape matches pregel's real partial:
|
||||
# functools.partial(local_read, scratchpad, channels, managed, task)
|
||||
def _read(scratchpad, channels, managed, task, select, fresh): # noqa: ARG001
|
||||
if isinstance(select, str):
|
||||
return channel_values[select]
|
||||
return {k: channel_values[k] for k in select if k in channel_values}
|
||||
|
||||
read = functools.partial(_read, None, channels_stub, managed_stub, None)
|
||||
cfg = _create_config_with_runtime(store)
|
||||
cfg["configurable"]["__pregel_read"] = read
|
||||
return cfg
|
||||
|
||||
|
||||
def test_list_form_send_hydrates_state_from_channel_read() -> None:
|
||||
"""Send('tools', [tool_call]) with no inlined state should hydrate
|
||||
ToolRuntime.state from CONFIG_KEY_READ (full state read)."""
|
||||
state_seen = []
|
||||
|
||||
def state_inspector_handler(
|
||||
request: ToolCallRequest,
|
||||
execute: Callable[[ToolCallRequest], ToolMessage | Command],
|
||||
) -> ToolMessage | Command:
|
||||
state_seen.append(request.state)
|
||||
return execute(request)
|
||||
|
||||
channel_values = {
|
||||
"messages": [AIMessage("from channels")],
|
||||
"files": {"/a.md": "body"},
|
||||
}
|
||||
|
||||
tool_node = ToolNode([add], wrap_tool_call=state_inspector_handler)
|
||||
|
||||
tool_call: ToolCall = {
|
||||
"name": "add",
|
||||
"args": {"a": 1, "b": 2},
|
||||
"id": "call_1",
|
||||
"type": "tool_call",
|
||||
}
|
||||
|
||||
tool_node.invoke([tool_call], config=_config_with_channel_read(channel_values))
|
||||
|
||||
assert len(state_seen) == 1
|
||||
got = state_seen[0]
|
||||
assert got == channel_values
|
||||
assert "messages" in got and "files" in got
|
||||
|
||||
|
||||
async def test_list_form_send_hydrates_state_async() -> None:
|
||||
state_seen = []
|
||||
|
||||
def state_inspector_handler(
|
||||
request: ToolCallRequest,
|
||||
execute: Callable[[ToolCallRequest], ToolMessage | Command],
|
||||
) -> ToolMessage | Command:
|
||||
state_seen.append(request.state)
|
||||
return execute(request)
|
||||
|
||||
channel_values = {"messages": [AIMessage("from channels")], "files": {}}
|
||||
|
||||
tool_node = ToolNode([add], wrap_tool_call=state_inspector_handler)
|
||||
|
||||
tool_call: ToolCall = {
|
||||
"name": "add",
|
||||
"args": {"a": 1, "b": 2},
|
||||
"id": "call_1",
|
||||
"type": "tool_call",
|
||||
}
|
||||
|
||||
await tool_node.ainvoke(
|
||||
[tool_call], config=_config_with_channel_read(channel_values)
|
||||
)
|
||||
|
||||
assert len(state_seen) == 1
|
||||
assert state_seen[0] == channel_values
|
||||
|
||||
|
||||
def test_tool_call_request_is_frozen() -> None:
|
||||
"""Test that ToolCallRequest raises deprecation warnings on direct attribute reassignment."""
|
||||
tool_call: ToolCall = {"name": "add", "args": {"a": 1, "b": 2}, "id": "call_1"}
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
"""Tests for ToolCallTransformer and the ToolCallStream projection."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Annotated, Any
|
||||
|
||||
import pytest
|
||||
from langchain_core.messages import AIMessage
|
||||
from langchain_core.tools import tool
|
||||
from langgraph.constants import END, START
|
||||
from langgraph.graph import StateGraph
|
||||
from langgraph.graph.message import add_messages
|
||||
from langgraph.stream._mux import StreamMux
|
||||
from langgraph.stream._types import ProtocolEvent
|
||||
from langgraph.stream.stream_channel import StreamChannel
|
||||
from langgraph.stream.transformers import (
|
||||
MessagesTransformer,
|
||||
ValuesTransformer,
|
||||
)
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from langgraph.prebuilt import (
|
||||
ToolCallTransformer,
|
||||
ToolNode,
|
||||
ToolRuntime,
|
||||
)
|
||||
from langgraph.prebuilt._tool_call_stream import ToolCallStream
|
||||
|
||||
TS = int(time.time() * 1000)
|
||||
|
||||
|
||||
def _unstamped(items):
|
||||
"""Strip push stamps from a StreamChannel's internal buffer."""
|
||||
return [item for _stamp, item in items]
|
||||
|
||||
|
||||
def _tool_event(
|
||||
event: str,
|
||||
tool_call_id: str,
|
||||
*,
|
||||
tool_name: str = "",
|
||||
input: dict[str, Any] | None = None,
|
||||
delta: Any = None,
|
||||
output: Any = None,
|
||||
message: str = "",
|
||||
namespace: list[str] | None = None,
|
||||
) -> ProtocolEvent:
|
||||
data: dict[str, Any] = {"event": event, "tool_call_id": tool_call_id}
|
||||
if event == "tool-started":
|
||||
data["tool_name"] = tool_name
|
||||
if input is not None:
|
||||
data["input"] = input
|
||||
elif event == "tool-output-delta":
|
||||
data["delta"] = delta
|
||||
elif event == "tool-finished":
|
||||
data["output"] = output
|
||||
elif event == "tool-error":
|
||||
data["message"] = message
|
||||
return {
|
||||
"type": "event",
|
||||
"method": "tools",
|
||||
"params": {
|
||||
"namespace": namespace or [],
|
||||
"timestamp": TS,
|
||||
"data": data,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _subscribe(log: StreamChannel) -> None:
|
||||
log._subscribed = True
|
||||
|
||||
|
||||
def _mux() -> tuple[StreamMux, ToolCallTransformer]:
|
||||
transformer = ToolCallTransformer()
|
||||
mux = StreamMux(
|
||||
[
|
||||
ValuesTransformer(),
|
||||
MessagesTransformer(),
|
||||
transformer,
|
||||
],
|
||||
is_async=False,
|
||||
)
|
||||
_subscribe(transformer._log)
|
||||
return mux, transformer
|
||||
|
||||
|
||||
class TestToolCallTransformerUnit:
|
||||
def test_required_stream_modes_declares_tools(self) -> None:
|
||||
assert ToolCallTransformer.required_stream_modes == ("tools",)
|
||||
|
||||
def test_tool_started_yields_handle(self) -> None:
|
||||
mux, transformer = _mux()
|
||||
mux.push(
|
||||
_tool_event(
|
||||
"tool-started",
|
||||
"tc1",
|
||||
tool_name="echo",
|
||||
input={"text": "hi"},
|
||||
)
|
||||
)
|
||||
handles = _unstamped(transformer._log._items)
|
||||
assert len(handles) == 1
|
||||
h = handles[0]
|
||||
assert isinstance(h, ToolCallStream)
|
||||
assert h.tool_call_id == "tc1"
|
||||
assert h.tool_name == "echo"
|
||||
assert h.input == {"text": "hi"}
|
||||
assert h.completed is False
|
||||
|
||||
def test_delta_accumulates_on_active_stream(self) -> None:
|
||||
mux, transformer = _mux()
|
||||
mux.push(_tool_event("tool-started", "tc1", tool_name="echo"))
|
||||
_subscribe(transformer._active["tc1"]._output_deltas)
|
||||
mux.push(_tool_event("tool-output-delta", "tc1", delta="a"))
|
||||
mux.push(_tool_event("tool-output-delta", "tc1", delta="b"))
|
||||
stream = transformer._active["tc1"]
|
||||
assert _unstamped(stream._output_deltas._items) == ["a", "b"]
|
||||
|
||||
def test_finish_closes_stream(self) -> None:
|
||||
mux, transformer = _mux()
|
||||
mux.push(_tool_event("tool-started", "tc1", tool_name="echo"))
|
||||
stream = transformer._active["tc1"]
|
||||
mux.push(_tool_event("tool-finished", "tc1", output="done"))
|
||||
assert stream.completed is True
|
||||
assert stream.output == "done"
|
||||
assert stream.error is None
|
||||
assert "tc1" not in transformer._active
|
||||
|
||||
def test_error_closes_stream(self) -> None:
|
||||
mux, transformer = _mux()
|
||||
mux.push(_tool_event("tool-started", "tc1", tool_name="boom"))
|
||||
stream = transformer._active["tc1"]
|
||||
mux.push(_tool_event("tool-error", "tc1", message="nope"))
|
||||
assert stream.completed is True
|
||||
assert stream.output is None
|
||||
assert stream.error == "nope"
|
||||
assert "tc1" not in transformer._active
|
||||
|
||||
def test_concurrent_tool_calls_do_not_bleed(self) -> None:
|
||||
mux, transformer = _mux()
|
||||
mux.push(_tool_event("tool-started", "a", tool_name="t"))
|
||||
mux.push(_tool_event("tool-started", "b", tool_name="t"))
|
||||
for tc in ("a", "b"):
|
||||
_subscribe(transformer._active[tc]._output_deltas)
|
||||
mux.push(_tool_event("tool-output-delta", "a", delta="A1"))
|
||||
mux.push(_tool_event("tool-output-delta", "b", delta="B1"))
|
||||
mux.push(_tool_event("tool-output-delta", "a", delta="A2"))
|
||||
assert _unstamped(transformer._active["a"]._output_deltas._items) == [
|
||||
"A1",
|
||||
"A2",
|
||||
]
|
||||
assert _unstamped(transformer._active["b"]._output_deltas._items) == ["B1"]
|
||||
|
||||
def test_tools_event_passes_through_main_log(self) -> None:
|
||||
mux, transformer = _mux()
|
||||
_subscribe(mux._events)
|
||||
mux.push(_tool_event("tool-started", "tc1", tool_name="echo"))
|
||||
kept = [e for e in _unstamped(mux._events._items) if e["method"] == "tools"]
|
||||
assert len(kept) == 1
|
||||
|
||||
def test_out_of_scope_event_skipped(self) -> None:
|
||||
"""Subgraph-scoped `tools` events must not project into a parent
|
||||
transformer's `tool_calls` log.
|
||||
|
||||
The parent's main event log keeps the event (so wire consumers
|
||||
still see it) but the parent's `ToolCallTransformer` only owns
|
||||
the projection at its own scope. Per-scope `ToolCallTransformer`
|
||||
instances on child mini-muxes are responsible for projecting
|
||||
events at their own depth.
|
||||
"""
|
||||
# Root-scope transformer (`scope == ()`).
|
||||
mux, transformer = _mux()
|
||||
_subscribe(mux._events)
|
||||
mux.push(
|
||||
_tool_event(
|
||||
"tool-started",
|
||||
"tc1",
|
||||
tool_name="inner_echo",
|
||||
namespace=["child:abc"],
|
||||
)
|
||||
)
|
||||
# No `ToolCallStream` was projected into the root's log.
|
||||
assert _unstamped(transformer._log._items) == []
|
||||
assert "tc1" not in transformer._active
|
||||
# The event still passes through the main event log so consumers
|
||||
# of the raw `tools` channel see it untouched.
|
||||
kept = [e for e in _unstamped(mux._events._items) if e["method"] == "tools"]
|
||||
assert len(kept) == 1
|
||||
|
||||
def test_in_scope_event_projected_when_scope_set(self) -> None:
|
||||
"""A non-root transformer projects only events at its own scope."""
|
||||
scope: tuple[str, ...] = ("child:abc",)
|
||||
transformer = ToolCallTransformer(scope=scope)
|
||||
mux = StreamMux(
|
||||
[ValuesTransformer(), MessagesTransformer(), transformer],
|
||||
scope=scope,
|
||||
is_async=False,
|
||||
)
|
||||
_subscribe(transformer._log)
|
||||
# Event at this scope: projected.
|
||||
mux.push(
|
||||
_tool_event(
|
||||
"tool-started",
|
||||
"tc1",
|
||||
tool_name="echo",
|
||||
namespace=list(scope),
|
||||
)
|
||||
)
|
||||
assert len(_unstamped(transformer._log._items)) == 1
|
||||
# Event at a deeper scope: ignored.
|
||||
mux.push(
|
||||
_tool_event(
|
||||
"tool-started",
|
||||
"tc2",
|
||||
tool_name="grandchild",
|
||||
namespace=[*scope, "grand:xyz"],
|
||||
)
|
||||
)
|
||||
assert len(_unstamped(transformer._log._items)) == 1
|
||||
# Event at root (above this scope): ignored.
|
||||
mux.push(
|
||||
_tool_event(
|
||||
"tool-started",
|
||||
"tc3",
|
||||
tool_name="root_tool",
|
||||
namespace=[],
|
||||
)
|
||||
)
|
||||
assert len(_unstamped(transformer._log._items)) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# End-to-end tests with a real graph
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _State(TypedDict):
|
||||
messages: Annotated[list, add_messages]
|
||||
|
||||
|
||||
def _build_graph(caller, tools):
|
||||
sg = StateGraph(_State)
|
||||
sg.add_node("caller", caller)
|
||||
sg.add_node("tools", ToolNode(tools))
|
||||
sg.add_edge(START, "caller")
|
||||
sg.add_edge("caller", "tools")
|
||||
sg.add_edge("tools", END)
|
||||
return sg.compile()
|
||||
|
||||
|
||||
class TestToolCallTransformerEndToEnd:
|
||||
def test_sync_streaming_tool_populates_tool_calls(self) -> None:
|
||||
@tool
|
||||
def streamer(text: str, runtime: ToolRuntime) -> str:
|
||||
"""streams chunks."""
|
||||
for chunk in ("one", "two"):
|
||||
runtime.emit_output_delta(chunk)
|
||||
return text
|
||||
|
||||
def caller(state: _State) -> dict:
|
||||
return {
|
||||
"messages": [
|
||||
AIMessage(
|
||||
content="",
|
||||
tool_calls=[
|
||||
{"name": "streamer", "args": {"text": "x"}, "id": "tc1"}
|
||||
],
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
graph = _build_graph(caller, [streamer])
|
||||
run = graph.stream_events(
|
||||
{"messages": []}, transformers=[ToolCallTransformer], version="v3"
|
||||
)
|
||||
|
||||
tool_calls: list[ToolCallStream] = []
|
||||
for tc in run.tool_calls:
|
||||
tool_calls.append(tc)
|
||||
deltas = list(tc.output_deltas)
|
||||
assert deltas == ["one", "two"]
|
||||
assert len(tool_calls) == 1
|
||||
tc = tool_calls[0]
|
||||
assert tc.tool_call_id == "tc1"
|
||||
assert tc.tool_name == "streamer"
|
||||
assert tc.completed is True
|
||||
assert tc.error is None
|
||||
|
||||
def test_stream_modes_union_includes_tools(self) -> None:
|
||||
@tool
|
||||
def echo(text: str) -> str:
|
||||
"""echo."""
|
||||
return text
|
||||
|
||||
def caller(state: _State) -> dict:
|
||||
return {
|
||||
"messages": [
|
||||
AIMessage(
|
||||
content="",
|
||||
tool_calls=[
|
||||
{"name": "echo", "args": {"text": "x"}, "id": "tc1"}
|
||||
],
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
graph = _build_graph(caller, [echo])
|
||||
# Without ToolCallTransformer, no tool_calls projection is
|
||||
# exposed and no `tools` events flow through (required_stream_modes
|
||||
# omits it).
|
||||
run_no_tc = graph.stream_events({"messages": []}, version="v3")
|
||||
assert "tool_calls" not in run_no_tc._mux.extensions # type: ignore[attr-defined]
|
||||
|
||||
# With ToolCallTransformer, the projection is present.
|
||||
run = graph.stream_events(
|
||||
{"messages": []}, transformers=[ToolCallTransformer], version="v3"
|
||||
)
|
||||
assert "tool_calls" in run._mux.extensions # type: ignore[attr-defined]
|
||||
# Drain so the run closes cleanly.
|
||||
list(run.tool_calls)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_async_streaming_tool_populates_tool_calls(self) -> None:
|
||||
@tool
|
||||
async def astreamer(text: str, runtime: ToolRuntime) -> str:
|
||||
"""async streams."""
|
||||
runtime.emit_output_delta(text)
|
||||
runtime.emit_output_delta(text + "!")
|
||||
return text
|
||||
|
||||
async def caller(state: _State) -> dict:
|
||||
return {
|
||||
"messages": [
|
||||
AIMessage(
|
||||
content="",
|
||||
tool_calls=[
|
||||
{"name": "astreamer", "args": {"text": "hi"}, "id": "tc1"}
|
||||
],
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
graph = _build_graph(caller, [astreamer])
|
||||
run = await graph.astream_events(
|
||||
{"messages": []}, version="v3", transformers=[ToolCallTransformer]
|
||||
)
|
||||
|
||||
collected: list[ToolCallStream] = []
|
||||
async for tc in run.tool_calls:
|
||||
collected.append(tc)
|
||||
deltas = [d async for d in tc.output_deltas]
|
||||
assert deltas == ["hi", "hi!"]
|
||||
assert len(collected) == 1
|
||||
assert collected[0].completed is True
|
||||
assert collected[0].error is None
|
||||
|
||||
def test_tool_error_populates_error_field(self) -> None:
|
||||
@tool
|
||||
def boom() -> str:
|
||||
"""raises."""
|
||||
raise ValueError("nope")
|
||||
|
||||
def caller(state: _State) -> dict:
|
||||
return {
|
||||
"messages": [
|
||||
AIMessage(
|
||||
content="",
|
||||
tool_calls=[{"name": "boom", "args": {}, "id": "tc1"}],
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
graph = _build_graph(caller, [boom])
|
||||
run = graph.stream_events(
|
||||
{"messages": []}, transformers=[ToolCallTransformer], version="v3"
|
||||
)
|
||||
|
||||
collected: list[ToolCallStream] = []
|
||||
with pytest.raises(ValueError, match="nope"):
|
||||
for tc in run.tool_calls:
|
||||
collected.append(tc)
|
||||
# Drain deltas so the error field is populated before we
|
||||
# inspect it below.
|
||||
list(tc.output_deltas)
|
||||
|
||||
assert len(collected) == 1
|
||||
assert collected[0].error == "nope"
|
||||
assert collected[0].output is None
|
||||
assert collected[0].completed is True
|
||||
@@ -2016,6 +2016,19 @@ async def test_tool_node_inject_runtime_dynamic_tool_via_wrap_tool_call_async()
|
||||
assert tool_message.tool_call_id == "call_dynamic_2"
|
||||
|
||||
|
||||
def test_tool_runtime_defaults_tools_to_empty_list() -> None:
|
||||
runtime = ToolRuntime(
|
||||
state={},
|
||||
context=None,
|
||||
config={},
|
||||
stream_writer=lambda *args, **kwargs: None,
|
||||
tool_call_id=None,
|
||||
store=None,
|
||||
)
|
||||
|
||||
assert runtime.tools == []
|
||||
|
||||
|
||||
def test_tool_runtime_forwards_execution_info_server_info_and_tools() -> None:
|
||||
"""Test that execution_info, server_info, and tools are forwarded from Runtime to ToolRuntime."""
|
||||
from langgraph.runtime import ExecutionInfo, ServerInfo
|
||||
|
||||
Generated
+150
-133
@@ -1,6 +1,10 @@
|
||||
version = 1
|
||||
revision = 2
|
||||
revision = 3
|
||||
requires-python = ">=3.10"
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.15'",
|
||||
"python_full_version < '3.15'",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aiosqlite"
|
||||
@@ -249,10 +253,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langchain-core"
|
||||
version = "1.3.1"
|
||||
version = "1.4.0a2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jsonpatch" },
|
||||
{ name = "langchain-protocol" },
|
||||
{ name = "langsmith" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pydantic" },
|
||||
@@ -261,14 +266,26 @@ dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "uuid-utils" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f4/fe/abeae8d0d2899e191d67c6c7f065f7e52a953f30b21ef327fa49084e4af9/langchain_core-1.3.1.tar.gz", hash = "sha256:41b384055799f93f34520df6bf7b80e2e5e23153cdfd46874251c6c9916ea030", size = 862403, upload-time = "2026-04-23T18:54:01.857Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3c/93/68bafa047f8e1770d0cf0f61d6c70889f1dec42ef6bd263540d916c421b9/langchain_core-1.4.0a2.tar.gz", hash = "sha256:b723c7961b615c7f2180ce2bcf352fdad8247bc51a60adecd3d97088235c120d", size = 916486, upload-time = "2026-05-01T15:02:19.029Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/c2/8493be505921857988db068b7c027f28a9b1587b4425c6a32b1221c9c9fe/langchain_core-1.3.1-py3-none-any.whl", hash = "sha256:8b13d19d3bed3f4768df12c7f6932d2ada715f3ac9fd020c63d28c693968269e", size = 515879, upload-time = "2026-04-23T18:53:59.94Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/8e/933e0ba7ba0430ce264e36b178d581b255239ad45093872483142d93478c/langchain_core-1.4.0a2-py3-none-any.whl", hash = "sha256:a5c689f8404357df797120c012da7704144a953b2ae18f258df263301e7badd5", size = 546297, upload-time = "2026-05-01T15:02:17.731Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langchain-protocol"
|
||||
version = "0.0.14"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/05/bf/efb5e2ed832e4d6d45590e25a9e5191986b291b543bc6a807b48bee070b0/langchain_protocol-0.0.14.tar.gz", hash = "sha256:bc1e8553122e6ede310280462d5813023a172ff2785ccbbdec54d43f3a15e5f2", size = 5862, upload-time = "2026-04-29T16:40:18.657Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/e9/06c47ecb2aff08f83dfa30058da3bf86be64862c19569043ed5331bbeecd/langchain_protocol-0.0.14-py3-none-any.whl", hash = "sha256:ffc35089779bd8ca217015180cef5e660fc3b074efdaa0f2e95df73583f1a047", size = 6984, upload-time = "2026-04-29T16:40:17.841Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langgraph"
|
||||
version = "1.1.9"
|
||||
version = "1.2.0a7"
|
||||
source = { editable = "../langgraph" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -281,7 +298,7 @@ dependencies = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "langchain-core", specifier = ">=1.3.0,<2" },
|
||||
{ name = "langchain-core", specifier = ">=1.4.0a2,<2" },
|
||||
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
|
||||
{ name = "langgraph-prebuilt", editable = "." },
|
||||
{ name = "langgraph-sdk", editable = "../sdk-py" },
|
||||
@@ -352,7 +369,7 @@ test = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-checkpoint"
|
||||
version = "4.0.2"
|
||||
version = "4.1.0a4"
|
||||
source = { editable = "../checkpoint" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -400,7 +417,7 @@ test = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-checkpoint-postgres"
|
||||
version = "3.0.5"
|
||||
version = "3.1.0a4"
|
||||
source = { editable = "../checkpoint-postgres" }
|
||||
dependencies = [
|
||||
{ name = "langgraph-checkpoint" },
|
||||
@@ -490,7 +507,7 @@ test = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-prebuilt"
|
||||
version = "1.0.11"
|
||||
version = "1.1.0a2"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -593,20 +610,20 @@ requires-dist = [
|
||||
dev = [
|
||||
{ name = "codespell" },
|
||||
{ name = "langgraph", editable = "../langgraph" },
|
||||
{ name = "mypy", specifier = "==1.19.1" },
|
||||
{ name = "mypy", specifier = "==1.20.2" },
|
||||
{ name = "pydantic", specifier = ">=2.12.4" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "pytest-mock" },
|
||||
{ name = "pytest-watch" },
|
||||
{ name = "ruff", specifier = "==0.15.6" },
|
||||
{ name = "ruff", specifier = "==0.15.12" },
|
||||
{ name = "starlette" },
|
||||
{ name = "ty", specifier = "==0.0.23" },
|
||||
]
|
||||
lint = [
|
||||
{ name = "codespell" },
|
||||
{ name = "mypy", specifier = "==1.19.1" },
|
||||
{ name = "ruff", specifier = "==0.15.6" },
|
||||
{ name = "mypy", specifier = "==1.20.2" },
|
||||
{ name = "ruff", specifier = "==0.15.12" },
|
||||
{ name = "starlette" },
|
||||
{ name = "ty", specifier = "==0.0.23" },
|
||||
]
|
||||
@@ -724,7 +741,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "mypy"
|
||||
version = "1.20.0"
|
||||
version = "1.20.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "librt", marker = "platform_python_implementation != 'PyPy'" },
|
||||
@@ -733,51 +750,51 @@ dependencies = [
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f8/5c/b0089fe7fef0a994ae5ee07029ced0526082c6cfaaa4c10d40a10e33b097/mypy-1.20.0.tar.gz", hash = "sha256:eb96c84efcc33f0b5e0e04beacf00129dd963b67226b01c00b9dfc8affb464c3", size = 3815028, upload-time = "2026-03-31T16:55:14.959Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/04/af/e3d4b3e9ec91a0ff9aabfdb38692952acf49bbb899c2e4c29acb3a6da3ae/mypy-1.20.2.tar.gz", hash = "sha256:e8222c26daaafd9e8626dec58ae36029f82585890589576f769a650dd20fd665", size = 3817349, upload-time = "2026-04-21T17:12:28.473Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/a2/a965c8c3fcd4fa8b84ba0d46606181b0d0a1d50f274c67877f3e9ed4882c/mypy-1.20.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d99f515f95fd03a90875fdb2cca12ff074aa04490db4d190905851bdf8a549a8", size = 14430138, upload-time = "2026-03-31T16:52:37.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/6e/043477501deeb8eabbab7f1a2f6cac62cfb631806dc1d6862a04a7f5011b/mypy-1.20.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bd0212976dc57a5bfeede7c219e7cd66568a32c05c9129686dd487c059c1b88a", size = 13311282, upload-time = "2026-03-31T16:55:11.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/aa/bd89b247b83128197a214f29f0632ff3c14f54d4cd70d144d157bd7d7d6e/mypy-1.20.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8426d4d75d68714abc17a4292d922f6ba2cfb984b72c2278c437f6dae797865", size = 13750889, upload-time = "2026-03-31T16:52:02.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/9d/2860be7355c45247ccc0be1501c91176318964c2a137bd4743f58ce6200e/mypy-1.20.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02cca0761c75b42a20a2757ae58713276605eb29a08dd8a6e092aa347c4115ca", size = 14619788, upload-time = "2026-03-31T16:50:48.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/7f/3ef3e360c91f3de120f205c8ce405e9caf9fc52ef14b65d37073e322c114/mypy-1.20.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b3a49064504be59e59da664c5e149edc1f26c67c4f8e8456f6ba6aba55033018", size = 14918849, upload-time = "2026-03-31T16:51:10.478Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/72/af970dfe167ef788df7c5e6109d2ed0229f164432ce828bc9741a4250e64/mypy-1.20.0-cp310-cp310-win_amd64.whl", hash = "sha256:ebea00201737ad4391142808ed16e875add5c17f676e0912b387739f84991e13", size = 10822007, upload-time = "2026-03-31T16:50:25.268Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/94/ba9065c2ebe5421619aff684b793d953e438a8bfe31a320dd6d1e0706e81/mypy-1.20.0-cp310-cp310-win_arm64.whl", hash = "sha256:e80cf77847d0d3e6e3111b7b25db32a7f8762fd4b9a3a72ce53fe16a2863b281", size = 9756158, upload-time = "2026-03-31T16:48:36.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/1c/74cb1d9993236910286865679d1c616b136b2eae468493aa939431eda410/mypy-1.20.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4525e7010b1b38334516181c5b81e16180b8e149e6684cee5a727c78186b4e3b", size = 14343972, upload-time = "2026-03-31T16:49:04.887Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/0d/01399515eca280386e308cf57901e68d3a52af18691941b773b3380c1df8/mypy-1.20.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a17c5d0bdcca61ce24a35beb828a2d0d323d3fcf387d7512206888c900193367", size = 13225007, upload-time = "2026-03-31T16:50:08.151Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/ac/b4ba5094fb2d7fe9d2037cd8d18bbe02bcf68fd22ab9ff013f55e57ba095/mypy-1.20.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f75ff57defcd0f1d6e006d721ccdec6c88d4f6a7816eb92f1c4890d979d9ee62", size = 13663752, upload-time = "2026-03-31T16:49:26.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/a7/460678d3cf7da252d2288dad0c602294b6ec22a91932ec368cc11e44bb6e/mypy-1.20.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b503ab55a836136b619b5fc21c8803d810c5b87551af8600b72eecafb0059cb0", size = 14532265, upload-time = "2026-03-31T16:53:55.077Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/3e/051cca8166cf0438ae3ea80e0e7c030d7a8ab98dffc93f80a1aa3f23c1a2/mypy-1.20.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1973868d2adbb4584a3835780b27436f06d1dc606af5be09f187aaa25be1070f", size = 14768476, upload-time = "2026-03-31T16:50:34.587Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/66/8e02ec184f852ed5c4abb805583305db475930854e09964b55e107cdcbc4/mypy-1.20.0-cp311-cp311-win_amd64.whl", hash = "sha256:2fcedb16d456106e545b2bfd7ef9d24e70b38ec252d2a629823a4d07ebcdb69e", size = 10818226, upload-time = "2026-03-31T16:53:15.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/4b/383ad1924b28f41e4879a74151e7a5451123330d45652da359f9183bcd45/mypy-1.20.0-cp311-cp311-win_arm64.whl", hash = "sha256:379edf079ce44ac8d2805bcf9b3dd7340d4f97aad3a5e0ebabbf9d125b84b442", size = 9750091, upload-time = "2026-03-31T16:54:12.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/dd/3afa29b58c2e57c79116ed55d700721c3c3b15955e2b6251dd165d377c0e/mypy-1.20.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:002b613ae19f4ac7d18b7e168ffe1cb9013b37c57f7411984abbd3b817b0a214", size = 14509525, upload-time = "2026-03-31T16:55:01.824Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/eb/227b516ab8cad9f2a13c5e7a98d28cd6aa75e9c83e82776ae6c1c4c046c7/mypy-1.20.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a9336b5e6712f4adaf5afc3203a99a40b379049104349d747eb3e5a3aa23ac2e", size = 13326469, upload-time = "2026-03-31T16:51:41.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/d4/1ddb799860c1b5ac6117ec307b965f65deeb47044395ff01ab793248a591/mypy-1.20.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f13b3e41bce9d257eded794c0f12878af3129d80aacd8a3ee0dee51f3a978651", size = 13705953, upload-time = "2026-03-31T16:48:55.69Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/b7/54a720f565a87b893182a2a393370289ae7149e4715859e10e1c05e49154/mypy-1.20.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9804c3ad27f78e54e58b32e7cb532d128b43dbfb9f3f9f06262b821a0f6bd3f5", size = 14710363, upload-time = "2026-03-31T16:53:26.948Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/2a/74810274848d061f8a8ea4ac23aaad43bd3d8c1882457999c2e568341c57/mypy-1.20.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:697f102c5c1d526bdd761a69f17c6070f9892eebcb94b1a5963d679288c09e78", size = 14947005, upload-time = "2026-03-31T16:50:17.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/91/21b8ba75f958bcda75690951ce6fa6b7138b03471618959529d74b8544e2/mypy-1.20.0-cp312-cp312-win_amd64.whl", hash = "sha256:0ecd63f75fdd30327e4ad8b5704bd6d91fc6c1b2e029f8ee14705e1207212489", size = 10880616, upload-time = "2026-03-31T16:52:19.986Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/15/3d8198ef97c1ca03aea010cce4f1d4f3bc5d9849e8c0140111ca2ead9fdd/mypy-1.20.0-cp312-cp312-win_arm64.whl", hash = "sha256:f194db59657c58593a3c47c6dfd7bad4ef4ac12dbc94d01b3a95521f78177e33", size = 9813091, upload-time = "2026-03-31T16:53:44.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/a7/f64ea7bd592fa431cb597418b6dec4a47f7d0c36325fec7ac67bc8402b94/mypy-1.20.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b20c8b0fd5877abdf402e79a3af987053de07e6fb208c18df6659f708b535134", size = 14485344, upload-time = "2026-03-31T16:49:16.78Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/72/8927d84cfc90c6abea6e96663576e2e417589347eb538749a464c4c218a0/mypy-1.20.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:367e5c993ba34d5054d11937d0485ad6dfc60ba760fa326c01090fc256adf15c", size = 13327400, upload-time = "2026-03-31T16:53:08.02Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/4a/11ab99f9afa41aa350178d24a7d2da17043228ea10f6456523f64b5a6cf6/mypy-1.20.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f799d9db89fc00446f03281f84a221e50018fc40113a3ba9864b132895619ebe", size = 13706384, upload-time = "2026-03-31T16:52:28.577Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/79/694ca73979cfb3535ebfe78733844cd5aff2e63304f59bf90585110d975a/mypy-1.20.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:555658c611099455b2da507582ea20d2043dfdfe7f5ad0add472b1c6238b433f", size = 14700378, upload-time = "2026-03-31T16:48:45.527Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/24/a022ccab3a46e3d2cdf2e0e260648633640eb396c7e75d5a42818a8d3971/mypy-1.20.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:efe8d70949c3023698c3fca1e94527e7e790a361ab8116f90d11221421cd8726", size = 14932170, upload-time = "2026-03-31T16:49:36.038Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/9b/549228d88f574d04117e736f55958bd4908f980f9f5700a07aeb85df005b/mypy-1.20.0-cp313-cp313-win_amd64.whl", hash = "sha256:f49590891d2c2f8a9de15614e32e459a794bcba84693c2394291a2038bbaaa69", size = 10888526, upload-time = "2026-03-31T16:50:59.827Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/17/15095c0e54a8bc04d22d4ff06b2139d5f142c2e87520b4e39010c4862771/mypy-1.20.0-cp313-cp313-win_arm64.whl", hash = "sha256:76a70bf840495729be47510856b978f1b0ec7d08f257ca38c9d932720bf6b43e", size = 9816456, upload-time = "2026-03-31T16:49:59.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/0e/6ca4a84cbed9e62384bc0b2974c90395ece5ed672393e553996501625fc5/mypy-1.20.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0f42dfaab7ec1baff3b383ad7af562ab0de573c5f6edb44b2dab016082b89948", size = 14483331, upload-time = "2026-03-31T16:52:57.999Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/c5/5fe9d8a729dd9605064691816243ae6c49fde0bd28f6e5e17f6a24203c43/mypy-1.20.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:31b5dbb55293c1bd27c0fc813a0d2bb5ceef9d65ac5afa2e58f829dab7921fd5", size = 13342047, upload-time = "2026-03-31T16:54:21.555Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/33/e18bcfa338ca4e6b2771c85d4c5203e627d0c69d9de5c1a2cf2ba13320ba/mypy-1.20.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49d11c6f573a5a08f77fad13faff2139f6d0730ebed2cfa9b3d2702671dd7188", size = 13719585, upload-time = "2026-03-31T16:51:53.89Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/8d/93491ff7b79419edc7eabf95cb3b3f7490e2e574b2855c7c7e7394ff933f/mypy-1.20.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d3243c406773185144527f83be0e0aefc7bf4601b0b2b956665608bf7c98a83", size = 14685075, upload-time = "2026-03-31T16:54:04.464Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/9d/d924b38a4923f8d164bf2b4ec98bf13beaf6e10a5348b4b137eadae40a6e/mypy-1.20.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a79c1eba7ac4209f2d850f0edd0a2f8bba88cbfdfefe6fb76a19e9d4fe5e71a2", size = 14919141, upload-time = "2026-03-31T16:54:51.785Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/98/1da9977016678c0b99d43afe52ed00bb3c1a0c4c995d3e6acca1a6ebb9b4/mypy-1.20.0-cp314-cp314-win_amd64.whl", hash = "sha256:00e047c74d3ec6e71a2eb88e9ea551a2edb90c21f993aefa9e0d2a898e0bb732", size = 11050925, upload-time = "2026-03-31T16:51:30.758Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/e3/ba0b7a3143e49a9c4f5967dde6ea4bf8e0b10ecbbcca69af84027160ee89/mypy-1.20.0-cp314-cp314-win_arm64.whl", hash = "sha256:931a7630bba591593dcf6e97224a21ff80fb357e7982628d25e3c618e7f598ef", size = 10001089, upload-time = "2026-03-31T16:49:43.632Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/28/e617e67b3be9d213cda7277913269c874eb26472489f95d09d89765ce2d8/mypy-1.20.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:26c8b52627b6552f47ff11adb4e1509605f094e29815323e487fc0053ebe93d1", size = 15534710, upload-time = "2026-03-31T16:52:12.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/0c/3b5f2d3e45dc7169b811adce8451679d9430399d03b168f9b0489f43adaa/mypy-1.20.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:39362cdb4ba5f916e7976fccecaab1ba3a83e35f60fa68b64e9a70e221bb2436", size = 14393013, upload-time = "2026-03-31T16:54:41.186Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/49/edc8b0aa145cc09c1c74f7ce2858eead9329931dcbbb26e2ad40906daa4e/mypy-1.20.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34506397dbf40c15dc567635d18a21d33827e9ab29014fb83d292a8f4f8953b6", size = 15047240, upload-time = "2026-03-31T16:54:31.955Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/37/a946bb416e37a57fa752b3100fd5ede0e28df94f92366d1716555d47c454/mypy-1.20.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:555493c44a4f5a1b58d611a43333e71a9981c6dbe26270377b6f8174126a0526", size = 15858565, upload-time = "2026-03-31T16:53:36.997Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/99/7690b5b5b552db1bd4ff362e4c0eb3107b98d680835e65823fbe888c8b78/mypy-1.20.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2721f0ce49cb74a38f00c50da67cb7d36317b5eda38877a49614dc018e91c787", size = 16087874, upload-time = "2026-03-31T16:52:48.313Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/76/53e893a498138066acd28192b77495c9357e5a58cc4be753182846b43315/mypy-1.20.0-cp314-cp314t-win_amd64.whl", hash = "sha256:47781555a7aa5fedcc2d16bcd72e0dc83eb272c10dd657f9fb3f9cc08e2e6abb", size = 12572380, upload-time = "2026-03-31T16:49:52.454Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/9c/6dbdae21f01b7aacddc2c0bbf3c5557aa547827fdf271770fe1e521e7093/mypy-1.20.0-cp314-cp314t-win_arm64.whl", hash = "sha256:c70380fe5d64010f79fb863b9081c7004dd65225d2277333c219d93a10dad4dd", size = 10381174, upload-time = "2026-03-31T16:51:20.179Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/66/4d734961ce167f0fd8380769b3b7c06dbdd6ff54c2190f3f2ecd22528158/mypy-1.20.0-py3-none-any.whl", hash = "sha256:a6e0641147cbfa7e4e94efdb95c2dab1aff8cfc159ded13e07f308ddccc8c48e", size = 2636365, upload-time = "2026-03-31T16:51:44.911Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/97/ce2502df2cecf2ef997b6c6527c4a223b92feb9e7b790cdc8dcd683f3a8a/mypy-1.20.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cf5a4db6dca263010e2c7bff081c89383c72d187ba2cf4c44759aac970e2f0c4", size = 14457059, upload-time = "2026-04-21T17:06:14.935Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/34/417ee60b822cc80c0f3dc9f495ad7fd8dbb8d8b2cf4baf22d4046d25d01d/mypy-1.20.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7b0e817b518bff7facd7f85ea05b643ad8bdcce684cf29784987b0a7c8e1f997", size = 13346816, upload-time = "2026-04-21T17:10:41.433Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/85/e20951978702df58379d0bcc2e8f7ccdca4e78cd7dc66dd3ddbf9b29d517/mypy-1.20.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97d7b9a485b40f8ca425460e89bf1da2814625b2da627c0dcc6aa46c92631d14", size = 13772593, upload-time = "2026-04-21T17:08:11.24Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/a5/5441a13259ec516c56fd5de0fd96a69a9590ae6c5e5d3e5174aa84b97973/mypy-1.20.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e1c12f6d2db3d78b909b5f77513c11eb7f2dd2782b96a3ab6dffc7d44575c99", size = 14656635, upload-time = "2026-04-21T17:09:54.042Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/51/b89c69157c5e1f19fd125a65d991166a26906e7902f026f00feebbcfa2b9/mypy-1.20.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:89dce27e142d25ffbc154c1819383b69f2e9234dc4ed4766f42e0e8cb264ab5c", size = 14943278, upload-time = "2026-04-21T17:09:15.599Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/44/6b0eeecfe96d7cce1d71c66b8e03cb304aa70ec11f1955dc1d6b46aca3c3/mypy-1.20.2-cp310-cp310-win_amd64.whl", hash = "sha256:f376e37f9bf2a946872fc5fd1199c99310748e3c26c7a26683f13f8bdb756cbd", size = 10851915, upload-time = "2026-04-21T17:06:03.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/36/6593dc88545d75fb96416184be5392da5e2a8e8c2802a8597913e16ae25c/mypy-1.20.2-cp310-cp310-win_arm64.whl", hash = "sha256:6e2b469efd811707bc530fd1effef0f5d6eebcb7fe376affae69025da4b979a2", size = 9786676, upload-time = "2026-04-21T17:07:02.035Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/4d/9ebeae211caccbdaddde7ed5e31dfcf57faac66be9b11deb1dc6526c8078/mypy-1.20.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4077797a273e56e8843d001e9dfe4ba10e33323d6ade647ff260e5cd97d9758c", size = 14371307, upload-time = "2026-04-21T17:08:56.442Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/d7/93473d34b61f04fac1aecc01368485c89c5c4af7a4b9a0cab5d77d04b63f/mypy-1.20.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cdecf62abcc4292500d7858aeae87a1f8f1150f4c4dd08fb0b336ee79b2a6df3", size = 13258917, upload-time = "2026-04-21T17:05:50.978Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/30/3dd903e8bafb7b5f7bf87fcd58f8382086dea2aa19f0a7b357f21f63071b/mypy-1.20.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c566c3a88b6ece59b3d70f65bedef17304f48eb52ff040a6a18214e1917b3254", size = 13700516, upload-time = "2026-04-21T17:11:33.161Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/05/c61a140aba4c729ac7bc99ae26fc627c78a6e08f5b9dd319244ea71a3d7e/mypy-1.20.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0deb80d062b2479f2c87ae568f89845afc71d11bc41b04179e58165fd9f31e98", size = 14562889, upload-time = "2026-04-21T17:05:27.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/87/da78243742ffa8a36d98c3010f0d829f93d5da4e6786f1a1a6f2ad616502/mypy-1.20.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bba9ad231e92a3e424b3e56b65aa17704993425bba97e302c832f9466bb85bac", size = 14803844, upload-time = "2026-04-21T17:10:06.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/52/10a1ddf91b40f843943a3c6db51e2df59c9e237f29d355e95eaab427461f/mypy-1.20.2-cp311-cp311-win_amd64.whl", hash = "sha256:baf593f2765fa3a6b1ef95807dbaa3d25b594f6a52adcc506a6b9cb115e1be67", size = 10846300, upload-time = "2026-04-21T17:12:23.886Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/02/f9a4415b664c53bd34d6709be59da303abcae986dc4ac847b402edb6fa1e/mypy-1.20.2-cp311-cp311-win_arm64.whl", hash = "sha256:20175a1c0f49863946ec20b7f63255768058ac4f07d2b9ded6a6b46cfb5a9100", size = 9779498, upload-time = "2026-04-21T17:09:23.695Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/4e/7560e4528db9e9b147e4c0f22660466bf30a0a1fe3d63d1b9d3b0fd354ee/mypy-1.20.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4dbfcf869f6b0517f70cf0030ba6ea1d6645e132337a7d5204a18d8d5636c02b", size = 14539393, upload-time = "2026-04-21T17:07:12.52Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/d9/34a5efed8124f5a9234f55ac6a4ced4201e2c5b81e1109c49ad23190ec8c/mypy-1.20.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b6481b228d072315b053210b01ac320e1be243dc17f9e5887ef167f23f5fae4", size = 13361642, upload-time = "2026-04-21T17:06:53.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/14/eb377acf78c03c92d566a1510cda8137348215b5335085ef662ab82ecd3a/mypy-1.20.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34397cdced6b90b836e38182076049fdb41424322e0b0728c946b0939ebdf9f6", size = 13740347, upload-time = "2026-04-21T17:12:04.73Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/94/7e4634a32b641aa1c112422eed1bbece61ee16205f674190e8b536f884de/mypy-1.20.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5da6976f20cae27059ea8d0c86e7cef3de720e04c4bb9ee18e3690fdb792066", size = 14734042, upload-time = "2026-04-21T17:07:43.16Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/f3/f7e62395cb7f434541b4491a01149a4439e28ace4c0c632bbf5431e92d1f/mypy-1.20.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:56908d7e08318d39f85b1f0c6cfd47b0cac1a130da677630dac0de3e0623e102", size = 14964958, upload-time = "2026-04-21T17:11:00.665Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/0d/47e3c3a0ec2a876e35aeac365df3cac7776c36bbd4ed18cc521e1b9d255b/mypy-1.20.2-cp312-cp312-win_amd64.whl", hash = "sha256:d52ad8d78522da1d308789df651ee5379088e77c76cb1994858d40a426b343b9", size = 10911340, upload-time = "2026-04-21T17:10:49.179Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/b2/6c852d72e0ea8b01f49da817fb52539993cde327e7d010e0103dc12d0dac/mypy-1.20.2-cp312-cp312-win_arm64.whl", hash = "sha256:785b08db19c9f214dc37d65f7c165d19a30fcecb48abfa30f31b01b5acaabb58", size = 9833947, upload-time = "2026-04-21T17:09:05.267Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/c4/b93812d3a192c9bcf5df405bd2f30277cd0e48106a14d1023c7f6ed6e39b/mypy-1.20.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:edfbfca868cdd6bd8d974a60f8a3682f5565d3f5c99b327640cedd24c4264026", size = 14524670, upload-time = "2026-04-21T17:10:30.737Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/47/42c122501bff18eaf1e8f457f5c017933452d8acdc52918a9f59f6812955/mypy-1.20.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e2877a02380adfcdbc69071a0f74d6e9dbbf593c0dc9d174e1f223ffd5281943", size = 13336218, upload-time = "2026-04-21T17:08:44.069Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/8f/75bbc92f41725fbd585fb17b440b1119b576105df1013622983e18640a93/mypy-1.20.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7488448de6007cd5177c6cea0517ac33b4c0f5ee9b5e9f2be51ce75511a85517", size = 13724906, upload-time = "2026-04-21T17:08:01.02Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/32/4c49da27a606167391ff0c39aa955707a00edc500572e562f7c36c08a71f/mypy-1.20.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb9c2fa06887e21d6a3a868762acb82aec34e2c6fd0174064f27c93ede68ad15", size = 14726046, upload-time = "2026-04-21T17:11:22.354Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/fc/4e354a1bd70216359deb0c9c54847ee6b32ef78dfb09f5131ff99b494078/mypy-1.20.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d56a78b646f2e3daa865bc70cd5ec5a46c50045801ca8ff17a0c43abc97e3ee", size = 14955587, upload-time = "2026-04-21T17:12:16.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/b2/c0f2056e9eb8f08c62cafd9715e4584b89132bdc832fcf85d27d07b5f3e5/mypy-1.20.2-cp313-cp313-win_amd64.whl", hash = "sha256:2a4102b03bb7481d9a91a6da8d174740c9c8c4401024684b9ca3b7cc5e49852f", size = 10922681, upload-time = "2026-04-21T17:06:35.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/14/065e333721f05de8ef683d0aa804c23026bcc287446b61cac657b902ccac/mypy-1.20.2-cp313-cp313-win_arm64.whl", hash = "sha256:a95a9248b0c6fd933a442c03c3b113c3b61320086b88e2c444676d3fd1ca3330", size = 9830560, upload-time = "2026-04-21T17:07:51.023Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/d1/b4ec96b0ecc620a4443570c6e95c867903428cfcde4206518eafdd5880c3/mypy-1.20.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:419413398fe250aae057fd2fe50166b61077083c9b82754c341cf4fd73038f30", size = 14524561, upload-time = "2026-04-21T17:06:27.325Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/63/d2c2ff4fa66bc49477d32dfa26e8a167ba803ea6a69c5efb416036909d30/mypy-1.20.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e73c07f23009962885c197ccb9b41356a30cc0e5a1d0c2ea8fd8fb1362d7f924", size = 13363883, upload-time = "2026-04-21T17:11:11.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/56/983916806bf4eddeaaa2c9230903c3669c6718552a921154e1c5182c701f/mypy-1.20.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c64e5973df366b747646fc98da921f9d6eba9716d57d1db94a83c026a08e0fb", size = 13742945, upload-time = "2026-04-21T17:08:34.181Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/65/0cd9285ab010ee8214c83d67c6b49417c40d86ce46f1aa109457b5a9b8d7/mypy-1.20.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a65aa591af023864fd08a97da9974e919452cfe19cb146c8a5dc692626445dc", size = 14706163, upload-time = "2026-04-21T17:05:15.51Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/97/48ff3b297cafcc94d185243a9190836fb1b01c1b0918fff64e941e973cc9/mypy-1.20.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4fef51b01e638974a6e69885687e9bd40c8d1e09a6cd291cca0619625cf1f558", size = 14938677, upload-time = "2026-04-21T17:05:39.562Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/a1/1b4233d255bdd0b38a1f284feeb1c143ca508c19184964e22f8d837ec851/mypy-1.20.2-cp314-cp314-win_amd64.whl", hash = "sha256:913485a03f1bcf5d279409a9d2b9ed565c151f61c09f29991e5faa14033da4c8", size = 11089322, upload-time = "2026-04-21T17:06:44.29Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/c2/ce7ee2ba36aeb954ba50f18fa25d9c1188578654b97d02a66a15b6f09531/mypy-1.20.2-cp314-cp314-win_arm64.whl", hash = "sha256:c3bae4f855d965b5453784300c12ffc63a548304ac7f99e55d4dc7c898673aa3", size = 10017775, upload-time = "2026-04-21T17:07:20.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/a1/9d93a7d0b5859af0ead82b4888b46df6c8797e1bc5e1e262a08518c6d48e/mypy-1.20.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2de3dcea53babc1c3237a19002bc3d228ce1833278f093b8d619e06e7cc79609", size = 15549002, upload-time = "2026-04-21T17:08:23.107Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/d2/09a6a10ee1bf0008f6c144d9676f2ca6a12512151b4e0ad0ff6c4fac5337/mypy-1.20.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:52b176444e2e5054dfcbcb8c75b0b719865c96247b37407184bbfca5c353f2c2", size = 14401942, upload-time = "2026-04-21T17:07:31.837Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/da/9594b75c3c019e805250bed3583bdf4443ff9e6ef08f97e39ae308cb06f2/mypy-1.20.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:688c3312e5dadb573a2c69c82af3a298d43ecf9e6d264e0f95df960b5f6ac19c", size = 15041649, upload-time = "2026-04-21T17:09:34.653Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/77/f75a65c278e6e8eba2071f7f5a90481891053ecc39878cc444634d892abe/mypy-1.20.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29752dbbf8cc53f89f6ac096d363314333045c257c9c75cbd189ca2de0455744", size = 15864588, upload-time = "2026-04-21T17:11:44.936Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/46/1a4e1c66e96c1a3246ddf5403d122ac9b0a8d2b7e65730b9d6533ba7a6d3/mypy-1.20.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:803203d2b6ea644982c644895c2f78b28d0e208bba7b27d9b921e0ec5eb207c6", size = 16093956, upload-time = "2026-04-21T17:10:17.683Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/2c/78a8851264dec38cd736ca5b8bc9380674df0dd0be7792f538916157716c/mypy-1.20.2-cp314-cp314t-win_amd64.whl", hash = "sha256:9bcb8aa397ff0093c824182fd76a935a9ba7ad097fcbef80ae89bf6c1731d8ec", size = 12568661, upload-time = "2026-04-21T17:11:54.473Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/01/cd7318aa03493322ce275a0e14f4f52b8896335e4e79d4fb8153a7ad2b77/mypy-1.20.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e061b58443f1736f8a37c48978d7ab581636d6ab03e3d4f99e3fa90463bb9382", size = 10389240, upload-time = "2026-04-21T17:09:42.719Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/9a/f23c163e25b11074188251b0b5a0342625fc1cdb6af604757174fa9acc9b/mypy-1.20.2-py3-none-any.whl", hash = "sha256:a94c5a76ab46c5e6257c7972b6c8cff0574201ca7dc05647e33e795d78680563", size = 2637314, upload-time = "2026-04-21T17:05:54.5Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -967,64 +984,64 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "psycopg-binary"
|
||||
version = "3.3.3"
|
||||
version = "3.3.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/d8/a763308a41e2ecfb6256ba0877d340c2f2b124c8b2746401863d96fa2c7a/psycopg_binary-3.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b3385b58b2fe408a13d084c14b8dcf468cd36cbbe774408250facc128f9fa75c", size = 4609758, upload-time = "2026-02-18T16:46:33.132Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/a9/f8a683e85400c1208685e7c895abc049dc13aa0b6ea989e6adf0a3681fe0/psycopg_binary-3.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1bef235a50a80f6aba05147002bc354559657cb6386dbd04d8e1c97d1d7cbe84", size = 4676740, upload-time = "2026-02-18T16:46:42.904Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/7d/03512c4aaac8a58fc3b1221f38293aa517a1950d10ef8646c72c49addc7d/psycopg_binary-3.3.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:97c839717bf8c8df3f6d983a20949c4fb22e2a34ee172e3e427ede363feda27b", size = 5496335, upload-time = "2026-02-18T16:46:51.517Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/bc/23319b4b1c2c0b810d225e1b6f16efbb16150074fc0ea96bfcabdf59ee09/psycopg_binary-3.3.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:48e500cf1c0984dacf1f28ea482c3cdbb4c2288d51c336c04bc64198ab21fc51", size = 5172032, upload-time = "2026-02-18T16:47:00.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/c8/6d61dc0a56654c558a37b2d9b2094e470aa12621305cc7935fd769122e32/psycopg_binary-3.3.3-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb36a08859b9432d94ea6b26ec41a2f98f83f14868c91321d0c1e11f672eeae7", size = 6763107, upload-time = "2026-02-18T16:47:11.784Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/b5/e2a3c90aa1059f5b5f593379caad7be3cc3c2ce1ddfc7730e39854e174fe/psycopg_binary-3.3.3-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dde92cfde09293fb63b3f547919ba7d73bd2654573c03502b3263dd0218e44e", size = 5006494, upload-time = "2026-02-18T16:47:17.062Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/3e/bf126e0a1f864e191b7f3eeea667ee2ce13d582b036255fb8b12946d1f7a/psycopg_binary-3.3.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:78c9ce98caaf82ac8484d269791c1b403d7598633e0e4e2fa1097baae244e2f1", size = 4533850, upload-time = "2026-02-18T16:47:21.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/d8/bb5e8d395deb945629aa0c65d12ab90ec3bfcbdf56be89e2a84d001864c9/psycopg_binary-3.3.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d593612758d0041cb13cb0003f7f8d3fabb7ad9319e651e78afae49b1cf5860e", size = 4223316, upload-time = "2026-02-18T16:47:25.82Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/70/33eef61b0f0fd41ebf93b9699f44067313a45016827f67b3c8cc41f0a7ab/psycopg_binary-3.3.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:f24e8e17035200a465c178e9ea945527ad0738118694184c450f1192a452ff25", size = 3954515, upload-time = "2026-02-18T16:47:30.434Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/db/27c2b3b9698e713e83e11e8540daa27516f9e90390ec21a41091cb15fcaf/psycopg_binary-3.3.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e7b607f0e14f2a4cf7e78a05ebd13df6144acfba87cb90842e70d3f125d9f53f", size = 4260274, upload-time = "2026-02-18T16:47:36.128Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/3b/71e5d603059bf5474215f573a3e2d357a4e95672b26e04d41674400d4862/psycopg_binary-3.3.3-cp310-cp310-win_amd64.whl", hash = "sha256:b27d3a23c79fa59557d2cc63a7e8bb4c7e022c018558eda36f9d7c4e6b99a6e0", size = 3557375, upload-time = "2026-02-18T16:47:42.799Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/c0/b389119dd754483d316805260f3e73cdcad97925839107cc7a296f6132b1/psycopg_binary-3.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a89bb9ee11177b2995d87186b1d9fa892d8ea725e85eab28c6525e4cc14ee048", size = 4609740, upload-time = "2026-02-18T16:47:51.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/e3/9976eef20f61840285174d360da4c820a311ab39d6b82fa09fbb545be825/psycopg_binary-3.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9f7d0cf072c6fbac3795b08c98ef9ea013f11db609659dcfc6b1f6cc31f9e181", size = 4676837, upload-time = "2026-02-18T16:47:55.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/f2/d28ba2f7404fd7f68d41e8a11df86313bd646258244cb12a8dd83b868a97/psycopg_binary-3.3.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:90eecd93073922f085967f3ed3a98ba8c325cbbc8c1a204e300282abd2369e13", size = 5497070, upload-time = "2026-02-18T16:47:59.929Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/2f/6c5c54b815edeb30a281cfcea96dc93b3bb6be939aea022f00cab7aa1420/psycopg_binary-3.3.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dac7ee2f88b4d7bb12837989ca354c38d400eeb21bce3b73dac02622f0a3c8d6", size = 5172410, upload-time = "2026-02-18T16:48:05.665Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/75/8206c7008b57de03c1ada46bd3110cc3743f3fd9ed52031c4601401d766d/psycopg_binary-3.3.3-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b62cf8784eb6d35beaee1056d54caf94ec6ecf2b7552395e305518ab61eb8fd2", size = 6763408, upload-time = "2026-02-18T16:48:13.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/5a/ea1641a1e6c8c8b3454b0fcb43c3045133a8b703e6e824fae134088e63bd/psycopg_binary-3.3.3-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a39f34c9b18e8f6794cca17bfbcd64572ca2482318db644268049f8c738f35a6", size = 5006255, upload-time = "2026-02-18T16:48:22.176Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/fb/538df099bf55ae1637d52d7ccb6b9620b535a40f4c733897ac2b7bb9e14c/psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:883d68d48ca9ff3cb3d10c5fdebea02c79b48eecacdddbf7cce6e7cdbdc216b8", size = 4532694, upload-time = "2026-02-18T16:48:27.338Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/d1/00780c0e187ea3c13dfc53bd7060654b2232cd30df562aac91a5f1c545ac/psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:cab7bc3d288d37a80aa8c0820033250c95e40b1c2b5c57cf59827b19c2a8b69d", size = 4222833, upload-time = "2026-02-18T16:48:31.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/34/a07f1ff713c51d64dc9f19f2c32be80299a2055d5d109d5853662b922cb4/psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:56c767007ca959ca32f796b42379fc7e1ae2ed085d29f20b05b3fc394f3715cc", size = 3952818, upload-time = "2026-02-18T16:48:35.869Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/67/d33f268a7759b4445f3c9b5a181039b01af8c8263c865c1be7a6444d4749/psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:da2f331a01af232259a21573a01338530c6016dcfad74626c01330535bcd8628", size = 4258061, upload-time = "2026-02-18T16:48:41.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/3b/0d8d2c5e8e29ccc07d28c8af38445d9d9abcd238d590186cac82ee71fc84/psycopg_binary-3.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:19f93235ece6dbfc4036b5e4f6d8b13f0b8f2b3eeb8b0bd2936d406991bcdd40", size = 3558915, upload-time = "2026-02-18T16:48:46.679Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/15/021be5c0cbc5b7c1ab46e91cc3434eb42569f79a0592e67b8d25e66d844d/psycopg_binary-3.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6698dbab5bcef8fdb570fc9d35fd9ac52041771bfcfe6fd0fc5f5c4e36f1e99d", size = 4591170, upload-time = "2026-02-18T16:48:55.594Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/54/a60211c346c9a2f8c6b272b5f2bbe21f6e11800ce7f61e99ba75cf8b63e1/psycopg_binary-3.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:329ff393441e75f10b673ae99ab45276887993d49e65f141da20d915c05aafd8", size = 4670009, upload-time = "2026-02-18T16:49:03.608Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/53/ac7c18671347c553362aadbf65f92786eef9540676ca24114cc02f5be405/psycopg_binary-3.3.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:eb072949b8ebf4082ae24289a2b0fd724da9adc8f22743409d6fd718ddb379df", size = 5469735, upload-time = "2026-02-18T16:49:10.128Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/c3/4f4e040902b82a344eff1c736cde2f2720f127fe939c7e7565706f96dd44/psycopg_binary-3.3.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:263a24f39f26e19ed7fc982d7859a36f17841b05bebad3eb47bb9cd2dd785351", size = 5152919, upload-time = "2026-02-18T16:49:16.335Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/e7/d929679c6a5c212bcf738806c7c89f5b3d0919f2e1685a0e08d6ff877945/psycopg_binary-3.3.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5152d50798c2fa5bd9b68ec68eb68a1b71b95126c1d70adaa1a08cd5eefdc23d", size = 6738785, upload-time = "2026-02-18T16:49:22.687Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/b0/09703aeb69a9443d232d7b5318d58742e8ca51ff79f90ffe6b88f1db45e7/psycopg_binary-3.3.3-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d6a1e56dd267848edb824dbeb08cf5bac649e02ee0b03ba883ba3f4f0bd54f2", size = 4979008, upload-time = "2026-02-18T16:49:27.313Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/a6/e662558b793c6e13a7473b970fee327d635270e41eded3090ef14045a6a5/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73eaaf4bb04709f545606c1db2f65f4000e8a04cdbf3e00d165a23004692093e", size = 4508255, upload-time = "2026-02-18T16:49:31.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/7f/0f8b2e1d5e0093921b6f324a948a5c740c1447fbb45e97acaf50241d0f39/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:162e5675efb4704192411eaf8e00d07f7960b679cd3306e7efb120bb8d9456cc", size = 4189166, upload-time = "2026-02-18T16:49:35.801Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/ec/ce2e91c33bc8d10b00c87e2f6b0fb570641a6a60042d6a9ae35658a3a797/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:fab6b5e37715885c69f5d091f6ff229be71e235f272ebaa35158d5a46fd548a0", size = 3924544, upload-time = "2026-02-18T16:49:41.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/2f/7718141485f73a924205af60041c392938852aa447a94c8cbd222ff389a1/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a4aab31bd6d1057f287c96c0effca3a25584eb9cc702f282ecb96ded7814e830", size = 4235297, upload-time = "2026-02-18T16:49:46.726Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/f9/1add717e2643a003bbde31b1b220172e64fbc0cb09f06429820c9173f7fc/psycopg_binary-3.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:59aa31fe11a0e1d1bcc2ce37ed35fe2ac84cd65bb9036d049b1a1c39064d0f14", size = 3547659, upload-time = "2026-02-18T16:49:52.999Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/0a/cac9fdf1df16a269ba0e5f0f06cac61f826c94cadb39df028cdfe19d3a33/psycopg_binary-3.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05f32239aec25c5fb15f7948cffdc2dc0dac098e48b80a140e4ba32b572a2e7d", size = 4590414, upload-time = "2026-02-18T16:50:01.441Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/c0/d8f8508fbf440edbc0099b1abff33003cd80c9e66eb3a1e78834e3fb4fb9/psycopg_binary-3.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c84f9d214f2d1de2fafebc17fa68ac3f6561a59e291553dfc45ad299f4898c1", size = 4669021, upload-time = "2026-02-18T16:50:08.803Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/05/097016b77e343b4568feddf12c72171fc513acef9a4214d21b9478569068/psycopg_binary-3.3.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e77957d2ba17cada11be09a5066d93026cdb61ada7c8893101d7fe1c6e1f3925", size = 5467453, upload-time = "2026-02-18T16:50:14.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/23/73244e5feb55b5ca109cede6e97f32ef45189f0fdac4c80d75c99862729d/psycopg_binary-3.3.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:42961609ac07c232a427da7c87a468d3c82fee6762c220f38e37cfdacb2b178d", size = 5151135, upload-time = "2026-02-18T16:50:24.82Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/49/5309473b9803b207682095201d8708bbc7842ddf3f192488a69204e36455/psycopg_binary-3.3.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae07a3114313dd91fce686cab2f4c44af094398519af0e0f854bc707e1aeedf1", size = 6737315, upload-time = "2026-02-18T16:50:35.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/5d/03abe74ef34d460b33c4d9662bf6ec1dd38888324323c1a1752133c10377/psycopg_binary-3.3.3-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d257c58d7b36a621dcce1d01476ad8b60f12d80eb1406aee4cf796f88b2ae482", size = 4979783, upload-time = "2026-02-18T16:50:42.067Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/6c/3fbf8e604e15f2f3752900434046c00c90bb8764305a1b81112bff30ba24/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:07c7211f9327d522c9c47560cae00a4ecf6687f4e02d779d035dd3177b41cb12", size = 4509023, upload-time = "2026-02-18T16:50:50.116Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/6b/1a06b43b7c7af756c80b67eac8bfaa51d77e68635a8a8d246e4f0bb7604a/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:8e7e9eca9b363dbedeceeadd8be97149d2499081f3c52d141d7cd1f395a91f83", size = 4185874, upload-time = "2026-02-18T16:50:55.97Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/d3/bf49e3dcaadba510170c8d111e5e69e5ae3f981c1554c5bb71c75ce354bb/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:cb85b1d5702877c16f28d7b92ba030c1f49ebcc9b87d03d8c10bf45a2f1c7508", size = 3925668, upload-time = "2026-02-18T16:51:03.299Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/92/0aac830ed6a944fe334404e1687a074e4215630725753f0e3e9a9a595b62/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4d4606c84d04b80f9138d72f1e28c6c02dc5ae0c7b8f3f8aaf89c681ce1cd1b1", size = 4234973, upload-time = "2026-02-18T16:51:09.097Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/96/102244653ee5a143ece5afe33f00f52fe64e389dfce8dbc87580c6d70d3d/psycopg_binary-3.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:74eae563166ebf74e8d950ff359be037b85723d99ca83f57d9b244a871d6c13b", size = 3551342, upload-time = "2026-02-18T16:51:13.892Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/71/7a57e5b12275fe7e7d84d54113f0226080423a869118419c9106c083a21c/psycopg_binary-3.3.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:497852c5eaf1f0c2d88ab74a64a8097c099deac0c71de1cbcf18659a8a04a4b2", size = 4607368, upload-time = "2026-02-18T16:51:19.295Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/04/cb834f120f2b2c10d4003515ef9ca9d688115b9431735e3936ae48549af8/psycopg_binary-3.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:258d1ea53464d29768bf25930f43291949f4c7becc706f6e220c515a63a24edd", size = 4687047, upload-time = "2026-02-18T16:51:23.84Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/e9/47a69692d3da9704468041aa5ed3ad6fc7f6bb1a5ae788d261a26bbca6c7/psycopg_binary-3.3.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:111c59897a452196116db12e7f608da472fbff000693a21040e35fc978b23430", size = 5487096, upload-time = "2026-02-18T16:51:29.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/b6/0e0dd6a2f802864a4ae3dbadf4ec620f05e3904c7842b326aafc43e5f464/psycopg_binary-3.3.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:17bb6600e2455993946385249a3c3d0af52cd70c1c1cdbf712e9d696d0b0bf1b", size = 5168720, upload-time = "2026-02-18T16:51:36.499Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/0d/977af38ac19a6b55d22dff508bd743fd7c1901e1b73657e7937c7cccb0a3/psycopg_binary-3.3.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:642050398583d61c9856210568eb09a8e4f2fe8224bf3be21b67a370e677eead", size = 6762076, upload-time = "2026-02-18T16:51:43.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/40/912a39d48322cf86895c0eaf2d5b95cb899402443faefd4b09abbba6b6e1/psycopg_binary-3.3.3-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:533efe6dc3a7cba5e2a84e38970786bb966306863e45f3db152007e9f48638a6", size = 4997623, upload-time = "2026-02-18T16:51:47.707Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/0c/c14d0e259c65dc7be854d926993f151077887391d5a081118907a9d89603/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5958dbf28b77ce2033482f6cb9ef04d43f5d8f4b7636e6963d5626f000efb23e", size = 4532096, upload-time = "2026-02-18T16:51:51.421Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/21/8b7c50a194cfca6ea0fd4d1f276158307785775426e90700ab2eba5cd623/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a6af77b6626ce92b5817bf294b4d45ec1a6161dba80fc2d82cdffdd6814fd023", size = 4208884, upload-time = "2026-02-18T16:51:57.336Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/2c/a4981bf42cf30ebba0424971d7ce70a222ae9b82594c42fc3f2105d7b525/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:47f06fcbe8542b4d96d7392c476a74ada521c5aebdb41c3c0155f6595fc14c8d", size = 3944542, upload-time = "2026-02-18T16:52:04.266Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/e9/b7c29b56aa0b85a4e0c4d89db691c1ceef08f46a356369144430c155a2f5/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e7800e6c6b5dc4b0ca7cc7370f770f53ac83886b76afda0848065a674231e856", size = 4254339, upload-time = "2026-02-18T16:52:10.444Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/5a/291d89f44d3820fffb7a04ebc8f3ef5dda4f542f44a5daea0c55a84abf45/psycopg_binary-3.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:165f22ab5a9513a3d7425ffb7fcc7955ed8ccaeef6d37e369d6cc1dff1582383", size = 3652796, upload-time = "2026-02-18T16:52:14.02Z" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1331,27 +1348,27 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.9"
|
||||
version = "0.15.12"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e6/97/e9f1ca355108ef7194e38c812ef40ba98c7208f47b13ad78d023caa583da/ruff-0.15.9.tar.gz", hash = "sha256:29cbb1255a9797903f6dde5ba0188c707907ff44a9006eb273b5a17bfa0739a2", size = 4617361, upload-time = "2026-04-02T18:17:20.829Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/1f/9cdfd0ac4b9d1e5a6cf09bedabdf0b56306ab5e333c85c87281273e7b041/ruff-0.15.9-py3-none-linux_armv6l.whl", hash = "sha256:6efbe303983441c51975c243e26dff328aca11f94b70992f35b093c2e71801e1", size = 10511206, upload-time = "2026-04-02T18:16:41.574Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/f6/32bfe3e9c136b35f02e489778d94384118bb80fd92c6d92e7ccd97db12ce/ruff-0.15.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4965bac6ac9ea86772f4e23587746f0b7a395eccabb823eb8bfacc3fa06069f7", size = 10923307, upload-time = "2026-04-02T18:17:08.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/25/de55f52ab5535d12e7aaba1de37a84be6179fb20bddcbe71ec091b4a3243/ruff-0.15.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eaf05aad70ca5b5a0a4b0e080df3a6b699803916d88f006efd1f5b46302daab8", size = 10316722, upload-time = "2026-04-02T18:16:44.206Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/11/690d75f3fd6278fe55fff7c9eb429c92d207e14b25d1cae4064a32677029/ruff-0.15.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9439a342adb8725f32f92732e2bafb6d5246bd7a5021101166b223d312e8fc59", size = 10623674, upload-time = "2026-04-02T18:16:50.951Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/ec/176f6987be248fc5404199255522f57af1b4a5a1b57727e942479fec98ad/ruff-0.15.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9c5e6faf9d97c8edc43877c3f406f47446fc48c40e1442d58cfcdaba2acea745", size = 10351516, upload-time = "2026-04-02T18:16:57.206Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/fc/51cffbd2b3f240accc380171d51446a32aa2ea43a40d4a45ada67368fbd2/ruff-0.15.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b34a9766aeec27a222373d0b055722900fbc0582b24f39661aa96f3fe6ad901", size = 11150202, upload-time = "2026-04-02T18:17:06.452Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/d4/25292a6dfc125f6b6528fe6af31f5e996e19bf73ca8e3ce6eb7fa5b95885/ruff-0.15.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89dd695bc72ae76ff484ae54b7e8b0f6b50f49046e198355e44ea656e521fef9", size = 11988891, upload-time = "2026-04-02T18:17:18.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/e1/1eebcb885c10e19f969dcb93d8413dfee8172578709d7ee933640f5e7147/ruff-0.15.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce187224ef1de1bd225bc9a152ac7102a6171107f026e81f317e4257052916d5", size = 11480576, upload-time = "2026-04-02T18:16:52.986Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/6b/a1548ac378a78332a4c3dcf4a134c2475a36d2a22ddfa272acd574140b50/ruff-0.15.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b0c7c341f68adb01c488c3b7d4b49aa8ea97409eae6462d860a79cf55f431b6", size = 11254525, upload-time = "2026-04-02T18:17:02.041Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/aa/4bb3af8e61acd9b1281db2ab77e8b2c3c5e5599bf2a29d4a942f1c62b8d6/ruff-0.15.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:55cc15eee27dc0eebdfcb0d185a6153420efbedc15eb1d38fe5e685657b0f840", size = 11204072, upload-time = "2026-04-02T18:17:13.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/48/d550dc2aa6e423ea0bcc1d0ff0699325ffe8a811e2dba156bd80750b86dc/ruff-0.15.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6537f6eed5cda688c81073d46ffdfb962a5f29ecb6f7e770b2dc920598997ed", size = 10594998, upload-time = "2026-04-02T18:16:46.369Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/47/321167e17f5344ed5ec6b0aa2cff64efef5f9e985af8f5622cfa6536043f/ruff-0.15.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6d3fcbca7388b066139c523bda744c822258ebdcfbba7d24410c3f454cc9af71", size = 10359769, upload-time = "2026-04-02T18:17:10.994Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/5e/074f00b9785d1d2c6f8c22a21e023d0c2c1817838cfca4c8243200a1fa87/ruff-0.15.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:058d8e99e1bfe79d8a0def0b481c56059ee6716214f7e425d8e737e412d69677", size = 10850236, upload-time = "2026-04-02T18:16:48.749Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/37/804c4135a2a2caf042925d30d5f68181bdbd4461fd0d7739da28305df593/ruff-0.15.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8e1ddb11dbd61d5983fa2d7d6370ef3eb210951e443cace19594c01c72abab4c", size = 11358343, upload-time = "2026-04-02T18:16:55.068Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/3d/1364fcde8656962782aa9ea93c92d98682b1ecec2f184e625a965ad3b4a6/ruff-0.15.9-py3-none-win32.whl", hash = "sha256:bde6ff36eaf72b700f32b7196088970bf8fdb2b917b7accd8c371bfc0fd573ec", size = 10583382, upload-time = "2026-04-02T18:17:04.261Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/56/5c7084299bd2cacaa07ae63a91c6f4ba66edc08bf28f356b24f6b717c799/ruff-0.15.9-py3-none-win_amd64.whl", hash = "sha256:45a70921b80e1c10cf0b734ef09421f71b5aa11d27404edc89d7e8a69505e43d", size = 11744969, upload-time = "2026-04-02T18:16:59.611Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/36/76704c4f312257d6dbaae3c959add2a622f63fcca9d864659ce6d8d97d3d/ruff-0.15.9-py3-none-win_arm64.whl", hash = "sha256:0694e601c028fd97dc5c6ee244675bc241aeefced7ef80cd9c6935a871078f53", size = 11005870, upload-time = "2026-04-02T18:17:15.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -3,6 +3,6 @@ from langgraph_sdk.client import get_client, get_sync_client
|
||||
from langgraph_sdk.encryption import Encryption
|
||||
from langgraph_sdk.encryption.types import EncryptionContext
|
||||
|
||||
__version__ = "0.3.13"
|
||||
__version__ = "0.3.14"
|
||||
|
||||
__all__ = ["Auth", "Encryption", "EncryptionContext", "get_client", "get_sync_client"]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user